Compare commits
114
Commits
@@ -0,0 +1,43 @@
|
||||
name: Bug Report
|
||||
about: Report a bug in Outpost
|
||||
labels:
|
||||
- "type: bug"
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Outpost is early alpha — please check the version in About (or your build's commit) is current before filing, and mention which platform (macOS only, for now) and OS version you're on.
|
||||
- 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 alpha and tracking Outline's own web app for parity (see [`CLAUDE.md`](../../CLAUDE.md) for the phased build order) — a request that's "just do what web Outline does" is easier to act on than a net-new idea.
|
||||
- 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/screenshots/**/*.png
|
||||
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,71 @@
|
||||
# Contributing to Outpost
|
||||
|
||||
Thank you for contributing. Please read this guide before opening issues or PRs.
|
||||
|
||||
Outpost is early alpha (`0.0.x`) — expect the codebase and conventions here to shift as Phase 1 (see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)) settles. If something in this guide is stale, flag it.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -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,721 @@
|
||||
import Foundation
|
||||
|
||||
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
|
||||
/// support at the existing protocol boundary, so no view model needs to know
|
||||
/// the network exists:
|
||||
///
|
||||
/// - **Reads** (`documentInfo`, `listDocuments`, `documentsList`,
|
||||
/// `listViewedDocuments`, `listCollections`, `collectionInfo`): read-through
|
||||
/// cache. Always tries live first — never pre-checks reachability, since
|
||||
/// "did this specific request just fail" is a more honest signal than a
|
||||
/// reachability monitor, and the two can disagree (captive portals,
|
||||
/// flaky Wi-Fi). Falls back to the cache only on failure; a live success
|
||||
/// always overwrites the cache, so staleness is bounded by "last time this
|
||||
/// endpoint actually worked."
|
||||
/// - **A small set of writes** (`updateDocument`, `updateCollection`,
|
||||
/// pin/star/subscribe create+delete): applied optimistically against the
|
||||
/// cache and queued in `OfflineCacheStore`'s `PendingOperation` table on
|
||||
/// failure, then replayed in order by `flushPendingOperations()` once back
|
||||
/// online. Deliberately doesn't include anything that creates new tree
|
||||
/// structure (`createDocument`, `moveDocument`, `archiveDocument`,
|
||||
/// `deleteDocument`, `deleteCollection`, `duplicateDocument`) — those need
|
||||
/// real server-assigned ids to stay consistent with the rest of the tree,
|
||||
/// and reconciling a locally-invented id with the one the server hands
|
||||
/// back on sync is a much bigger problem than this pass takes on. Sharing,
|
||||
/// permissions, search, and export also stay live-only — read the request,
|
||||
/// they need someone else's server session, not just a network.
|
||||
/// - **Manual offline mode** (`offlineModeDefaultsKey`): when set, skips
|
||||
/// attempting `live` entirely — same fallback/queue paths as a real
|
||||
/// failure, just chosen on purpose instead of discovered.
|
||||
public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
public static let offlineModeDefaultsKey = "outpost.offlineModeEnabled"
|
||||
|
||||
private let live: OutlineAPIClient
|
||||
private let cache: OfflineCacheStore
|
||||
private let defaults: UserDefaults
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
private let keyEncoder: JSONEncoder
|
||||
|
||||
public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) {
|
||||
self.live = live
|
||||
self.cache = cache
|
||||
self.defaults = defaults
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
self.encoder = encoder
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
self.decoder = decoder
|
||||
|
||||
let keyEncoder = JSONEncoder()
|
||||
keyEncoder.outputFormatting = .sortedKeys
|
||||
self.keyEncoder = keyEncoder
|
||||
}
|
||||
|
||||
private var isManualOfflineModeEnabled: Bool {
|
||||
defaults.bool(forKey: Self.offlineModeDefaultsKey)
|
||||
}
|
||||
|
||||
// MARK: - Cached reads
|
||||
|
||||
public func documentInfo(id: String) async throws -> OutlineDocument {
|
||||
try await cachedFetch(key: "document:\(id)") { try await self.live.documentInfo(id: id) }
|
||||
}
|
||||
|
||||
public func listDocuments(
|
||||
collectionId: String?,
|
||||
parentDocumentId: String?,
|
||||
offset: Int,
|
||||
limit: Int
|
||||
) async throws -> [OutlineDocument] {
|
||||
let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)"
|
||||
return try await cachedFetch(key: key) {
|
||||
try await self.live.listDocuments(
|
||||
collectionId: collectionId,
|
||||
parentDocumentId: parentDocumentId,
|
||||
offset: offset,
|
||||
limit: limit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
|
||||
try await cachedFetch(key: requestKey("documentsList", request)) { try await self.live.documentsList(request) }
|
||||
}
|
||||
|
||||
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
|
||||
try await cachedFetch(key: "documentsViewed:\(offset):\(limit)") {
|
||||
try await self.live.listViewedDocuments(offset: offset, limit: limit)
|
||||
}
|
||||
}
|
||||
|
||||
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
||||
try await cachedFetch(key: "collections:\(offset):\(limit)") {
|
||||
try await self.live.listCollections(offset: offset, limit: limit)
|
||||
}
|
||||
}
|
||||
|
||||
public func collectionInfo(id: String) async throws -> OutlineCollection {
|
||||
try await cachedFetch(key: "collection:\(id)") { try await self.live.collectionInfo(id: id) }
|
||||
}
|
||||
|
||||
// MARK: - Queueable writes
|
||||
|
||||
/// The one exception to "no new tree structure while offline" —
|
||||
/// synthesizes a document under a `pending-<uuid>` id (same scheme as
|
||||
/// pin/star/subscribe), caches it so it's immediately readable/editable,
|
||||
/// and queues the real create. On sync, the server-assigned id replaces
|
||||
/// the placeholder in the cache; anything still referencing the old id
|
||||
/// directly (a currently-open reader, most likely) won't follow that
|
||||
/// rename automatically — a known, narrow limitation, not something this
|
||||
/// pass tries to solve generally.
|
||||
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
let result = try await live.createDocument(request)
|
||||
await cacheDocument(result)
|
||||
return result
|
||||
} catch {
|
||||
return await queueDocumentCreate(request)
|
||||
}
|
||||
}
|
||||
return await queueDocumentCreate(request)
|
||||
}
|
||||
|
||||
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
let result = try await live.updateDocument(request)
|
||||
await cacheDocument(result)
|
||||
return result
|
||||
} catch {
|
||||
return try await queueDocumentUpdate(request, dueTo: error)
|
||||
}
|
||||
}
|
||||
return try await queueDocumentUpdate(request, dueTo: nil)
|
||||
}
|
||||
|
||||
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
let result = try await live.updateCollection(request)
|
||||
await cacheCollection(result)
|
||||
return result
|
||||
} catch {
|
||||
return try await queueCollectionUpdate(request, dueTo: error)
|
||||
}
|
||||
}
|
||||
return try await queueCollectionUpdate(request, dueTo: nil)
|
||||
}
|
||||
|
||||
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do { return try await live.createPin(request) } catch { return await queuePinCreate(request) }
|
||||
}
|
||||
return await queuePinCreate(request)
|
||||
}
|
||||
|
||||
public func deletePin(id: String) async throws {
|
||||
if await cancelIfNeverSynced(id: id) { return }
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
try await live.deletePin(id: id)
|
||||
return
|
||||
} catch {
|
||||
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
|
||||
return
|
||||
}
|
||||
}
|
||||
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
|
||||
}
|
||||
|
||||
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do { return try await live.createSubscription(request) } catch { return await queueSubscriptionCreate(request) }
|
||||
}
|
||||
return await queueSubscriptionCreate(request)
|
||||
}
|
||||
|
||||
public func deleteSubscription(id: String) async throws {
|
||||
if await cancelIfNeverSynced(id: id) { return }
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
try await live.deleteSubscription(id: id)
|
||||
return
|
||||
} catch {
|
||||
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
|
||||
return
|
||||
}
|
||||
}
|
||||
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
|
||||
}
|
||||
|
||||
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do { return try await live.starDocument(request) } catch { return await queueStarDocumentCreate(request) }
|
||||
}
|
||||
return await queueStarDocumentCreate(request)
|
||||
}
|
||||
|
||||
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do { return try await live.starCollection(request) } catch { return await queueStarCollectionCreate(request) }
|
||||
}
|
||||
return await queueStarCollectionCreate(request)
|
||||
}
|
||||
|
||||
public func deleteStar(id: String) async throws {
|
||||
if await cancelIfNeverSynced(id: id) { return }
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
try await live.deleteStar(id: id)
|
||||
return
|
||||
} catch {
|
||||
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
|
||||
return
|
||||
}
|
||||
}
|
||||
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Pass-through
|
||||
|
||||
public func authInfo() async throws -> OutlineAuthInfo {
|
||||
try await live.authInfo()
|
||||
}
|
||||
|
||||
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
|
||||
try await live.searchDocuments(request)
|
||||
}
|
||||
|
||||
public func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] {
|
||||
try await live.searchDocumentTitles(request)
|
||||
}
|
||||
|
||||
public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate {
|
||||
try await live.templatizeDocument(request)
|
||||
}
|
||||
|
||||
public func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] {
|
||||
try await live.duplicateDocument(request)
|
||||
}
|
||||
|
||||
public func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument {
|
||||
try await live.unpublishDocument(request)
|
||||
}
|
||||
|
||||
public func archiveDocument(id: String) async throws -> OutlineDocument {
|
||||
try await live.archiveDocument(id: id)
|
||||
}
|
||||
|
||||
public func moveDocument(_ request: MoveDocumentRequest) async throws {
|
||||
try await live.moveDocument(request)
|
||||
}
|
||||
|
||||
public func deleteDocument(_ request: DeleteDocumentRequest) async throws {
|
||||
try await live.deleteDocument(request)
|
||||
}
|
||||
|
||||
public func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] {
|
||||
try await live.documentInsights(request)
|
||||
}
|
||||
|
||||
public func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] {
|
||||
try await live.listRevisions(request)
|
||||
}
|
||||
|
||||
public func exportDocument(id: String) async throws -> String {
|
||||
try await live.exportDocument(id: id)
|
||||
}
|
||||
|
||||
public func createShare(_ request: CreateShareRequest) async throws -> OutlineShare {
|
||||
try await live.createShare(request)
|
||||
}
|
||||
|
||||
public func shareInfo(documentId: String) async throws -> OutlineShare? {
|
||||
try await live.shareInfo(documentId: documentId)
|
||||
}
|
||||
|
||||
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
|
||||
try await live.updateShare(request)
|
||||
}
|
||||
|
||||
public func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] {
|
||||
try await live.listShares(request)
|
||||
}
|
||||
|
||||
public func revokeShare(id: String) async throws {
|
||||
try await live.revokeShare(id: id)
|
||||
}
|
||||
|
||||
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
|
||||
try await live.listPins(request)
|
||||
}
|
||||
|
||||
public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] {
|
||||
try await live.listSubscriptions(request)
|
||||
}
|
||||
|
||||
public func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] {
|
||||
try await live.listViews(request)
|
||||
}
|
||||
|
||||
public func 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 deleteAttachment(id: String) async throws {
|
||||
try await live.deleteAttachment(id: id)
|
||||
}
|
||||
|
||||
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
|
||||
try await live.updateUserAvatar(request)
|
||||
}
|
||||
|
||||
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
|
||||
try await live.updateUserName(request)
|
||||
}
|
||||
|
||||
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
|
||||
try await live.updateUserLanguage(request)
|
||||
}
|
||||
|
||||
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
|
||||
try await live.updateUserPreferences(request)
|
||||
}
|
||||
|
||||
public func deleteAccount() async throws {
|
||||
try await live.deleteAccount()
|
||||
}
|
||||
|
||||
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||
try await live.subscribeToNotifications(eventType: eventType)
|
||||
}
|
||||
|
||||
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||
try await live.unsubscribeFromNotifications(eventType: eventType)
|
||||
}
|
||||
|
||||
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
|
||||
try await live.listApiKeys(request)
|
||||
}
|
||||
|
||||
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
|
||||
try await live.createApiKey(request)
|
||||
}
|
||||
|
||||
public func deleteApiKey(id: String) async throws {
|
||||
try await live.deleteApiKey(id: id)
|
||||
}
|
||||
|
||||
public func installationInfo() async throws -> OutlineInstallationInfo {
|
||||
try await live.installationInfo()
|
||||
}
|
||||
|
||||
// MARK: - Sync management (Settings surface)
|
||||
|
||||
public func pendingOperations() async -> [PendingOperationSummary] {
|
||||
await cache.pendingOperations().map {
|
||||
PendingOperationSummary(id: $0.id, kind: $0.kind, createdAt: $0.createdAt, attemptCount: $0.attemptCount, lastError: $0.lastError)
|
||||
}
|
||||
}
|
||||
|
||||
public func cacheStorageSummary() async -> CacheStorageSummary {
|
||||
CacheStorageSummary(itemCount: await cache.itemCount(), totalBytes: await cache.totalBytes())
|
||||
}
|
||||
|
||||
public func clearCache() async {
|
||||
await cache.clearAll()
|
||||
}
|
||||
|
||||
/// Replays every queued operation against `live`, in the order they were
|
||||
/// queued. Each is independent — one failing doesn't block the rest.
|
||||
public func flushPendingOperations() async -> SyncFlushSummary {
|
||||
let operations = await cache.pendingOperations()
|
||||
var succeeded = 0
|
||||
var failed = 0
|
||||
for operation in operations {
|
||||
do {
|
||||
try await replay(operation)
|
||||
await cache.removeOperation(id: operation.id)
|
||||
succeeded += 1
|
||||
} catch {
|
||||
await cache.recordFailure(id: operation.id, error: errorDescription(error))
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
return SyncFlushSummary(succeeded: succeeded, failed: failed)
|
||||
}
|
||||
|
||||
/// "Full Local Sync": eagerly walks every collection and caches every
|
||||
/// document's full content (not just whatever's been opened), so
|
||||
/// browsing offline works for the whole workspace, not only what was
|
||||
/// already viewed. Goes through `self`, not `live`, directly — the
|
||||
/// existing cached-read methods already do the caching as a side effect,
|
||||
/// this just has to drive the walk and separately cache each document by
|
||||
/// id (`listDocuments`'s cache key is the list, not the individual doc).
|
||||
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 {
|
||||
var offset = 0
|
||||
let limit = 100
|
||||
while true {
|
||||
let documents: [OutlineDocument]
|
||||
do {
|
||||
documents = try await listDocuments(collectionId: collection.id, parentDocumentId: nil, offset: offset, limit: limit)
|
||||
} catch {
|
||||
errors.append("\(collection.name): \(errorDescription(error))")
|
||||
break
|
||||
}
|
||||
for document in documents {
|
||||
await cacheDocument(document)
|
||||
}
|
||||
documentsCount += documents.count
|
||||
guard documents.count == limit else { break }
|
||||
offset += limit
|
||||
}
|
||||
}
|
||||
|
||||
return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date())
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
|
||||
// Manual offline mode means "skip the network entirely," not just
|
||||
// "prefer it" — without this check, a read would still hit `live`
|
||||
// (and succeed, showing content beyond whatever's cached) any time
|
||||
// the device actually had a connection, defeating the point of
|
||||
// deliberately testing/working as if offline.
|
||||
if isManualOfflineModeEnabled {
|
||||
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
|
||||
return cached
|
||||
}
|
||||
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||
}
|
||||
do {
|
||||
let result = try await fetch()
|
||||
if let data = try? encoder.encode(result) {
|
||||
await cache.save(data, forKey: key)
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
|
||||
return cached
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func requestKey(_ prefix: String, _ request: some Encodable) -> String {
|
||||
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
|
||||
return prefix
|
||||
}
|
||||
return "\(prefix):\(json)"
|
||||
}
|
||||
|
||||
private func cacheDocument(_ document: OutlineDocument) async {
|
||||
if let data = try? encoder.encode(document) {
|
||||
await cache.save(data, forKey: "document:\(document.id)")
|
||||
}
|
||||
}
|
||||
|
||||
private func cacheCollection(_ collection: OutlineCollection) async {
|
||||
if let data = try? encoder.encode(collection) {
|
||||
await cache.save(data, forKey: "collection:\(collection.id)")
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async {
|
||||
guard let data = try? encoder.encode(payload) else { return }
|
||||
await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data)
|
||||
}
|
||||
|
||||
/// A delete targeting a `pending-*` id can only mean "cancel the create
|
||||
/// still sitting in the queue" — the server has never heard of that id,
|
||||
/// so queuing the delete would just fail once synced. Returns whether it
|
||||
/// found (and removed) a matching create, meaning the caller is done.
|
||||
private func cancelIfNeverSynced(id: String) async -> Bool {
|
||||
guard id.hasPrefix("pending-") else { return false }
|
||||
return await cache.removeOperation(id: id)
|
||||
}
|
||||
|
||||
private func queueDocumentCreate(_ request: CreateDocumentRequest) async -> OutlineDocument {
|
||||
let pendingId = "pending-\(UUID().uuidString)"
|
||||
let now = Date()
|
||||
let synthesized = OutlineDocument(
|
||||
id: pendingId,
|
||||
title: request.title,
|
||||
text: request.text,
|
||||
emoji: nil,
|
||||
collectionId: request.collectionId,
|
||||
parentDocumentId: request.parentDocumentId,
|
||||
url: "/doc/\(pendingId)",
|
||||
revision: nil,
|
||||
fullWidth: nil,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
publishedAt: request.publish ? now : nil,
|
||||
archivedAt: nil,
|
||||
deletedAt: nil
|
||||
)
|
||||
await cacheDocument(synthesized)
|
||||
await enqueue(.createDocument, payload: request, id: pendingId)
|
||||
return synthesized
|
||||
}
|
||||
|
||||
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
|
||||
guard let baseData = await cache.load(forKey: "document:\(request.id)"),
|
||||
let base = try? decoder.decode(OutlineDocument.self, from: baseData) else {
|
||||
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||
}
|
||||
let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text)
|
||||
let merged = OutlineDocument(
|
||||
id: base.id,
|
||||
title: request.title ?? base.title,
|
||||
text: mergedText,
|
||||
emoji: base.emoji,
|
||||
collectionId: base.collectionId,
|
||||
parentDocumentId: base.parentDocumentId,
|
||||
url: base.url,
|
||||
revision: base.revision,
|
||||
fullWidth: request.fullWidth ?? base.fullWidth,
|
||||
createdAt: base.createdAt,
|
||||
updatedAt: Date(),
|
||||
publishedAt: base.publishedAt,
|
||||
archivedAt: base.archivedAt,
|
||||
deletedAt: base.deletedAt
|
||||
)
|
||||
await cacheDocument(merged)
|
||||
|
||||
// Still-unsynced create for this exact document — fold the edit into
|
||||
// the pending create's payload instead of queuing a separate update.
|
||||
// A separate update would target `merged.id`, which is still the
|
||||
// `pending-*` placeholder at replay time; the server has never heard
|
||||
// of it and the update would just fail every retry.
|
||||
if merged.id.hasPrefix("pending-"),
|
||||
let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }),
|
||||
let createRequest = try? decoder.decode(CreateDocumentRequest.self, from: createOp.payload) {
|
||||
let resolvedCreate = CreateDocumentRequest(
|
||||
title: merged.title,
|
||||
text: merged.text,
|
||||
collectionId: createRequest.collectionId,
|
||||
parentDocumentId: createRequest.parentDocumentId,
|
||||
publish: createRequest.publish
|
||||
)
|
||||
await enqueue(.createDocument, payload: resolvedCreate, id: merged.id)
|
||||
return merged
|
||||
}
|
||||
|
||||
// Fully resolved (not the original partial request) so replaying just
|
||||
// this one queued operation reproduces `merged` exactly — that's what
|
||||
// makes coalescing a second edit onto the same queued id safe.
|
||||
let resolved = UpdateDocumentRequest(id: merged.id, title: merged.title, text: merged.text, fullWidth: merged.fullWidth)
|
||||
await enqueue(.updateDocument, payload: resolved, id: "update-document-\(merged.id)")
|
||||
return merged
|
||||
}
|
||||
|
||||
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
|
||||
guard let baseData = await cache.load(forKey: "collection:\(request.id)"),
|
||||
let base = try? decoder.decode(OutlineCollection.self, from: baseData) else {
|
||||
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||
}
|
||||
let merged = OutlineCollection(
|
||||
id: base.id,
|
||||
name: request.name ?? base.name,
|
||||
description: request.description ?? base.description,
|
||||
color: base.color,
|
||||
icon: base.icon,
|
||||
createdAt: base.createdAt,
|
||||
updatedAt: Date()
|
||||
)
|
||||
await cacheCollection(merged)
|
||||
let resolved = UpdateCollectionRequest(id: merged.id, name: merged.name, description: merged.description)
|
||||
await enqueue(.updateCollection, payload: resolved, id: "update-collection-\(merged.id)")
|
||||
return merged
|
||||
}
|
||||
|
||||
private func queuePinCreate(_ request: CreatePinRequest) async -> OutlinePin {
|
||||
let pendingId = "pending-\(UUID().uuidString)"
|
||||
await enqueue(.createPin, payload: request, id: pendingId)
|
||||
return OutlinePin(id: pendingId, documentId: request.documentId, collectionId: request.collectionId, index: nil)
|
||||
}
|
||||
|
||||
private func queueSubscriptionCreate(_ request: CreateSubscriptionRequest) async -> OutlineSubscription {
|
||||
let pendingId = "pending-\(UUID().uuidString)"
|
||||
await enqueue(.createSubscription, payload: request, id: pendingId)
|
||||
return OutlineSubscription(id: pendingId, documentId: request.documentId, collectionId: nil, event: request.event)
|
||||
}
|
||||
|
||||
private func queueStarDocumentCreate(_ request: StarDocumentRequest) async -> OutlineStar {
|
||||
let pendingId = "pending-\(UUID().uuidString)"
|
||||
await enqueue(.starDocument, payload: request, id: pendingId)
|
||||
return OutlineStar(id: pendingId, index: nil, documentId: request.documentId, collectionId: nil)
|
||||
}
|
||||
|
||||
private func queueStarCollectionCreate(_ request: StarCollectionRequest) async -> OutlineStar {
|
||||
let pendingId = "pending-\(UUID().uuidString)"
|
||||
await enqueue(.starCollection, payload: request, id: pendingId)
|
||||
return OutlineStar(id: pendingId, index: nil, documentId: nil, collectionId: request.collectionId)
|
||||
}
|
||||
|
||||
private func replay(_ operation: PendingOperation) async throws {
|
||||
guard let kind = PendingOperationKind(rawValue: operation.kind) else {
|
||||
throw OutlineAPIError.decoding(DecodingError.dataCorrupted(
|
||||
DecodingError.Context(codingPath: [], debugDescription: "Unknown pending operation kind: \(operation.kind)")
|
||||
))
|
||||
}
|
||||
switch kind {
|
||||
case .createDocument:
|
||||
let request = try decoder.decode(CreateDocumentRequest.self, from: operation.payload)
|
||||
let result = try await live.createDocument(request)
|
||||
await cacheDocument(result)
|
||||
// The placeholder id (== operation.id) is now a dead orphan —
|
||||
// nothing server-side will ever answer to it again.
|
||||
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
|
||||
case .updateDocument:
|
||||
let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload)
|
||||
let result = try await live.updateDocument(request)
|
||||
await cacheDocument(result)
|
||||
case .updateCollection:
|
||||
let request = try decoder.decode(UpdateCollectionRequest.self, from: operation.payload)
|
||||
let result = try await live.updateCollection(request)
|
||||
await cacheCollection(result)
|
||||
case .createPin:
|
||||
let request = try decoder.decode(CreatePinRequest.self, from: operation.payload)
|
||||
_ = try await live.createPin(request)
|
||||
case .deletePin:
|
||||
let request = try decoder.decode(IDPayload.self, from: operation.payload)
|
||||
try await live.deletePin(id: request.id)
|
||||
case .createSubscription:
|
||||
let request = try decoder.decode(CreateSubscriptionRequest.self, from: operation.payload)
|
||||
_ = try await live.createSubscription(request)
|
||||
case .deleteSubscription:
|
||||
let request = try decoder.decode(IDPayload.self, from: operation.payload)
|
||||
try await live.deleteSubscription(id: request.id)
|
||||
case .starDocument:
|
||||
let request = try decoder.decode(StarDocumentRequest.self, from: operation.payload)
|
||||
_ = try await live.starDocument(request)
|
||||
case .deleteStar:
|
||||
let request = try decoder.decode(IDPayload.self, from: operation.payload)
|
||||
try await live.deleteStar(id: request.id)
|
||||
case .starCollection:
|
||||
let request = try decoder.decode(StarCollectionRequest.self, from: operation.payload)
|
||||
_ = try await live.starCollection(request)
|
||||
}
|
||||
}
|
||||
|
||||
private func errorDescription(_ error: Error) -> String {
|
||||
if let apiError = error as? OutlineAPIError {
|
||||
switch apiError {
|
||||
case .unauthorized: return "Sign-in expired."
|
||||
case .notFound: return "Not found on the server."
|
||||
case .server(let status, let message): return message ?? "Server error (\(status))."
|
||||
case .decoding: return "Unexpected response shape."
|
||||
case .transport: return "Couldn't reach the server."
|
||||
case .tokenUnavailable: return "Couldn't access the saved sign-in."
|
||||
}
|
||||
}
|
||||
return String(describing: error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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
|
||||
}
|
||||
|
||||
/// Used to drop a temporary `pending-*` document's cache entry once a
|
||||
/// queued create syncs and the server hands back the real id — the
|
||||
/// placeholder key would otherwise sit around as a dead orphan forever.
|
||||
public func removeCacheEntry(forKey key: String) {
|
||||
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
|
||||
guard let existing = try? modelContext.fetch(descriptor).first else { return }
|
||||
modelContext.delete(existing)
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
public func itemCount() -> Int {
|
||||
(try? modelContext.fetchCount(FetchDescriptor<CachedPayload>())) ?? 0
|
||||
}
|
||||
|
||||
public func totalBytes() -> Int {
|
||||
let descriptor = FetchDescriptor<CachedPayload>()
|
||||
let rows = (try? modelContext.fetch(descriptor)) ?? []
|
||||
return rows.reduce(0) { $0 + $1.payload.count }
|
||||
}
|
||||
|
||||
public func clearAll() {
|
||||
let descriptor = FetchDescriptor<CachedPayload>()
|
||||
guard let rows = try? modelContext.fetch(descriptor) else { return }
|
||||
rows.forEach { modelContext.delete($0) }
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
// MARK: - Offline write queue
|
||||
|
||||
/// Upserts by `id` — a second call with the same id (an edit coalescing
|
||||
/// onto a still-unsynced edit, or a create being cancelled by its own
|
||||
/// synthesized id) replaces the row in place rather than piling up.
|
||||
public func enqueueOperation(id: String, kind: String, payload: Data) {
|
||||
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
|
||||
if let existing = try? modelContext.fetch(descriptor).first {
|
||||
existing.kind = kind
|
||||
existing.payload = payload
|
||||
existing.lastError = nil
|
||||
existing.attemptCount = 0
|
||||
} else {
|
||||
modelContext.insert(PendingOperation(id: id, kind: kind, payload: payload, createdAt: Date()))
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
public func pendingOperations() -> [PendingOperation] {
|
||||
let descriptor = FetchDescriptor<PendingOperation>(sortBy: [SortDescriptor(\.createdAt)])
|
||||
return (try? modelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Returns whether a matching operation was actually found and removed —
|
||||
/// callers use this to detect "this targeted something that never made
|
||||
/// it to the server in the first place" and skip queuing a delete.
|
||||
@discardableResult
|
||||
public func removeOperation(id: String) -> Bool {
|
||||
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
|
||||
guard let existing = try? modelContext.fetch(descriptor).first else { return false }
|
||||
modelContext.delete(existing)
|
||||
try? modelContext.save()
|
||||
return true
|
||||
}
|
||||
|
||||
public func recordFailure(id: String, error: String) {
|
||||
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
|
||||
guard let existing = try? modelContext.fetch(descriptor).first else { return }
|
||||
existing.lastAttemptAt = Date()
|
||||
existing.lastError = error
|
||||
existing.attemptCount += 1
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// A queued mutation made while offline, waiting to replay against the live
|
||||
/// server. `id` is deliberately overloaded: for actions that create a new
|
||||
/// server-side record (pin, star, subscription), it's also the synthesized
|
||||
/// placeholder id handed back to the caller immediately — so a matching
|
||||
/// delete queued before that create ever syncs can cancel both out by id
|
||||
/// instead of hitting a server that's never heard of the placeholder. For
|
||||
/// actions that edit an existing record (document/collection updates), it's
|
||||
/// deterministic per target id, so a second edit before the first syncs
|
||||
/// coalesces into one queued operation instead of piling up.
|
||||
@Model
|
||||
public final class PendingOperation {
|
||||
@Attribute(.unique) public var id: String
|
||||
public var kind: String
|
||||
public var payload: Data
|
||||
public var createdAt: Date
|
||||
public var lastAttemptAt: Date?
|
||||
public var lastError: String?
|
||||
public var attemptCount: Int
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
kind: String,
|
||||
payload: Data,
|
||||
createdAt: Date,
|
||||
lastAttemptAt: Date? = nil,
|
||||
lastError: String? = nil,
|
||||
attemptCount: Int = 0
|
||||
) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.payload = payload
|
||||
self.createdAt = createdAt
|
||||
self.lastAttemptAt = lastAttemptAt
|
||||
self.lastError = lastError
|
||||
self.attemptCount = attemptCount
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
/// UI-facing snapshot of a queued offline mutation — deliberately not the
|
||||
/// `@Model` type itself, so Settings can display it without holding a
|
||||
/// reference into the SwiftData store.
|
||||
public struct PendingOperationSummary: Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let kind: String
|
||||
public let createdAt: Date
|
||||
public let attemptCount: Int
|
||||
public let lastError: String?
|
||||
}
|
||||
|
||||
public struct CacheStorageSummary: Sendable {
|
||||
public let itemCount: Int
|
||||
public let totalBytes: Int
|
||||
}
|
||||
|
||||
public struct SyncFlushSummary: Sendable {
|
||||
public let succeeded: Int
|
||||
public let failed: Int
|
||||
}
|
||||
|
||||
public struct FullSyncSummary: Sendable {
|
||||
public let collectionsCount: Int
|
||||
public let documentsCount: Int
|
||||
public let errors: [String]
|
||||
public let finishedAt: Date
|
||||
}
|
||||
|
||||
/// Every mutation `CachingOutlineAPIClient` knows how to queue offline and
|
||||
/// replay later. Deliberately a small, explicit set — see its doc comment
|
||||
/// for what's excluded and why.
|
||||
enum PendingOperationKind: String, Codable, Sendable {
|
||||
case createDocument
|
||||
case updateDocument
|
||||
case updateCollection
|
||||
case createPin
|
||||
case deletePin
|
||||
case createSubscription
|
||||
case deleteSubscription
|
||||
case starDocument
|
||||
case deleteStar
|
||||
case starCollection
|
||||
}
|
||||
|
||||
/// Shared payload shape for the delete-by-id queueable operations.
|
||||
struct IDPayload: Codable, Sendable {
|
||||
let id: String
|
||||
}
|
||||
@@ -9,6 +9,10 @@ public protocol OutlineAPIClient: Sendable {
|
||||
|
||||
func documentInfo(id: String) 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]
|
||||
/// Full-text search with snippets/ranking. Backed by `documents.search`.
|
||||
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
|
||||
/// Title-only search — faster, no snippets. Backed by `documents.search_titles`.
|
||||
@@ -31,6 +35,8 @@ public protocol OutlineAPIClient: Sendable {
|
||||
func createShare(_ request: CreateShareRequest) async throws -> OutlineShare
|
||||
func shareInfo(documentId: String) 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.
|
||||
func createPin(_ request: CreatePinRequest) async throws -> OutlinePin
|
||||
func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin]
|
||||
@@ -42,6 +48,14 @@ public protocol OutlineAPIClient: Sendable {
|
||||
/// Historical view records, not live presence. Backed by `views.list`.
|
||||
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView]
|
||||
|
||||
/// 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 collectionInfo(id: String) async throws -> OutlineCollection
|
||||
func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection
|
||||
@@ -55,4 +69,40 @@ public protocol OutlineAPIClient: Sendable {
|
||||
func deleteStar(id: String) async throws
|
||||
|
||||
func currentUser() async throws -> OutlineUser
|
||||
|
||||
/// Two-step presigned upload: this requests where/how to upload,
|
||||
/// `uploadAttachmentFile` performs the actual multipart POST to that
|
||||
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
|
||||
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
|
||||
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws
|
||||
/// 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,14 @@ 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 searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
|
||||
try await post("documents.search", body: request)
|
||||
}
|
||||
@@ -113,23 +121,38 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
}
|
||||
|
||||
public func shareInfo(documentId: String) async throws -> OutlineShare? {
|
||||
do {
|
||||
return try await post("shares.info", body: ShareInfoRequest(documentId: documentId))
|
||||
} catch OutlineAPIError.notFound {
|
||||
return nil
|
||||
}
|
||||
// Real shape confirmed against a live server: `data` is
|
||||
// `{ shares: [...] }`, not the bare share object the docs imply —
|
||||
// same pattern as `pins.list`. A document could in principle have
|
||||
// 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 {
|
||||
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 {
|
||||
try await post("pins.create", body: request)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -152,6 +175,22 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
try await post("views.list", body: request)
|
||||
}
|
||||
|
||||
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] {
|
||||
try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit))
|
||||
}
|
||||
@@ -190,6 +229,103 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
try await post("users.info", body: EmptyParams())
|
||||
}
|
||||
|
||||
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
|
||||
try await post("attachments.create", body: request)
|
||||
}
|
||||
|
||||
/// No Bearer/CSRF header attached here, deliberately — the presigned
|
||||
/// `form.sig` field (short-lived, scoped to this exact upload key) is
|
||||
/// what authorizes this specific request, the same way an S3 presigned
|
||||
/// POST works. Best-effort against a live server: if it turns out the
|
||||
/// self-hosted local-storage backend also wants a bearer token here,
|
||||
/// that's a one-line addition once confirmed, not a design change.
|
||||
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
|
||||
let (body, contentType) = MultipartFormDataBuilder.build(
|
||||
fields: result.form,
|
||||
fileFieldName: "file",
|
||||
fileName: result.attachment.name ?? "avatar",
|
||||
fileData: fileData,
|
||||
fileContentType: result.form["Content-Type"] ?? "application/octet-stream"
|
||||
)
|
||||
|
||||
// `uploadUrl` is a host-relative path (e.g. "/api/files.create") on
|
||||
// a self-hosted local-storage backend, not an absolute S3 URL —
|
||||
// resolving against `baseURL` handles both: `URL(string:relativeTo:)`
|
||||
// replaces the whole path for a leading-slash relative string per
|
||||
// RFC 3986, same resolution already used for user/team avatar URLs.
|
||||
guard let uploadURL = URL(string: result.uploadUrl, relativeTo: baseURL)?.absoluteURL else {
|
||||
throw OutlineAPIError.transport(URLError(.badURL))
|
||||
}
|
||||
|
||||
var request = URLRequest(url: uploadURL)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = body
|
||||
|
||||
let data: Data
|
||||
let response: HTTPURLResponse
|
||||
do {
|
||||
(data, response) = try await httpClient.send(request)
|
||||
} catch let error as OutlineAPIError {
|
||||
throw error
|
||||
} catch {
|
||||
throw OutlineAPIError.transport(error)
|
||||
}
|
||||
|
||||
guard (200...299).contains(response.statusCode) else {
|
||||
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
|
||||
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteAttachment(id: String) async throws {
|
||||
try await postForSuccess("attachments.delete", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
|
||||
try await post("users.update", body: request)
|
||||
}
|
||||
|
||||
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
|
||||
try await post("users.update", body: request)
|
||||
}
|
||||
|
||||
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
|
||||
try await post("users.update", body: request)
|
||||
}
|
||||
|
||||
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
|
||||
try await post("users.update", body: request)
|
||||
}
|
||||
|
||||
public func deleteAccount() async throws {
|
||||
try await postForSuccess("users.delete", body: EmptyParams())
|
||||
}
|
||||
|
||||
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||
try await post("users.notificationsSubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
||||
}
|
||||
|
||||
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||
try await post("users.notificationsUnsubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
||||
}
|
||||
|
||||
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
|
||||
try await post("apiKeys.list", body: request)
|
||||
}
|
||||
|
||||
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
|
||||
try await post("apiKeys.create", body: request)
|
||||
}
|
||||
|
||||
public func deleteApiKey(id: String) async throws {
|
||||
try await postForSuccess("apiKeys.delete", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
public func installationInfo() async throws -> OutlineInstallationInfo {
|
||||
try await post("installation.info", body: EmptyParams())
|
||||
}
|
||||
|
||||
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
||||
guard let token = try? tokenStore.token() else {
|
||||
throw OutlineAPIError.tokenUnavailable
|
||||
@@ -230,6 +366,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": ... }`
|
||||
/// (e.g. `collections.delete`) — `post(_:body:)`'s envelope decode doesn't fit.
|
||||
private func postForSuccess<Body: Encodable>(_ path: String, body: Body) async throws {
|
||||
@@ -286,6 +468,15 @@ private struct DuplicateDocumentResponse: Decodable {
|
||||
let documents: [OutlineDocument]
|
||||
}
|
||||
|
||||
private struct PinsListPayload: Decodable {
|
||||
let pins: [OutlinePin]
|
||||
let documents: [OutlineDocument]
|
||||
}
|
||||
|
||||
private struct SharesInfoPayload: Decodable {
|
||||
let shares: [OutlineShare]
|
||||
}
|
||||
|
||||
private struct ListStarsResponse: Decodable {
|
||||
let stars: [OutlineStar]
|
||||
}
|
||||
@@ -314,6 +505,11 @@ private struct DocumentListParams: Encodable {
|
||||
let limit: Int
|
||||
}
|
||||
|
||||
private struct PaginationParams: Encodable {
|
||||
let offset: Int
|
||||
let limit: Int
|
||||
}
|
||||
|
||||
private struct CollectionListParams: Encodable {
|
||||
let offset: Int
|
||||
let limit: Int
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
/// The subset of Outline's notification event types this app exposes a
|
||||
/// toggle for. Wire values confirmed live (captured `users.update`
|
||||
/// responses showing the full `notificationSettings` dictionary after
|
||||
/// toggling each one in Outline's own web app). The server tracks more
|
||||
/// event types than this app has UI for (`revisions.create`,
|
||||
/// `emails.onboarding`, `emails.features` were also seen live) — those are
|
||||
/// left alone since `users.notificationsSubscribe`/`Unsubscribe` are
|
||||
/// per-event, not a whole-object replace like `preferences`, so there's no
|
||||
/// clobbering risk in only covering a subset.
|
||||
public enum NotificationEventType: String, CaseIterable, Sendable {
|
||||
case documentPublish = "documents.publish"
|
||||
case documentUpdate = "documents.update"
|
||||
case commentCreate = "comments.create"
|
||||
case commentMentioned = "comments.mentioned"
|
||||
case documentMentioned = "documents.mentioned"
|
||||
case commentGroupMentioned = "comments.group_mentioned"
|
||||
case documentGroupMentioned = "documents.group_mentioned"
|
||||
case commentResolve = "comments.resolve"
|
||||
case reactionCreate = "reactions.create"
|
||||
case collectionCreate = "collections.create"
|
||||
case emailsInviteAccepted = "emails.invite_accepted"
|
||||
case documentAddUser = "documents.add_user"
|
||||
case collectionAddUser = "collections.add_user"
|
||||
case emailsExportCompleted = "emails.export_completed"
|
||||
case accessRequestCreate = "access_requests.create"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
/// A personal API key (Settings → API & Access). Only the last 4 characters
|
||||
/// of the actual token are ever returned by the server — there's no way to
|
||||
/// see a full key again after creation, matching every other API-key UI
|
||||
/// convention.
|
||||
public struct OutlineAPIKey: Codable, Identifiable, Hashable, Sendable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let last4: String?
|
||||
public let scope: [String]?
|
||||
public let createdAt: Date
|
||||
public let expiresAt: Date?
|
||||
public let lastActiveAt: Date?
|
||||
/// The full plaintext key — present *only* in `apiKeys.create`'s
|
||||
/// response, confirmed live: `apiKeys.list` never includes it, matching
|
||||
/// "shown once at creation" being enforced server-side, not just a
|
||||
/// client-side UI convention this app has to uphold on its own.
|
||||
public let value: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
last4: String? = nil,
|
||||
scope: [String]? = nil,
|
||||
createdAt: Date,
|
||||
expiresAt: Date? = nil,
|
||||
lastActiveAt: Date? = nil,
|
||||
value: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.last4 = last4
|
||||
self.scope = scope
|
||||
self.createdAt = createdAt
|
||||
self.expiresAt = expiresAt
|
||||
self.lastActiveAt = lastActiveAt
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// One uploaded file — created via a two-step presigned upload
|
||||
/// (`attachments.create` for the upload target, then a direct POST to
|
||||
/// `uploadUrl`/`form`). Confirmed live against a self-hosted instance's
|
||||
/// local-storage backend; `size` comes back as a string there, not a
|
||||
/// number — same "don't trust the vendored spec's implied types" lesson as
|
||||
/// everywhere else this codebase has hit it.
|
||||
public struct OutlineAttachment: Decodable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let documentId: String?
|
||||
public let contentType: String?
|
||||
public let name: String?
|
||||
public let url: String?
|
||||
public let size: String?
|
||||
}
|
||||
|
||||
/// `attachments.create`'s response — everything needed to perform the
|
||||
/// actual upload. `form` fields (Content-Type, key, acl, sig, `_csrf`, …)
|
||||
/// must all be included as their own multipart parts, in the same request
|
||||
/// as the file itself, POSTed to `uploadUrl`.
|
||||
public struct CreateAttachmentResult: Decodable, Sendable {
|
||||
public let attachment: OutlineAttachment
|
||||
public let uploadUrl: String
|
||||
public let form: [String: String]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
/// `installation.info` — the self-hosted server's own version, confirmed
|
||||
/// live. `policies` (a separate top-level array alongside `data` in the raw
|
||||
/// response) isn't modeled here — not used by this app's Installation
|
||||
/// settings page.
|
||||
public struct OutlineInstallationInfo: Decodable, Sendable {
|
||||
public let version: String
|
||||
public let latestVersion: String
|
||||
public let versionsBehind: Int
|
||||
}
|
||||
@@ -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
|
||||
|
||||
/// A document pinned to the top of a collection (or the team home), backed by
|
||||
/// `pins.*`. Not in the vendored OpenAPI spec (`docs/reference/outline-openapi`)
|
||||
/// — that spec has no `Pins` tag at all — but the endpoint exists on Outline's
|
||||
/// actual server (`server/routes/api/pins.ts` upstream). Shape reconstructed
|
||||
/// from general knowledge of Outline's API, not verified against this spec;
|
||||
/// treat field names as best-effort until confirmed against a live server.
|
||||
/// A document pinned to the top of a collection, or to team Home when
|
||||
/// `collectionId` is `nil`. Backed by `pins.*` — not in the vendored OpenAPI
|
||||
/// spec (`docs/reference/outline-openapi`, no `Pins` tag at all), but
|
||||
/// confirmed real against a live server's network traffic. `collectionId: nil`
|
||||
/// is what "Pin to Home" actually sends; a non-nil value is "Pin to
|
||||
/// Collection", a distinct action.
|
||||
public struct OutlinePin: Decodable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let documentId: String
|
||||
|
||||
@@ -1,10 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
/// 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 let id: String
|
||||
public let published: Bool
|
||||
|
||||
public let documentId: String?
|
||||
public let collectionId: String?
|
||||
public let url: String
|
||||
public let published: Bool
|
||||
public let documentTitle: String?
|
||||
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 avatarUrl: String?
|
||||
public let role: String?
|
||||
public let language: String?
|
||||
public let preferences: OutlineUserPreferences?
|
||||
/// Keyed by `NotificationEventType`'s raw values, plus event types this
|
||||
/// app has no UI for — kept as a flexible dictionary rather than a
|
||||
/// fixed struct for that reason. `true` means subscribed.
|
||||
public let notificationSettings: [String: Bool]?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
email: String? = nil,
|
||||
avatarUrl: String? = nil,
|
||||
role: String? = nil
|
||||
role: String? = nil,
|
||||
language: String? = nil,
|
||||
preferences: OutlineUserPreferences? = nil,
|
||||
notificationSettings: [String: Bool]? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.email = email
|
||||
self.avatarUrl = avatarUrl
|
||||
self.role = role
|
||||
self.language = language
|
||||
self.preferences = preferences
|
||||
self.notificationSettings = notificationSettings
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
|
||||
/// `User.preferences` — a free-form JSON blob on Outline's own `User` row,
|
||||
/// not a fixed-shape API resource. Wire key names below are confirmed
|
||||
/// against a live server's own web app traffic (captured toggling every
|
||||
/// Preferences setting one at a time), not guessed — see the `CodingKeys`
|
||||
/// mapping for the two that don't match this struct's own property names.
|
||||
///
|
||||
/// The server also validates `preferences` against a known key allowlist
|
||||
/// and rejects the whole `users.update` call (not just the bad field) if
|
||||
/// any key it doesn't recognize is present — confirmed live via a
|
||||
/// `"notificationBadge: Invalid Input"` error when an earlier, wrong value
|
||||
/// was sent. That's also why `fullWidthDocuments` is kept here even though
|
||||
/// this app has no UI for it yet: this app always sends the *whole*
|
||||
/// preferences object back on every save (see
|
||||
/// `UpdateUserPreferencesRequest`), so silently dropping an unknown key
|
||||
/// during decode would permanently clear it the next time any other
|
||||
/// preference here gets saved.
|
||||
public struct OutlineUserPreferences: Codable, Hashable, Sendable {
|
||||
public var rememberLastPath: Bool?
|
||||
/// App-facing polarity: `true` means "separate editing mode is on" —
|
||||
/// the opposite of the wire's own `seamlessEdit` (seamless editing and
|
||||
/// separate editing modes are each other's negation), inverted in
|
||||
/// `init(from:)`/`encode(to:)` so nothing outside this file has to
|
||||
/// remember that.
|
||||
public var separateEditing: Bool?
|
||||
public var useCursorPointer: Bool?
|
||||
public var codeBlockLineNumbers: Bool?
|
||||
public var showCommentMarker: Bool?
|
||||
public var smartText: Bool?
|
||||
/// One of `NotificationBadgeStyle`'s raw values.
|
||||
public var notificationBadge: String?
|
||||
/// No UI in this app yet — preserved purely so saving any other
|
||||
/// preference here doesn't clobber it. See the type doc comment.
|
||||
public var fullWidthDocuments: Bool?
|
||||
|
||||
public init(
|
||||
rememberLastPath: Bool? = nil,
|
||||
separateEditing: Bool? = nil,
|
||||
useCursorPointer: Bool? = nil,
|
||||
codeBlockLineNumbers: Bool? = nil,
|
||||
showCommentMarker: Bool? = nil,
|
||||
smartText: Bool? = nil,
|
||||
notificationBadge: String? = nil,
|
||||
fullWidthDocuments: Bool? = nil
|
||||
) {
|
||||
self.rememberLastPath = rememberLastPath
|
||||
self.separateEditing = separateEditing
|
||||
self.useCursorPointer = useCursorPointer
|
||||
self.codeBlockLineNumbers = codeBlockLineNumbers
|
||||
self.showCommentMarker = showCommentMarker
|
||||
self.smartText = smartText
|
||||
self.notificationBadge = notificationBadge
|
||||
self.fullWidthDocuments = fullWidthDocuments
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case rememberLastPath
|
||||
case seamlessEdit
|
||||
case useCursorPointer
|
||||
case codeBlockLineNumbers
|
||||
case commentsInGutter
|
||||
case enableSmartText
|
||||
case notificationBadge
|
||||
case fullWidthDocuments
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
rememberLastPath = try container.decodeIfPresent(Bool.self, forKey: .rememberLastPath)
|
||||
separateEditing = try container.decodeIfPresent(Bool.self, forKey: .seamlessEdit).map { !$0 }
|
||||
useCursorPointer = try container.decodeIfPresent(Bool.self, forKey: .useCursorPointer)
|
||||
codeBlockLineNumbers = try container.decodeIfPresent(Bool.self, forKey: .codeBlockLineNumbers)
|
||||
showCommentMarker = try container.decodeIfPresent(Bool.self, forKey: .commentsInGutter)
|
||||
smartText = try container.decodeIfPresent(Bool.self, forKey: .enableSmartText)
|
||||
notificationBadge = try container.decodeIfPresent(String.self, forKey: .notificationBadge)
|
||||
fullWidthDocuments = try container.decodeIfPresent(Bool.self, forKey: .fullWidthDocuments)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encodeIfPresent(rememberLastPath, forKey: .rememberLastPath)
|
||||
try container.encodeIfPresent(separateEditing.map { !$0 }, forKey: .seamlessEdit)
|
||||
try container.encodeIfPresent(useCursorPointer, forKey: .useCursorPointer)
|
||||
try container.encodeIfPresent(codeBlockLineNumbers, forKey: .codeBlockLineNumbers)
|
||||
try container.encodeIfPresent(showCommentMarker, forKey: .commentsInGutter)
|
||||
try container.encodeIfPresent(smartText, forKey: .enableSmartText)
|
||||
try container.encodeIfPresent(notificationBadge, forKey: .notificationBadge)
|
||||
try container.encodeIfPresent(fullWidthDocuments, forKey: .fullWidthDocuments)
|
||||
}
|
||||
}
|
||||
|
||||
/// App-icon unread indicator style. Wire values confirmed live (captured
|
||||
/// setting all three from Outline's own web app).
|
||||
public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable {
|
||||
case none = "disabled"
|
||||
case unreadIndicator = "indicator"
|
||||
case unreadCount = "count"
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .none: return "None"
|
||||
case .unreadIndicator: return "Unread Indicator"
|
||||
case .unreadCount: return "Unread Count"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
/// Builds the multipart/form-data body for Outline's presigned-upload
|
||||
/// targets (`attachments.create`'s `uploadUrl`/`form`) — an S3-style
|
||||
/// presigned POST: every `form` field has to ride along as its own part in
|
||||
/// the same request as the file, not as query params or headers.
|
||||
enum MultipartFormDataBuilder {
|
||||
static func build(
|
||||
fields: [String: String],
|
||||
fileFieldName: String,
|
||||
fileName: String,
|
||||
fileData: Data,
|
||||
fileContentType: String,
|
||||
boundary: String = "Boundary-\(UUID().uuidString)"
|
||||
) -> (body: Data, contentType: String) {
|
||||
var body = Data()
|
||||
|
||||
for (key, value) in fields {
|
||||
body.append("--\(boundary)\r\n".utf8Data)
|
||||
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".utf8Data)
|
||||
body.append(value.utf8Data)
|
||||
body.append("\r\n".utf8Data)
|
||||
}
|
||||
|
||||
body.append("--\(boundary)\r\n".utf8Data)
|
||||
body.append(
|
||||
"Content-Disposition: form-data; name=\"\(fileFieldName)\"; filename=\"\(fileName)\"\r\n".utf8Data
|
||||
)
|
||||
body.append("Content-Type: \(fileContentType)\r\n\r\n".utf8Data)
|
||||
body.append(fileData)
|
||||
body.append("\r\n".utf8Data)
|
||||
body.append("--\(boundary)--\r\n".utf8Data)
|
||||
|
||||
return (body, "multipart/form-data; boundary=\(boundary)")
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var utf8Data: Data { Data(utf8) }
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
public struct CreateDocumentRequest: Encodable, Sendable {
|
||||
public struct CreateDocumentRequest: Codable, Sendable {
|
||||
public let title: String
|
||||
public let text: String
|
||||
public let collectionId: String
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
/// See `OutlinePin` — best-effort shape, not in the vendored spec.
|
||||
public struct CreatePinRequest: Encodable, Sendable {
|
||||
/// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to
|
||||
/// Collection" — these are distinct actions on the real server.
|
||||
public struct CreatePinRequest: Codable, Sendable {
|
||||
public let documentId: String
|
||||
public let collectionId: String?
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
/// See `OutlineSubscription` — best-effort shape, not in the vendored spec.
|
||||
public struct CreateSubscriptionRequest: Encodable, Sendable {
|
||||
public struct CreateSubscriptionRequest: Codable, Sendable {
|
||||
public let documentId: String
|
||||
public let event: String
|
||||
|
||||
|
||||
@@ -0,0 +1,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,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
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
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 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
|
||||
|
||||
public struct StarCollectionRequest: Encodable, Sendable {
|
||||
public struct StarCollectionRequest: Codable, Sendable {
|
||||
public let collectionId: String
|
||||
|
||||
public init(collectionId: String) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
public struct StarDocumentRequest: Encodable, Sendable {
|
||||
public struct StarDocumentRequest: Codable, Sendable {
|
||||
public let documentId: String
|
||||
|
||||
public init(documentId: String) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
public struct UpdateCollectionRequest: Encodable, Sendable {
|
||||
public struct UpdateCollectionRequest: Codable, Sendable {
|
||||
public let id: String
|
||||
public let name: String?
|
||||
public let description: String?
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
public struct UpdateDocumentRequest: Encodable, Sendable {
|
||||
public struct UpdateDocumentRequest: Codable, Sendable {
|
||||
public let id: String
|
||||
public let title: String?
|
||||
public let text: String?
|
||||
public let append: Bool?
|
||||
public let fullWidth: Bool?
|
||||
public let insightsEnabled: Bool?
|
||||
/// Not in the vendored spec's `Document`/`documents.update` shape at all
|
||||
/// (only a workspace-level `documentEmbeds` flag exists there) — included
|
||||
/// speculatively since the field may exist on newer self-hosted servers.
|
||||
/// Unrecognized fields are typically ignored server-side rather than
|
||||
/// rejected, so this is low-risk even if unsupported.
|
||||
public let documentEmbeds: Bool?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
@@ -20,8 +14,7 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
|
||||
text: String? = nil,
|
||||
append: Bool? = nil,
|
||||
fullWidth: Bool? = nil,
|
||||
insightsEnabled: Bool? = nil,
|
||||
documentEmbeds: Bool? = nil
|
||||
insightsEnabled: Bool? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
@@ -29,6 +22,5 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
|
||||
self.append = append
|
||||
self.fullWidth = fullWidth
|
||||
self.insightsEnabled = insightsEnabled
|
||||
self.documentEmbeds = documentEmbeds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,16 @@ import Foundation
|
||||
public struct UpdateShareRequest: Encodable, Sendable {
|
||||
public let id: String
|
||||
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.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,514 @@
|
||||
import XCTest
|
||||
@testable import OutlineKit
|
||||
|
||||
private struct NotStubbed: Error {}
|
||||
|
||||
/// Conforms to the full protocol (so it can stand in for `live`), but only
|
||||
/// the two methods under test in this file have real behavior — everything
|
||||
/// else throws loudly if a test accidentally exercises it.
|
||||
private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable {
|
||||
var documentInfoHandler: (@Sendable (String) async throws -> OutlineDocument)?
|
||||
var listCollectionsHandler: (@Sendable (Int, Int) async throws -> [OutlineCollection])?
|
||||
var updateDocumentHandler: (@Sendable (UpdateDocumentRequest) async throws -> OutlineDocument)?
|
||||
var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)?
|
||||
var deletePinHandler: (@Sendable (String) async throws -> Void)?
|
||||
var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])?
|
||||
var createDocumentHandler: (@Sendable (CreateDocumentRequest) async throws -> OutlineDocument)?
|
||||
|
||||
func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() }
|
||||
|
||||
func documentInfo(id: String) async throws -> OutlineDocument {
|
||||
guard let handler = documentInfoHandler else { throw NotStubbed() }
|
||||
return try await handler(id)
|
||||
}
|
||||
|
||||
func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument] {
|
||||
guard let handler = listDocumentsHandler else { throw NotStubbed() }
|
||||
return try await handler(collectionId, parentDocumentId, offset, limit)
|
||||
}
|
||||
|
||||
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func 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 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 deleteAttachment(id: String) async throws { throw NotStubbed() }
|
||||
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func deleteAccount() async throws { throw NotStubbed() }
|
||||
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] { throw NotStubbed() }
|
||||
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey { throw NotStubbed() }
|
||||
func deleteApiKey(id: String) async throws { throw NotStubbed() }
|
||||
func installationInfo() async throws -> OutlineInstallationInfo { throw NotStubbed() }
|
||||
}
|
||||
|
||||
private struct StubTransportError: Error {}
|
||||
|
||||
final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
private func makeCache() throws -> OfflineCacheStore {
|
||||
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
|
||||
}
|
||||
|
||||
private func makeDocument(id: String = "doc-1", title: String = "Hello") -> OutlineDocument {
|
||||
OutlineDocument(
|
||||
id: id,
|
||||
title: title,
|
||||
text: "body",
|
||||
url: "/doc/\(id)",
|
||||
createdAt: Date(timeIntervalSince1970: 0),
|
||||
updatedAt: Date(timeIntervalSince1970: 0)
|
||||
)
|
||||
}
|
||||
|
||||
private func makeCollection(id: String = "col-1", name: String = "Engineering") -> OutlineCollection {
|
||||
OutlineCollection(
|
||||
id: id,
|
||||
name: name,
|
||||
createdAt: Date(timeIntervalSince1970: 0),
|
||||
updatedAt: Date(timeIntervalSince1970: 0)
|
||||
)
|
||||
}
|
||||
|
||||
func testDocumentInfoWritesThroughToCacheOnSuccess() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let document = makeDocument()
|
||||
stub.documentInfoHandler = { _ in document }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
let result = try await sut.documentInfo(id: "doc-1")
|
||||
|
||||
XCTAssertEqual(result, document)
|
||||
}
|
||||
|
||||
func testDocumentInfoFallsBackToCacheWhenLiveFails() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let document = makeDocument()
|
||||
var callCount = 0
|
||||
stub.documentInfoHandler = { _ in
|
||||
callCount += 1
|
||||
if callCount == 1 { return document }
|
||||
throw StubTransportError()
|
||||
}
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
// First call succeeds and populates the cache.
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
// Second call fails live — should transparently return the cached value.
|
||||
let result = try await sut.documentInfo(id: "doc-1")
|
||||
|
||||
XCTAssertEqual(result, document)
|
||||
XCTAssertEqual(callCount, 2)
|
||||
}
|
||||
|
||||
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
do {
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
XCTFail("Expected an error")
|
||||
} catch is StubTransportError {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
func testListCollectionsFallsBackToCacheWhenLiveFails() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let collections = [makeCollection()]
|
||||
var callCount = 0
|
||||
stub.listCollectionsHandler = { _, _ in
|
||||
callCount += 1
|
||||
if callCount == 1 { return collections }
|
||||
throw StubTransportError()
|
||||
}
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
_ = try await sut.listCollections(offset: 0, limit: 25)
|
||||
let result = try await sut.listCollections(offset: 0, limit: 25)
|
||||
|
||||
XCTAssertEqual(result, collections)
|
||||
}
|
||||
|
||||
func testDifferentDocumentIdsAreCachedSeparately() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let docOne = makeDocument(id: "doc-1", title: "One")
|
||||
let docTwo = makeDocument(id: "doc-2", title: "Two")
|
||||
stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
_ = try await sut.documentInfo(id: "doc-2")
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
|
||||
let cachedOne = try await sut.documentInfo(id: "doc-1")
|
||||
let cachedTwo = try await sut.documentInfo(id: "doc-2")
|
||||
|
||||
XCTAssertEqual(cachedOne, docOne)
|
||||
XCTAssertEqual(cachedTwo, docTwo)
|
||||
}
|
||||
|
||||
// MARK: - Offline writes
|
||||
|
||||
func testUpdateDocumentQueuesAndAppliesOptimisticallyWhenLiveFails() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let original = makeDocument(id: "doc-1", title: "Original")
|
||||
stub.documentInfoHandler = { _ in original }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
// Populate the cache with the base document first (as a real open would).
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
|
||||
let updated = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
||||
XCTAssertEqual(updated.title, "Edited Offline")
|
||||
|
||||
// Re-opening the document (still offline) should reflect the optimistic edit.
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let reopened = try await sut.documentInfo(id: "doc-1")
|
||||
XCTAssertEqual(reopened.title, "Edited Offline")
|
||||
|
||||
let pending = await sut.pendingOperations()
|
||||
XCTAssertEqual(pending.count, 1)
|
||||
XCTAssertEqual(pending.first?.kind, "updateDocument")
|
||||
}
|
||||
|
||||
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
do {
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x"))
|
||||
XCTFail("Expected an error")
|
||||
} catch is StubTransportError {
|
||||
// expected — nothing to optimistically merge onto
|
||||
}
|
||||
}
|
||||
|
||||
func testSecondOfflineEditCoalescesIntoOneQueuedOperation() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let original = makeDocument(id: "doc-1", title: "Original")
|
||||
stub.documentInfoHandler = { _ in original }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit"))
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Second Edit"))
|
||||
|
||||
let pending = await sut.pendingOperations()
|
||||
XCTAssertEqual(pending.count, 1)
|
||||
}
|
||||
|
||||
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.createPinHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
|
||||
XCTAssertTrue(pin.id.hasPrefix("pending-"))
|
||||
let pendingCount1 = await sut.pendingOperations().count
|
||||
XCTAssertEqual(pendingCount1, 1)
|
||||
|
||||
try await sut.deletePin(id: pin.id)
|
||||
|
||||
let pendingCount0 = await sut.pendingOperations().count
|
||||
XCTAssertEqual(pendingCount0, 0)
|
||||
}
|
||||
|
||||
func testFlushPendingOperationsReplaysAndClearsOnSuccess() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let original = makeDocument(id: "doc-1", title: "Original")
|
||||
stub.documentInfoHandler = { _ in original }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
||||
let pendingCount1 = await sut.pendingOperations().count
|
||||
XCTAssertEqual(pendingCount1, 1)
|
||||
|
||||
// Network's back — replay should now succeed.
|
||||
stub.updateDocumentHandler = { request in
|
||||
self.makeDocument(id: request.id, title: request.title ?? "")
|
||||
}
|
||||
let summary = await sut.flushPendingOperations()
|
||||
|
||||
XCTAssertEqual(summary.succeeded, 1)
|
||||
XCTAssertEqual(summary.failed, 0)
|
||||
let pendingCount0 = await sut.pendingOperations().count
|
||||
XCTAssertEqual(pendingCount0, 0)
|
||||
}
|
||||
|
||||
func testFlushPendingOperationsRecordsErrorAndKeepsFailedOperation() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let original = makeDocument(id: "doc-1", title: "Original")
|
||||
stub.documentInfoHandler = { _ in original }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
||||
|
||||
// Still offline — replay should fail and keep the operation queued.
|
||||
let summary = await sut.flushPendingOperations()
|
||||
|
||||
XCTAssertEqual(summary.succeeded, 0)
|
||||
XCTAssertEqual(summary.failed, 1)
|
||||
let pending = await sut.pendingOperations()
|
||||
XCTAssertEqual(pending.count, 1)
|
||||
XCTAssertEqual(pending.first?.attemptCount, 1)
|
||||
XCTAssertNotNil(pending.first?.lastError)
|
||||
}
|
||||
|
||||
func testManualOfflineModeSkipsLiveEntirely() async throws {
|
||||
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOffline")!
|
||||
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOffline")
|
||||
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
|
||||
|
||||
let stub = StubOutlineAPIClient()
|
||||
var liveCallCount = 0
|
||||
stub.createPinHandler = { _ in
|
||||
liveCallCount += 1
|
||||
return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil)
|
||||
}
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults)
|
||||
|
||||
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
|
||||
|
||||
XCTAssertEqual(liveCallCount, 0, "Manual offline mode should never attempt the live call")
|
||||
XCTAssertTrue(pin.id.hasPrefix("pending-"))
|
||||
}
|
||||
|
||||
/// Regression test: manual offline mode used to only gate writes —
|
||||
/// reads (listCollections, documentInfo, etc.) still hit `live` first
|
||||
/// any time the device actually had a connection, silently defeating
|
||||
/// "skip the network entirely" for the one thing that mattered most:
|
||||
/// the sidebar showing more than what was actually cached.
|
||||
func testManualOfflineModeSkipsLiveForReadsToo() async throws {
|
||||
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOfflineReads")!
|
||||
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOfflineReads")
|
||||
|
||||
let stub = StubOutlineAPIClient()
|
||||
let cachedCollection = makeCollection(id: "col-1")
|
||||
var liveCallCount = 0
|
||||
stub.listCollectionsHandler = { _, _ in
|
||||
liveCallCount += 1
|
||||
return [cachedCollection]
|
||||
}
|
||||
let cache = try makeCache()
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults)
|
||||
|
||||
// Online first — populates the cache normally.
|
||||
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
|
||||
XCTAssertEqual(firstResult, [cachedCollection])
|
||||
XCTAssertEqual(liveCallCount, 1)
|
||||
|
||||
// Flip manual offline mode on, still "connected" (stub would happily
|
||||
// answer) — the live call must not be attempted at all.
|
||||
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
|
||||
let secondResult = try await sut.listCollections(offset: 0, limit: 25)
|
||||
|
||||
XCTAssertEqual(secondResult, [cachedCollection])
|
||||
XCTAssertEqual(liveCallCount, 1, "listCollections should have served entirely from cache")
|
||||
}
|
||||
|
||||
func testCacheStorageSummaryReflectsCachedItems() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.documentInfoHandler = { _ in self.makeDocument() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
let summary = await sut.cacheStorageSummary()
|
||||
|
||||
XCTAssertEqual(summary.itemCount, 1)
|
||||
XCTAssertGreaterThan(summary.totalBytes, 0)
|
||||
|
||||
await sut.clearCache()
|
||||
let clearedSummary = await sut.cacheStorageSummary()
|
||||
XCTAssertEqual(clearedSummary.itemCount, 0)
|
||||
}
|
||||
|
||||
// MARK: - Full sync
|
||||
|
||||
func testPerformFullSyncNeverRequestsMoreThan100CollectionsPerPage() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
var requestedLimits: [Int] = []
|
||||
// 105 collections across two pages (100 + 5) — regression test for a
|
||||
// real bug: this used to ask for `limit: 250` in one shot, which
|
||||
// Outline's server rejects outright ("Pagination limit is too large
|
||||
// (max 100)"), turning the whole sync into a single silent failure.
|
||||
stub.listCollectionsHandler = { offset, limit in
|
||||
requestedLimits.append(limit)
|
||||
let remaining = max(0, 105 - offset)
|
||||
let count = min(limit, remaining)
|
||||
return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
|
||||
}
|
||||
stub.listDocumentsHandler = { _, _, _, _ in [] }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
let summary = await sut.performFullSync()
|
||||
|
||||
XCTAssertEqual(summary.collectionsCount, 105)
|
||||
XCTAssertTrue(summary.errors.isEmpty)
|
||||
XCTAssertTrue(requestedLimits.allSatisfy { $0 <= 100 }, "requested limits: \(requestedLimits)")
|
||||
}
|
||||
|
||||
func testPerformFullSyncCachesEachDocumentIndividually() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
|
||||
stub.listDocumentsHandler = { _, _, offset, _ in
|
||||
offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
|
||||
}
|
||||
let cache = try makeCache()
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
|
||||
|
||||
let summary = await sut.performFullSync()
|
||||
XCTAssertEqual(summary.documentsCount, 2)
|
||||
|
||||
// A plain documentInfo read (no live call available) should now hit
|
||||
// the cache full sync populated, not just the list-shaped cache key.
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let cachedDoc = try await sut.documentInfo(id: "doc-2")
|
||||
XCTAssertEqual(cachedDoc.id, "doc-2")
|
||||
}
|
||||
|
||||
// MARK: - Offline document creation
|
||||
|
||||
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
let created = try await sut.createDocument(
|
||||
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
||||
)
|
||||
|
||||
XCTAssertTrue(created.id.hasPrefix("pending-"))
|
||||
XCTAssertEqual(created.title, "New Doc")
|
||||
XCTAssertEqual(created.text, "hello")
|
||||
|
||||
// It's immediately readable, same as any other cached document.
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let reopened = try await sut.documentInfo(id: created.id)
|
||||
XCTAssertEqual(reopened.title, "New Doc")
|
||||
|
||||
let pending = await sut.pendingOperations()
|
||||
XCTAssertEqual(pending.count, 1)
|
||||
XCTAssertEqual(pending.first?.kind, "createDocument")
|
||||
}
|
||||
|
||||
func testEditingAnUnsyncedCreatedDocumentFoldsIntoTheCreateInsteadOfQueuingAnUpdate() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
let created = try await sut.createDocument(
|
||||
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
|
||||
)
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: created.id, title: "Real Title", text: "Real body"))
|
||||
|
||||
let pending = await sut.pendingOperations()
|
||||
XCTAssertEqual(pending.count, 1, "the edit should have folded into the pending create, not added a second operation")
|
||||
XCTAssertEqual(pending.first?.kind, "createDocument")
|
||||
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let reopened = try await sut.documentInfo(id: created.id)
|
||||
XCTAssertEqual(reopened.title, "Real Title")
|
||||
XCTAssertEqual(reopened.text, "Real body")
|
||||
}
|
||||
|
||||
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
let created = try await sut.createDocument(
|
||||
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
||||
)
|
||||
let realDocument = makeDocument(id: "real-server-id", title: "New Doc")
|
||||
stub.createDocumentHandler = { _ in realDocument }
|
||||
|
||||
let summary = await sut.flushPendingOperations()
|
||||
XCTAssertEqual(summary.succeeded, 1)
|
||||
XCTAssertEqual(summary.failed, 0)
|
||||
|
||||
// The real document is now readable under its real id...
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let byRealId = try await sut.documentInfo(id: "real-server-id")
|
||||
XCTAssertEqual(byRealId.id, "real-server-id")
|
||||
|
||||
// ...and the placeholder is gone rather than left as a dead orphan.
|
||||
do {
|
||||
_ = try await sut.documentInfo(id: created.id)
|
||||
XCTFail("Expected the placeholder cache entry to have been removed")
|
||||
} catch {
|
||||
// expected — nothing left under the old id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,6 +280,65 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
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 {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
@@ -610,6 +669,117 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
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 {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.statusCode = 404
|
||||
@@ -628,6 +798,24 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
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 {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
@@ -676,6 +864,34 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
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 {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
@@ -694,6 +910,573 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
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 {
|
||||
let httpClient = MockHTTPClient()
|
||||
let client = LiveOutlineAPIClient(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import XCTest
|
||||
@testable import OutlineKit
|
||||
|
||||
final class MultipartFormDataBuilderTests: XCTestCase {
|
||||
func testBuildIncludesEveryFieldAndTheFile() throws {
|
||||
let fileData = Data("fake-jpeg-bytes".utf8)
|
||||
let (body, contentType) = MultipartFormDataBuilder.build(
|
||||
fields: ["key": "uploads/abc", "acl": "public-read", "Content-Type": "image/jpeg"],
|
||||
fileFieldName: "file",
|
||||
fileName: "avatar.jpg",
|
||||
fileData: fileData,
|
||||
fileContentType: "image/jpeg",
|
||||
boundary: "TestBoundary"
|
||||
)
|
||||
|
||||
let bodyString = String(decoding: body, as: UTF8.self)
|
||||
|
||||
XCTAssertEqual(contentType, "multipart/form-data; boundary=TestBoundary")
|
||||
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"key\""))
|
||||
XCTAssertTrue(bodyString.contains("uploads/abc"))
|
||||
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"acl\""))
|
||||
XCTAssertTrue(bodyString.contains("public-read"))
|
||||
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"file\"; filename=\"avatar.jpg\""))
|
||||
XCTAssertTrue(bodyString.contains("fake-jpeg-bytes"))
|
||||
XCTAssertTrue(bodyString.hasPrefix("--TestBoundary\r\n"))
|
||||
XCTAssertTrue(bodyString.hasSuffix("--TestBoundary--\r\n"))
|
||||
}
|
||||
|
||||
func testFileFieldComesAfterAllFormFields() throws {
|
||||
let (body, _) = MultipartFormDataBuilder.build(
|
||||
fields: ["a": "1", "b": "2"],
|
||||
fileFieldName: "file",
|
||||
fileName: "x.jpg",
|
||||
fileData: Data("bytes".utf8),
|
||||
fileContentType: "image/jpeg",
|
||||
boundary: "B"
|
||||
)
|
||||
let bodyString = String(decoding: body, as: UTF8.self)
|
||||
|
||||
let fieldsRange = bodyString.range(of: "name=\"a\"")
|
||||
let fileRange = bodyString.range(of: "name=\"file\"")
|
||||
XCTAssertNotNil(fieldsRange)
|
||||
XCTAssertNotNil(fileRange)
|
||||
if let fieldsRange, let fileRange {
|
||||
XCTAssertTrue(fieldsRange.lowerBound < fileRange.lowerBound)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -299,7 +299,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -361,7 +361,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -385,15 +385,20 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@@ -408,9 +413,10 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
MARKETING_VERSION = 0.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
||||
MARKETING_VERSION = 0.0.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -430,15 +436,20 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@@ -453,9 +464,10 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
MARKETING_VERSION = 0.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
||||
MARKETING_VERSION = 0.0.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -476,7 +488,7 @@
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
@@ -502,7 +514,7 @@
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
@@ -527,7 +539,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
@@ -552,7 +564,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
|
||||
@@ -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 |
@@ -2,25 +2,17 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct AboutView: View {
|
||||
/// Bare content (icon, name, version, links) with no window chrome — the
|
||||
/// macOS "About Outpost" app-menu command now opens Settings' own About
|
||||
/// section directly (no separate popup window), so this is its only caller.
|
||||
struct AboutInfoView: View {
|
||||
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
|
||||
private let releasesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/releases")!
|
||||
|
||||
private var appName: String {
|
||||
var appName: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
|
||||
}
|
||||
|
||||
/// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out
|
||||
/// of the bundle version itself since `CFBundleShortVersionString` is
|
||||
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
|
||||
private let releaseStage = "ALPHA"
|
||||
|
||||
private var versionString: String {
|
||||
let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
|
||||
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
||||
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
|
||||
return "Version \(shortVersion)\(stageSuffix) (\(buildNumber))"
|
||||
}
|
||||
var versionString: String { OutpostVersion.fullVersionString }
|
||||
|
||||
private var copyrightYear: String {
|
||||
String(Calendar.current.component(.year, from: Date()))
|
||||
@@ -51,29 +43,15 @@ struct AboutView: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Link(destination: repositoryURL) {
|
||||
Label("View Source on Git", systemImage: "link")
|
||||
}
|
||||
.font(.callout)
|
||||
|
||||
Button("Check for Updates…") {
|
||||
checkForUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
Text("© \(copyrightYear) Puranjay Savar Mattas")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(32)
|
||||
.frame(width: 320)
|
||||
}
|
||||
|
||||
// No Sparkle-style in-app updater yet — this just opens the releases page
|
||||
// on the self-hosted Gitea instance so the user can check/download manually.
|
||||
private func checkForUpdates() {
|
||||
NSWorkspace.shared.open(releasesURL)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
|
||||
/// contains the avatar image reliably broke its own sizing on click (even after
|
||||
@@ -7,10 +8,11 @@ import SwiftUI
|
||||
/// label rendering, which a `Button` doesn't go through.
|
||||
struct AccountFooter: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@Environment(AppNavigation.self) private var navigation
|
||||
@Environment(\.openURL) private var openURL
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||
@State private var isMenuPresented = false
|
||||
@State private var isShowingLogoutConfirmation = false
|
||||
@State private var isShowingProfile = false
|
||||
@@ -93,8 +95,17 @@ struct AccountFooter: View {
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
menuItem("Profile…") { isShowingProfile = true }
|
||||
menuItem("Preferences…") { openSettings() }
|
||||
// Deferred a tick: setting this synchronously in the same call
|
||||
// that dismisses this popover collides two AppKit window/layer
|
||||
// transactions in the same runloop turn (visible in the console
|
||||
// as "Invalid attempt to open a new transaction during CA
|
||||
// commit") — letting the popover's dismissal finish first avoids it.
|
||||
menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } }
|
||||
|
||||
Divider()
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Crop/rotate/zoom editor shown after picking a photo, before it's
|
||||
/// uploaded — pan (drag), zoom (pinch or the slider), and 90°-increment
|
||||
/// rotate, all inside a circular mask matching how the avatar actually
|
||||
/// renders everywhere else in the app.
|
||||
///
|
||||
/// The on-screen preview and the final exported image are built from the
|
||||
/// exact same view composition (`avatarContent`), just instantiated once
|
||||
/// for display and once inside an `ImageRenderer` — that's deliberate:
|
||||
/// hand-deriving a separate set of crop-math for a higher-resolution
|
||||
/// render would risk it silently disagreeing with what the user actually
|
||||
/// saw and confirmed, and there's no way to visually verify that
|
||||
/// agreement without running the app. Reusing the identical view tree
|
||||
/// makes the export WYSIWYG by construction instead of by careful math.
|
||||
struct AvatarCropperView: View {
|
||||
let sourceImage: NSImage
|
||||
let onConfirm: (Data) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@State private var scale: CGFloat = 1
|
||||
@State private var offset: CGSize = .zero
|
||||
@State private var rotationDegrees: Double = 0
|
||||
@GestureState private var dragTranslation: CGSize = .zero
|
||||
|
||||
/// Used for both the live preview and the exported image — see the
|
||||
/// type-level doc comment for why that's the same size, not two.
|
||||
private let diameter: CGFloat = 320
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("Edit Photo")
|
||||
.font(.headline)
|
||||
|
||||
ZStack {
|
||||
avatarContent
|
||||
.clipShape(Circle())
|
||||
Circle()
|
||||
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
|
||||
}
|
||||
.frame(width: diameter, height: diameter)
|
||||
.contentShape(Circle())
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.updating($dragTranslation) { value, state, _ in state = value.translation }
|
||||
.onEnded { value in
|
||||
offset.width += value.translation.width
|
||||
offset.height += value.translation.height
|
||||
}
|
||||
)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees -= 90 }
|
||||
} label: {
|
||||
Image(systemName: "rotate.left")
|
||||
}
|
||||
.help("Rotate left")
|
||||
|
||||
Slider(value: $scale, in: 1...4)
|
||||
.frame(width: 140)
|
||||
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees += 90 }
|
||||
} label: {
|
||||
Image(systemName: "rotate.right")
|
||||
}
|
||||
.help("Rotate right")
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Cancel", role: .cancel, action: onCancel)
|
||||
Spacer()
|
||||
Button("Use Photo") {
|
||||
if let data = renderFinalImage() {
|
||||
onConfirm(data)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 360)
|
||||
}
|
||||
|
||||
/// Aspect-fills `sourceImage` into a `diameter`×`diameter` square, then
|
||||
/// applies the user's pan/zoom/rotation on top — identical between the
|
||||
/// live preview and the final render (see the type-level doc comment).
|
||||
private var avatarContent: some View {
|
||||
Image(nsImage: sourceImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: diameter, height: diameter)
|
||||
.scaleEffect(scale)
|
||||
.rotationEffect(.degrees(rotationDegrees))
|
||||
.offset(x: offset.width + dragTranslation.width, y: offset.height + dragTranslation.height)
|
||||
.frame(width: diameter, height: diameter)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func renderFinalImage() -> Data? {
|
||||
let content = avatarContent
|
||||
.clipShape(Circle())
|
||||
.frame(width: diameter, height: diameter)
|
||||
let renderer = ImageRenderer(content: content)
|
||||
renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays
|
||||
guard let nsImage = renderer.nsImage else { return nil }
|
||||
return nsImage.jpegData(compressionQuality: 0.9)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,40 +0,0 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
|
||||
struct PreferencesView: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||
@State private var isShowingLogoutConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Appearance") {
|
||||
Picker("Appearance", selection: $appearance) {
|
||||
ForEach(AppAppearance.allCases) { option in
|
||||
Text(option.label).tag(option)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
Section("Account") {
|
||||
LabeledContent("Signed in as", value: session.userName ?? "—")
|
||||
if let email = session.userEmail {
|
||||
LabeledContent("Email", value: email)
|
||||
}
|
||||
if let teamName = session.teamName {
|
||||
LabeledContent("Workspace", value: teamName)
|
||||
}
|
||||
|
||||
Button("Log Out…", role: .destructive) {
|
||||
isShowingLogoutConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(width: 380, height: 300)
|
||||
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,115 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Swapped into the real sidebar's content slot (search field, collections
|
||||
/// tree, account footer) while Settings is open — same sidebar, different
|
||||
/// content, rather than a separate mini sidebar nested inside a page. "Done"
|
||||
/// clears `AppNavigation.isShowingSettings`, which puts the collections tree
|
||||
/// back.
|
||||
///
|
||||
/// Grouped by `SettingsCategory` — `general` (ours) sits under an "Outpost"
|
||||
/// header at the top, then Outline's own Account/Workspace groups, matching
|
||||
/// the settings page structure of the Outline web app. Outline's own server
|
||||
/// version has no dedicated section (there used to be an Integrations &
|
||||
/// Installation category for just that) — it's cheap enough to show
|
||||
/// unconditionally in the footer here instead, alongside Outpost's own
|
||||
/// version.
|
||||
struct SettingsSidebarList: View {
|
||||
@Binding var selection: SettingsSection?
|
||||
let onDone: () -> Void
|
||||
|
||||
@Environment(SessionStore.self) private var session
|
||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||
@State private var outlineVersion: String?
|
||||
|
||||
/// Mirrors `SettingsView`'s own check — a real dropped connection or
|
||||
/// the manual Offline Mode toggle both mean there's no server to ask.
|
||||
private var isEffectivelyOnline: Bool {
|
||||
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Settings")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Divider()
|
||||
|
||||
List(selection: $selection) {
|
||||
ForEach(SettingsCategory.allCases) { category in
|
||||
let sections = SettingsSection.allCases.filter { $0.category == category }
|
||||
Section {
|
||||
ForEach(sections) { section in
|
||||
Label(section.title, systemImage: section.icon)
|
||||
.tag(section)
|
||||
}
|
||||
} header: {
|
||||
if let title = category.title {
|
||||
HStack(spacing: 6) {
|
||||
Text(title)
|
||||
// Not hardcoded to `.workspace` specifically —
|
||||
// stays correct on its own as sections get
|
||||
// built, only shows while every section in
|
||||
// the category is still `!isImplemented`.
|
||||
if sections.allSatisfy({ !$0.isImplemented }) {
|
||||
Text("Coming Soon")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(.secondary.opacity(0.15), in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
|
||||
Divider()
|
||||
|
||||
versionFooter
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Done", action: onDone)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(12)
|
||||
}
|
||||
// Keyed to connectivity, not a one-shot `.task {}` — reconnecting
|
||||
// (or turning the manual Offline Mode toggle back off) re-fires
|
||||
// this automatically instead of leaving the footer stuck on
|
||||
// whatever it last knew, or blank, until Settings is reopened.
|
||||
.task(id: isEffectivelyOnline) { await refreshOutlineVersion() }
|
||||
}
|
||||
|
||||
private var versionFooter: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Outpost \(OutpostVersion.displayString)")
|
||||
if let outlineVersion {
|
||||
Text("Outline \(outlineVersion)")
|
||||
} else if !isEffectivelyOnline {
|
||||
Text("Outline — offline")
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func refreshOutlineVersion() async {
|
||||
guard isEffectivelyOnline, let apiClient = session.apiClient else { return }
|
||||
outlineVersion = try? await apiClient.installationInfo().version
|
||||
}
|
||||
}
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,21 +3,17 @@ import SwiftUI
|
||||
struct AuthHeaderView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.accentColor, Color.accentColor.opacity(0.6)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 64, height: 64)
|
||||
Image(systemName: "text.book.closed.fill")
|
||||
.font(.system(size: 26, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.shadow(color: Color.accentColor.opacity(0.35), radius: 12, y: 6)
|
||||
// A plain Image Set, not the AppIcon *app icon* asset — App Icon
|
||||
// sets aren't reliably resolvable through Image(_:)/UIImage
|
||||
// (named:) at runtime (confirmed live: showed nothing). This is
|
||||
// the same source artwork (outpost-ios-1024.png) duplicated
|
||||
// into a normal image set so SwiftUI can actually load it.
|
||||
Image("AppLogo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 72 * 0.2237, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text("Welcome to Outpost")
|
||||
|
||||
@@ -9,14 +9,27 @@ import OutlineKit
|
||||
/// client-side rather than `collections.documents`.
|
||||
struct CollectionDocumentsOutline: View {
|
||||
let apiClient: OutlineAPIClient
|
||||
let collection: OutlineCollection
|
||||
@State private var viewModel: DocumentsViewModel
|
||||
let sortOption: SidebarSortOption
|
||||
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?
|
||||
/// Full chain from root to the clicked document (inclusive) — lets the
|
||||
/// toolbar render the real hierarchy instead of just the leaf title.
|
||||
let onSelectDocument: ([OutlineDocument]) -> Void
|
||||
|
||||
/// Loaded once per collection (`pins.list` is collection-scoped) rather
|
||||
/// 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] = [:]
|
||||
|
||||
private var tree: [DocumentNode] {
|
||||
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
|
||||
}
|
||||
@@ -26,13 +39,16 @@ struct CollectionDocumentsOutline: View {
|
||||
collection: OutlineCollection,
|
||||
sortOption: SidebarSortOption,
|
||||
refreshToken: Int,
|
||||
externalRefreshToken: Int,
|
||||
selectedDocumentID: String?,
|
||||
onSelectDocument: @escaping ([OutlineDocument]) -> Void
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
self.collection = collection
|
||||
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
||||
self.sortOption = sortOption
|
||||
self.refreshToken = refreshToken
|
||||
self.externalRefreshToken = externalRefreshToken
|
||||
self.selectedDocumentID = selectedDocumentID
|
||||
self.onSelectDocument = onSelectDocument
|
||||
}
|
||||
@@ -56,13 +72,26 @@ struct CollectionDocumentsOutline: View {
|
||||
depth: 0,
|
||||
ancestors: [],
|
||||
selectedDocumentID: selectedDocumentID,
|
||||
pinsByDocumentID: pinsByDocumentID,
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPins() async {
|
||||
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) else { return }
|
||||
pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,8 +110,10 @@ private struct DocumentNodeRow: View {
|
||||
/// Chain from root down to (not including) this node.
|
||||
let ancestors: [OutlineDocument]
|
||||
let selectedDocumentID: String?
|
||||
let pinsByDocumentID: [String: OutlinePin]
|
||||
let onSelectDocument: ([OutlineDocument]) -> Void
|
||||
let onDocumentsChanged: () async -> Void
|
||||
let onPinsChanged: () async -> Void
|
||||
|
||||
@State private var isExpanded = false
|
||||
|
||||
@@ -96,6 +127,7 @@ private struct DocumentNodeRow: View {
|
||||
@State private var isShowingInsightsSheet = false
|
||||
@State private var isShowingPresentSheet = false
|
||||
@State private var isShowingSearchSheet = false
|
||||
@State private var isShowingNewDocumentSheet = false
|
||||
@State private var actionErrorMessage: String?
|
||||
|
||||
private var isSelected: Bool {
|
||||
@@ -172,8 +204,10 @@ private struct DocumentNodeRow: View {
|
||||
depth: depth + 1,
|
||||
ancestors: ancestors + [node.document],
|
||||
selectedDocumentID: selectedDocumentID,
|
||||
pinsByDocumentID: pinsByDocumentID,
|
||||
onSelectDocument: onSelectDocument,
|
||||
onDocumentsChanged: onDocumentsChanged
|
||||
onDocumentsChanged: onDocumentsChanged,
|
||||
onPinsChanged: onPinsChanged
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -252,6 +286,11 @@ private struct DocumentNodeRow: View {
|
||||
.sheet(isPresented: $isShowingSearchSheet) {
|
||||
DocumentSearchSheet(apiClient: apiClient, document: node.document)
|
||||
}
|
||||
.sheet(isPresented: $isShowingNewDocumentSheet) {
|
||||
NewDocumentSheet(apiClient: apiClient, initialParentDocument: node.document) { _ in
|
||||
Task { await onDocumentsChanged() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -259,7 +298,11 @@ private struct DocumentNodeRow: View {
|
||||
Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "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") {}
|
||||
.disabled(true)
|
||||
|
||||
@@ -273,8 +316,9 @@ private struct DocumentNodeRow: View {
|
||||
renameText = node.document.title
|
||||
isShowingRenameAlert = true
|
||||
}
|
||||
// Sharing/membership management is its own subsystem, not a one-off
|
||||
// action — deferred rather than half-built here.
|
||||
// DocumentShareSheet now has a real "People with access" section —
|
||||
// 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…") {}
|
||||
.disabled(true)
|
||||
|
||||
@@ -303,11 +347,13 @@ private struct DocumentNodeRow: View {
|
||||
Button("Import Document…") {}
|
||||
.disabled(true)
|
||||
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()
|
||||
|
||||
@@ -351,6 +397,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 {
|
||||
do {
|
||||
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: node.document.id, title: renameText))
|
||||
@@ -395,26 +454,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 {
|
||||
do {
|
||||
try await apiClient.deleteDocument(DeleteDocumentRequest(id: node.document.id))
|
||||
|
||||
@@ -10,7 +10,7 @@ struct CollectionRowView: View {
|
||||
} icon: {
|
||||
if let emoji = collection.emojiIcon {
|
||||
Text(emoji)
|
||||
} else if let symbolName = collection.icon.flatMap(OutlineIconMapping.sfSymbolName) {
|
||||
} else if let symbolName = collection.icon.flatMap({ OutlineIconMapping.sfSymbolName(for: $0) }) {
|
||||
Image(systemName: symbolName)
|
||||
.foregroundStyle(tintColor)
|
||||
} else {
|
||||
|
||||
@@ -11,6 +11,8 @@ struct CollectionTreeRow: View {
|
||||
let isExpanded: Bool
|
||||
let isSelected: Bool
|
||||
let selectedDocumentID: String?
|
||||
/// See the identical parameter on `CollectionDocumentsOutline`.
|
||||
let externalRefreshToken: Int
|
||||
let onToggle: () -> Void
|
||||
let onSelectDocument: ([OutlineDocument]) -> Void
|
||||
let onSearchInCollection: (OutlineCollection) -> Void
|
||||
@@ -22,6 +24,7 @@ struct CollectionTreeRow: View {
|
||||
@State private var isShowingRenameAlert = false
|
||||
@State private var renameText = ""
|
||||
@State private var isShowingDeleteConfirmation = false
|
||||
@State private var isShowingNewDocumentSheet = false
|
||||
@State private var actionErrorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
@@ -62,6 +65,7 @@ struct CollectionTreeRow: View {
|
||||
collection: collection,
|
||||
sortOption: sortOption,
|
||||
refreshToken: documentsRefreshToken,
|
||||
externalRefreshToken: externalRefreshToken,
|
||||
selectedDocumentID: selectedDocumentID,
|
||||
onSelectDocument: onSelectDocument
|
||||
)
|
||||
@@ -92,6 +96,12 @@ struct CollectionTreeRow: View {
|
||||
} message: {
|
||||
Text(actionErrorMessage ?? "")
|
||||
}
|
||||
.sheet(isPresented: $isShowingNewDocumentSheet) {
|
||||
NewDocumentSheet(apiClient: apiClient, initialCollectionID: collection.id) { _ in
|
||||
documentsRefreshToken += 1
|
||||
Task { await onCollectionsChanged() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -103,7 +113,7 @@ struct CollectionTreeRow: View {
|
||||
Divider()
|
||||
|
||||
Button("New Document") {
|
||||
Task { await createDocument() }
|
||||
isShowingNewDocumentSheet = true
|
||||
}
|
||||
|
||||
Divider()
|
||||
@@ -148,18 +158,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 {
|
||||
do {
|
||||
_ = try await apiClient.updateCollection(UpdateCollectionRequest(id: collection.id, name: renameText))
|
||||
|
||||
@@ -7,6 +7,12 @@ struct CollectionsTreeView: View {
|
||||
@Binding private var selectedCollection: OutlineCollection?
|
||||
let selectedDocumentID: 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 onSearchInCollection: (OutlineCollection) -> Void
|
||||
|
||||
@@ -14,12 +20,18 @@ struct CollectionsTreeView: View {
|
||||
apiClient: OutlineAPIClient,
|
||||
selectedCollection: Binding<OutlineCollection?>,
|
||||
selectedDocumentID: String?,
|
||||
externalRefreshToken: Int,
|
||||
isShowingHome: Bool,
|
||||
onSelectHome: @escaping () -> Void,
|
||||
onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void,
|
||||
onSearchInCollection: @escaping (OutlineCollection) -> Void
|
||||
) {
|
||||
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
|
||||
_selectedCollection = selectedCollection
|
||||
self.selectedDocumentID = selectedDocumentID
|
||||
self.externalRefreshToken = externalRefreshToken
|
||||
self.isShowingHome = isShowingHome
|
||||
self.onSelectHome = onSelectHome
|
||||
self.onSelectDocument = onSelectDocument
|
||||
self.onSearchInCollection = onSearchInCollection
|
||||
}
|
||||
@@ -31,6 +43,7 @@ struct CollectionsTreeView: View {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
}
|
||||
homeRow
|
||||
content
|
||||
}
|
||||
.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
|
||||
private var content: some View {
|
||||
Group {
|
||||
@@ -81,6 +120,7 @@ struct CollectionsTreeView: View {
|
||||
isExpanded: expandedCollectionIDs.contains(collection.id),
|
||||
isSelected: selectedCollection?.id == collection.id,
|
||||
selectedDocumentID: selectedDocumentID,
|
||||
externalRefreshToken: externalRefreshToken,
|
||||
onToggle: { toggle(collection) },
|
||||
onSelectDocument: { chain in onSelectDocument(collection, chain) },
|
||||
onSearchInCollection: onSearchInCollection,
|
||||
@@ -95,13 +135,6 @@ struct CollectionsTreeView: View {
|
||||
}
|
||||
.task {
|
||||
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,
|
||||
// global search) wouldn't otherwise expand its collection here, so
|
||||
|
||||
@@ -4,6 +4,12 @@ import OutlineKit
|
||||
|
||||
struct ContentView_macOS: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@Environment(AppNavigation.self) private var navigation
|
||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||
/// 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?
|
||||
/// 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.
|
||||
@@ -12,16 +18,83 @@ struct ContentView_macOS: View {
|
||||
@State private var contextualSearchQuery = ""
|
||||
@State private var isContextualSearchExpanded = false
|
||||
@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
|
||||
|
||||
private var trimmedGlobalQuery: String {
|
||||
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// `@Environment(AppNavigation.self)` doesn't hand out `$`-bindings on its
|
||||
/// own (that's `@Bindable`'s job, and introducing one in `body` would
|
||||
/// mean restructuring it away from a single implicit-return expression)
|
||||
/// — a manually-built `Binding` over the same reference is simpler here.
|
||||
private var selectedSettingsSectionBinding: Binding<SettingsSection?> {
|
||||
Binding(
|
||||
get: { navigation.selectedSettingsSection },
|
||||
set: { navigation.selectedSettingsSection = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// A totally separate top-level branch, not content swapped inside
|
||||
// one persistent `NavigationSplitView` — that was tried first (an
|
||||
// `if/else` inside a single split view, then an explicit `.id()` on
|
||||
// just the detail pane) and neither reliably replaced the detail
|
||||
// pane's content on macOS: `NavigationSplitView` bridges to
|
||||
// `NSSplitViewController`, and swapping a `NavigationStack` with
|
||||
// real push history for a plain view inside one persisting instance
|
||||
// doesn't propagate the way plain SwiftUI identity rules would
|
||||
// suggest. A different `if` branch is a genuinely different view
|
||||
// hierarchy, so there's no existing split view instance for AppKit
|
||||
// to get confused about reusing.
|
||||
if navigation.isShowingSettings {
|
||||
settingsContent
|
||||
} else {
|
||||
mainContent
|
||||
}
|
||||
}
|
||||
|
||||
private var settingsContent: some View {
|
||||
NavigationSplitView {
|
||||
SettingsSidebarList(
|
||||
selection: selectedSettingsSectionBinding,
|
||||
onDone: { navigation.isShowingSettings = false }
|
||||
)
|
||||
.navigationSplitViewColumnWidth(min: 220, ideal: 260)
|
||||
} detail: {
|
||||
SettingsView(section: navigation.selectedSettingsSection ?? .appearance)
|
||||
}
|
||||
.navigationTitle("")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigation) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "gearshape.fill")
|
||||
Text("Settings")
|
||||
}
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(.fill.tertiary, in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var mainContent: some View {
|
||||
NavigationSplitView {
|
||||
VStack(spacing: 0) {
|
||||
SidebarSearchField(text: $globalSearchQuery)
|
||||
Divider()
|
||||
if isOfflineModeEnabled {
|
||||
OfflineBanner(isManual: true)
|
||||
Divider()
|
||||
} else if !session.networkMonitor.isOnline {
|
||||
OfflineConnectionPromptBanner(onEnableOfflineMode: { isOfflineModeEnabled = true })
|
||||
Divider()
|
||||
}
|
||||
sidebar
|
||||
AccountFooter()
|
||||
}
|
||||
@@ -43,18 +116,49 @@ struct ContentView_macOS: View {
|
||||
// mutually exclusive: whenever a document's pushed (back button
|
||||
// visible), this shows the document hierarchy instead of falling
|
||||
// back to the workspace badge.
|
||||
ToolbarItem(placement: .navigation) {
|
||||
Button {
|
||||
goHome()
|
||||
} label: {
|
||||
Image(systemName: "house")
|
||||
}
|
||||
.help("Home")
|
||||
}
|
||||
ToolbarItem(placement: .navigation) {
|
||||
leadingToolbarContent
|
||||
}
|
||||
// Custom instead of `.searchable`: that modifier always renders a
|
||||
// full-width field, but this is meant to sit alongside the other
|
||||
// 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
|
||||
// itself — `contextualSearchQuery` is only ever read by
|
||||
// `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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func goHome() {
|
||||
globalSearchQuery = ""
|
||||
contextualSearchQuery = ""
|
||||
isContextualSearchExpanded = false
|
||||
selectedCollection = nil
|
||||
isShowingHome = true
|
||||
navigation.isShowingSettings = false
|
||||
replaceDocumentPath(with: [])
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var contextualSearchField: some View {
|
||||
@@ -106,12 +210,19 @@ struct ContentView_macOS: View {
|
||||
Image(systemName: "magnifyingglass")
|
||||
Text("Search")
|
||||
}
|
||||
} else if !documentPath.isEmpty, let selectedCollection {
|
||||
// Collection → every ancestor (icon only) → current document
|
||||
// (icon + full title) — ancestors stay icon-only so a deep
|
||||
// chain doesn't blow out the toolbar width.
|
||||
} else if !documentPath.isEmpty {
|
||||
// Origin (collection, or Home if opened from there) → every
|
||||
// ancestor (icon only) → current document (icon + full
|
||||
// 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) {
|
||||
if let selectedCollection {
|
||||
CollectionRowView(collection: selectedCollection)
|
||||
} else {
|
||||
Image(systemName: "house.fill")
|
||||
}
|
||||
|
||||
ForEach(Array(documentPath.enumerated()), id: \.element.id) { index, document in
|
||||
Image(systemName: "chevron.right")
|
||||
@@ -130,6 +241,11 @@ struct ContentView_macOS: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if isShowingHome {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "house.fill")
|
||||
Text("Home")
|
||||
}
|
||||
} else if let selectedCollection {
|
||||
CollectionRowView(collection: selectedCollection)
|
||||
} else {
|
||||
@@ -153,6 +269,9 @@ struct ContentView_macOS: View {
|
||||
apiClient: apiClient,
|
||||
selectedCollection: $selectedCollection,
|
||||
selectedDocumentID: documentPath.last?.id,
|
||||
externalRefreshToken: documentsChangedToken,
|
||||
isShowingHome: isShowingHome,
|
||||
onSelectHome: goHome,
|
||||
onSelectDocument: selectDocumentChain,
|
||||
onSearchInCollection: searchInCollection
|
||||
)
|
||||
@@ -205,6 +324,8 @@ struct ContentView_macOS: View {
|
||||
Group {
|
||||
if !trimmedGlobalQuery.isEmpty {
|
||||
GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery, onOpenDocument: openDocument)
|
||||
} else if isShowingHome {
|
||||
HomeView(apiClient: apiClient, onOpenDocument: openDocument, onDocumentCreated: { documentsChangedToken += 1 })
|
||||
} else if let selectedCollection {
|
||||
CollectionOverviewView(
|
||||
apiClient: apiClient,
|
||||
@@ -229,11 +350,12 @@ struct ContentView_macOS: View {
|
||||
if !documentPath.isEmpty {
|
||||
documentPath.removeLast()
|
||||
}
|
||||
}
|
||||
},
|
||||
onDocumentCreated: { documentsChangedToken += 1 }
|
||||
)
|
||||
}
|
||||
}
|
||||
.id(trimmedGlobalQuery.isEmpty ? (selectedCollection?.id ?? "none") : "search")
|
||||
.id(trimmedGlobalQuery.isEmpty ? (isShowingHome ? "home" : (selectedCollection?.id ?? "none")) : "search")
|
||||
} else {
|
||||
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import OutlineKit
|
||||
struct DocumentReaderView: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@Environment(StarStore.self) private var starStore
|
||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||
|
||||
@State private var viewModel: DocumentReaderViewModel
|
||||
let apiClient: OutlineAPIClient
|
||||
@@ -23,6 +24,11 @@ struct DocumentReaderView: View {
|
||||
/// longer visible in the collection it was opened from, so the reader
|
||||
/// pops itself off the navigation stack.
|
||||
let onDeleted: () -> Void
|
||||
/// The reader's own "New Document" toolbar button has no direct handle
|
||||
/// on the sidebar row it belongs under — this tells the sidebar a
|
||||
/// document exists now so it can pick it up. See
|
||||
/// `CollectionDocumentsOutline.externalRefreshToken`.
|
||||
let onDocumentCreated: () -> Void
|
||||
|
||||
@State private var isShowingUnpublishConfirmation = false
|
||||
@State private var isShowingArchiveConfirmation = false
|
||||
@@ -33,19 +39,31 @@ struct DocumentReaderView: View {
|
||||
@State private var isShowingPresentSheet = false
|
||||
@State private var isShowingSearchSheet = false
|
||||
@State private var isShowingShareSheet = false
|
||||
@State private var isShowingNewDocumentSheet = false
|
||||
@State private var actionErrorMessage: String?
|
||||
|
||||
init(
|
||||
apiClient: OutlineAPIClient,
|
||||
document: OutlineDocument,
|
||||
onOpenChild: @escaping (OutlineDocument) -> Void,
|
||||
onDeleted: @escaping () -> Void
|
||||
onDeleted: @escaping () -> Void,
|
||||
onDocumentCreated: @escaping () -> Void
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
self.document = document
|
||||
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
|
||||
self.onOpenChild = onOpenChild
|
||||
self.onDeleted = onDeleted
|
||||
self.onDocumentCreated = onDocumentCreated
|
||||
}
|
||||
|
||||
/// New Document, editing, pin/star/subscribe, and Full Width all queue
|
||||
/// and sync later (see `CachingOutlineAPIClient`) — everything else here
|
||||
/// (sharing, permissions, move/archive/delete/duplicate/templatize,
|
||||
/// history, insights, export) hits the server directly with no offline
|
||||
/// path, so it's disabled rather than left to fail confusingly on tap.
|
||||
private var isEffectivelyOnline: Bool {
|
||||
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -74,6 +92,7 @@ struct DocumentReaderView: View {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
configuration: .init(heightBehavior: .fitsContent),
|
||||
documentId: viewModel.documentId,
|
||||
isEditable: viewModel.isEditing
|
||||
)
|
||||
|
||||
@@ -102,7 +121,11 @@ struct DocumentReaderView: View {
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
.help("Share")
|
||||
.help(isEffectivelyOnline ? "Share" : "Sharing needs an internet connection")
|
||||
.disabled(!isEffectivelyOnline)
|
||||
.popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) {
|
||||
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await viewModel.toggleEditing() }
|
||||
@@ -116,7 +139,7 @@ struct DocumentReaderView: View {
|
||||
.disabled(viewModel.isSaving)
|
||||
|
||||
Button {
|
||||
Task { await createChildDocument() }
|
||||
isShowingNewDocumentSheet = true
|
||||
} label: {
|
||||
Image(systemName: "doc.badge.plus")
|
||||
}
|
||||
@@ -127,12 +150,22 @@ struct DocumentReaderView: View {
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
// SwiftUI's macOS `Menu` doesn't reliably re-evaluate a
|
||||
// `Toggle`'s checkmark against updated @Observable state on
|
||||
// its own — without a fresh `.id()` per state combination,
|
||||
// toggling Subscribed/Viewer Insights/Full Width kept
|
||||
// showing the pre-toggle checkmark until the whole view was
|
||||
// torn down and rebuilt (e.g. navigating away and back).
|
||||
.id(menuIdentity)
|
||||
}
|
||||
}
|
||||
.task { await viewModel.loadFullContent() }
|
||||
.task {
|
||||
await viewModel.loadPinAndSubscriptionState()
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadInsightsEnabledState()
|
||||
}
|
||||
.task {
|
||||
while !Task.isCancelled {
|
||||
await viewModel.loadViewers()
|
||||
@@ -197,14 +230,34 @@ struct DocumentReaderView: View {
|
||||
.sheet(isPresented: $isShowingSearchSheet) {
|
||||
DocumentSearchSheet(apiClient: apiClient, document: document)
|
||||
}
|
||||
.sheet(isPresented: $isShowingShareSheet) {
|
||||
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
||||
.sheet(isPresented: $isShowingNewDocumentSheet) {
|
||||
NewDocumentSheet(apiClient: apiClient, initialParentDocument: document) { child in
|
||||
onDocumentCreated()
|
||||
onOpenChild(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every toggle-backed piece of state shown as a checkmark inside
|
||||
/// `menuContent` — see the `.id()` comment on the `Menu` above.
|
||||
private var menuIdentity: String {
|
||||
[
|
||||
starStore.isStarred(documentId: viewModel.documentId),
|
||||
viewModel.isSubscribed,
|
||||
viewModel.isPinned,
|
||||
viewModel.isInsightsEnabled ?? false,
|
||||
viewModel.isFullWidth,
|
||||
viewModel.isEditing,
|
||||
isEffectivelyOnline
|
||||
].map(String.init).joined(separator: "-")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var viewerAvatars: some View {
|
||||
if !viewModel.viewers.isEmpty {
|
||||
// Tied to the Viewer Insights toggle — that's the feature this data
|
||||
// belongs to, so turning it off should hide the avatars immediately
|
||||
// rather than leaving them showing until the view reloads.
|
||||
if viewModel.isInsightsEnabled == true, !viewModel.viewers.isEmpty {
|
||||
HStack(spacing: -6) {
|
||||
ForEach(viewModel.viewers.prefix(5)) { viewer in
|
||||
AvatarBadge(
|
||||
@@ -249,48 +302,57 @@ struct DocumentReaderView: View {
|
||||
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
|
||||
Task { await star() }
|
||||
}
|
||||
Button(viewModel.isSubscribed ? "Unsubscribe" : "Subscribe") {
|
||||
Task { await toggleSubscription() }
|
||||
}
|
||||
Toggle("Subscribed", isOn: Binding(
|
||||
get: { viewModel.isSubscribed },
|
||||
set: { _ in Task { await toggleSubscription() } }
|
||||
))
|
||||
|
||||
Divider()
|
||||
|
||||
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
|
||||
Task { await viewModel.toggleEditing() }
|
||||
}
|
||||
// Sharing/membership management is its own subsystem, not a one-off
|
||||
// action — deferred rather than half-built here.
|
||||
Button("Permissions…") {}
|
||||
.disabled(true)
|
||||
// Membership management now lives in DocumentShareSheet's "People
|
||||
// with access" section, alongside the share link — same sheet,
|
||||
// same isShowingShareSheet state.
|
||||
Button("Permissions…") {
|
||||
isShowingShareSheet = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Templatize") {
|
||||
Task { await templatize() }
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
Button("Duplicate") {
|
||||
Task { await duplicate() }
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
Button("Unpublish") {
|
||||
isShowingUnpublishConfirmation = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
Button("Archive…") {
|
||||
isShowingArchiveConfirmation = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Move") {
|
||||
isShowingMoveSheet = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
// Multipart file upload is its own subsystem — deferred rather than
|
||||
// half-built here.
|
||||
Button("Import Document…") {}
|
||||
.disabled(true)
|
||||
Button("New Document") {
|
||||
Task { await createChildDocument() }
|
||||
isShowingNewDocumentSheet = true
|
||||
}
|
||||
Button(viewModel.isPinned ? "Unpin" : "Pin") {
|
||||
Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") {
|
||||
Task { await togglePin() }
|
||||
}
|
||||
|
||||
@@ -299,9 +361,13 @@ struct DocumentReaderView: View {
|
||||
Button("History") {
|
||||
isShowingHistorySheet = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
Button("Insights") {
|
||||
isShowingInsightsSheet = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
// Present/Search in Document both read `documents.info`, which is
|
||||
// read-through cached — they work offline on whatever's cached.
|
||||
Button("Present") {
|
||||
isShowingPresentSheet = true
|
||||
}
|
||||
@@ -311,6 +377,7 @@ struct DocumentReaderView: View {
|
||||
Button("Download") {
|
||||
Task { await download() }
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
Button("Copy") {
|
||||
Task { await copyMarkdown() }
|
||||
}
|
||||
@@ -323,21 +390,28 @@ struct DocumentReaderView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Enable Viewer Insights") {
|
||||
Task { await enableInsights() }
|
||||
}
|
||||
Button("Enable Embeds") {
|
||||
Task { await enableEmbeds() }
|
||||
}
|
||||
Button(viewModel.isFullWidth ? "Default Width" : "Full Width") {
|
||||
Task { await toggleFullWidth() }
|
||||
}
|
||||
Toggle("Viewer Insights", isOn: Binding(
|
||||
get: { viewModel.isInsightsEnabled ?? false },
|
||||
set: { _ in Task { await toggleInsights() } }
|
||||
))
|
||||
.disabled(!isEffectivelyOnline)
|
||||
// Confirmed against a live server: there's no per-document embeds
|
||||
// field. Only a workspace-level setting exists, and that's not
|
||||
// reachable via the API either (no `team.update` endpoint in the
|
||||
// vendored spec) — disabled rather than kept as a broken action.
|
||||
Button("Enable Embeds") {}
|
||||
.disabled(true)
|
||||
Toggle("Full Width", isOn: Binding(
|
||||
get: { viewModel.isFullWidth },
|
||||
set: { _ in Task { await toggleFullWidth() } }
|
||||
))
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Delete…", role: .destructive) {
|
||||
isShowingDeleteConfirmation = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
}
|
||||
|
||||
private func star() async {
|
||||
@@ -372,19 +446,11 @@ struct DocumentReaderView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func enableInsights() async {
|
||||
private func toggleInsights() async {
|
||||
do {
|
||||
try await viewModel.enableViewerInsights()
|
||||
try await viewModel.toggleViewerInsights()
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable viewer insights.")
|
||||
}
|
||||
}
|
||||
|
||||
private func enableEmbeds() async {
|
||||
do {
|
||||
try await viewModel.enableEmbeds()
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable embeds.")
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update viewer insights.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,21 +497,6 @@ struct DocumentReaderView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func createChildDocument() async {
|
||||
guard let collectionId = viewModel.collectionId else {
|
||||
actionErrorMessage = "This document isn't in a collection."
|
||||
return
|
||||
}
|
||||
do {
|
||||
let child = try await apiClient.createDocument(
|
||||
CreateDocumentRequest(title: "Untitled", text: "", collectionId: collectionId, parentDocumentId: viewModel.documentId)
|
||||
)
|
||||
onOpenChild(child)
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.")
|
||||
}
|
||||
}
|
||||
|
||||
private func download() async {
|
||||
do {
|
||||
let markdown = try await apiClient.exportDocument(id: viewModel.documentId)
|
||||
|
||||
@@ -28,7 +28,12 @@ final class DocumentReaderViewModel {
|
||||
private var pinId: String?
|
||||
private(set) var isSubscribed = false
|
||||
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
|
||||
private let apiClient: OutlineAPIClient
|
||||
@@ -75,7 +80,9 @@ final class DocumentReaderViewModel {
|
||||
}
|
||||
|
||||
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 apiClient.listPins(ListPinsRequest(collectionId: nil)),
|
||||
let match = pins.first(where: { $0.documentId == documentId }) {
|
||||
isPinned = true
|
||||
pinId = match.id
|
||||
@@ -94,8 +101,13 @@ final class DocumentReaderViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func loadShare() async {
|
||||
share = try? await apiClient.shareInfo(documentId: documentId)
|
||||
func loadInsightsEnabledState() async {
|
||||
do {
|
||||
_ = try await apiClient.documentInsights(DocumentInsightsRequest(id: documentId))
|
||||
isInsightsEnabled = true
|
||||
} catch {
|
||||
isInsightsEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
func togglePin() async throws {
|
||||
@@ -110,7 +122,7 @@ final class DocumentReaderViewModel {
|
||||
throw error
|
||||
}
|
||||
} 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
|
||||
isPinned = true
|
||||
}
|
||||
@@ -134,10 +146,6 @@ final class DocumentReaderViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func createOrLoadShare() async throws {
|
||||
share = try await apiClient.createShare(CreateShareRequest(documentId: documentId))
|
||||
}
|
||||
|
||||
/// Turning editing off saves; turning it on is just a mode switch.
|
||||
func toggleEditing() async {
|
||||
guard isEditing else {
|
||||
@@ -168,14 +176,14 @@ final class DocumentReaderViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire-and-forget: `insightsEnabled` isn't readable back off `Document`
|
||||
/// in the vendored spec, so there's no state to reflect as a checkmark.
|
||||
func enableViewerInsights() async throws {
|
||||
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: true))
|
||||
func toggleViewerInsights() async throws {
|
||||
let newValue = !(isInsightsEnabled ?? false)
|
||||
isInsightsEnabled = newValue
|
||||
do {
|
||||
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: newValue))
|
||||
} catch {
|
||||
isInsightsEnabled = !newValue
|
||||
throw error
|
||||
}
|
||||
|
||||
/// Fire-and-forget, speculative field — see `UpdateDocumentRequest.documentEmbeds`.
|
||||
func enableEmbeds() async throws {
|
||||
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, documentEmbeds: true))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,68 +3,306 @@ import AppKit
|
||||
import SwiftUI
|
||||
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
|
||||
struct DocumentShareSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let apiClient: OutlineAPIClient
|
||||
let documentId: String
|
||||
|
||||
@State private var share: OutlineShare?
|
||||
@State private var isLoading = false
|
||||
@State private var isUpdating = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var titleOverride = ""
|
||||
@State private var isLoadingShare = false
|
||||
@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 members: [OutlineDocumentMember] = []
|
||||
@State private var isLoadingMembers = false
|
||||
@State private var isShowingAddPerson = false
|
||||
@State private var userSearchQuery = ""
|
||||
@State private var userSearchResults: [OutlineUser] = []
|
||||
@State private var isSearchingUsers = false
|
||||
@State private var selectedPermission = "read"
|
||||
@State private var isAddingUser = false
|
||||
@State private var actionErrorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Share")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }
|
||||
.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 ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
if isLoading {
|
||||
ProgressView().frame(maxWidth: .infinity)
|
||||
} else if let errorMessage {
|
||||
Text(errorMessage)
|
||||
// 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)
|
||||
.foregroundStyle(.red)
|
||||
} else if let share {
|
||||
HStack {
|
||||
Text(share.url)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let url = share.url {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "globe")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.callout)
|
||||
Text(url)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Spacer(minLength: 0)
|
||||
Button {
|
||||
copyLink(share.url)
|
||||
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)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Revoke", role: .destructive) {
|
||||
isShowingRevokeConfirmation = true
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.disabled(isRevoking)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(8)
|
||||
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6))
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Toggle(
|
||||
"Published — accessible without sign-in",
|
||||
isOn: Binding(
|
||||
get: { share.published },
|
||||
set: { newValue in Task { await setPublished(newValue) } }
|
||||
)
|
||||
)
|
||||
.disabled(isUpdating)
|
||||
} else {
|
||||
Button("Create Share Link") {
|
||||
Task { await create() }
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 380)
|
||||
.task { await load() }
|
||||
|
||||
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) {
|
||||
@@ -78,34 +316,96 @@ struct DocumentShareSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
private func loadShare() async {
|
||||
isLoadingShare = true
|
||||
defer { isLoadingShare = false }
|
||||
do {
|
||||
share = try await apiClient.shareInfo(documentId: documentId)
|
||||
titleOverride = share?.title ?? ""
|
||||
} catch {
|
||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.")
|
||||
shareErrorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.")
|
||||
}
|
||||
}
|
||||
|
||||
private func create() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
isLoadingShare = true
|
||||
defer { isLoadingShare = false }
|
||||
do {
|
||||
share = try await apiClient.createShare(CreateShareRequest(documentId: documentId))
|
||||
titleOverride = share?.title ?? ""
|
||||
} 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 }
|
||||
isUpdating = true
|
||||
defer { isUpdating = false }
|
||||
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed != (share.title ?? "") else { return }
|
||||
isUpdatingShare = true
|
||||
defer { isUpdatingShare = false }
|
||||
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 {
|
||||
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 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 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,169 @@
|
||||
#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("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(parentOptions) { 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("Create") {
|
||||
Task { await create() }
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(selectedCollectionID == nil || isCreating)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 380)
|
||||
.task {
|
||||
await loadCollections()
|
||||
selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId ?? collections.first?.id
|
||||
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 {
|
||||
guard let selectedCollectionID else { return }
|
||||
isCreating = true
|
||||
defer { isCreating = false }
|
||||
do {
|
||||
let document = try await apiClient.createDocument(
|
||||
CreateDocumentRequest(
|
||||
title: title.isEmpty ? "Untitled" : title,
|
||||
text: "",
|
||||
collectionId: selectedCollectionID,
|
||||
parentDocumentId: selectedParentID
|
||||
)
|
||||
)
|
||||
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,43 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
struct DocumentCardView: View {
|
||||
@Environment(StarStore.self) private var starStore
|
||||
let document: OutlineDocument
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
if let emoji = document.emoji {
|
||||
Text(emoji)
|
||||
.font(.title2)
|
||||
} else {
|
||||
Image(systemName: "doc.text")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if starStore.isStarred(documentId: document.id) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
}
|
||||
|
||||
Text(document.title.isEmpty ? "Untitled" : document.title)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Text(document.updatedAt, format: .relative(presentation: .named))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading)
|
||||
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
enum HomeTab: String, CaseIterable, Identifiable {
|
||||
case recentlyViewed = "Recently Viewed"
|
||||
case popular = "Popular"
|
||||
case recentlyUpdated = "Recently Updated"
|
||||
case createdByMe = "Created by Me"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
struct HomeView: View {
|
||||
let apiClient: OutlineAPIClient
|
||||
let onOpenDocument: (OutlineDocument) -> Void
|
||||
/// Home has no sidebar row of its own to reload directly — this tells
|
||||
/// the sidebar a document exists now so it can pick it up. See
|
||||
/// `CollectionDocumentsOutline.externalRefreshToken`.
|
||||
let onDocumentCreated: () -> Void
|
||||
|
||||
@State private var viewModel: HomeViewModel
|
||||
@State private var selectedTab: HomeTab = .recentlyViewed
|
||||
@State private var isShowingNewDocumentSheet = false
|
||||
|
||||
private let pinnedGridColumns = [GridItem(.adaptive(minimum: 260), spacing: 8)]
|
||||
private let tabGridColumns = [GridItem(.adaptive(minimum: 220), spacing: 12)]
|
||||
|
||||
init(apiClient: OutlineAPIClient, onOpenDocument: @escaping (OutlineDocument) -> Void, onDocumentCreated: @escaping () -> Void) {
|
||||
self.apiClient = apiClient
|
||||
self.onOpenDocument = onOpenDocument
|
||||
self.onDocumentCreated = onDocumentCreated
|
||||
_viewModel = State(initialValue: HomeViewModel(apiClient: apiClient))
|
||||
}
|
||||
|
||||
private var isShowingPinnedSection: Bool {
|
||||
viewModel.isLoadingPinned || !viewModel.pinnedDocuments.isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if viewModel.hasRemoteChanges {
|
||||
RemoteChangesBanner {
|
||||
Task {
|
||||
await viewModel.loadPinned()
|
||||
await viewModel.load(tab: selectedTab)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The pinned section claims roughly the top half when it has
|
||||
// anything to show (scrolling within itself if there are enough
|
||||
// pinned documents to overflow that), and collapses away entirely
|
||||
// when there's nothing pinned so the tabs get the full height.
|
||||
GeometryReader { proxy in
|
||||
VStack(spacing: 0) {
|
||||
if isShowingPinnedSection {
|
||||
pinnedSection
|
||||
.frame(height: max(proxy.size.height / 2, 180))
|
||||
Divider()
|
||||
}
|
||||
tabSection
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
isShowingNewDocumentSheet = true
|
||||
} label: {
|
||||
Image(systemName: "doc.badge.plus")
|
||||
}
|
||||
.help("New Document")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isShowingNewDocumentSheet) {
|
||||
NewDocumentSheet(apiClient: apiClient) { document in
|
||||
onDocumentCreated()
|
||||
onOpenDocument(document)
|
||||
}
|
||||
}
|
||||
.task { await viewModel.loadPinned() }
|
||||
.task(id: selectedTab) { await viewModel.load(tab: selectedTab) }
|
||||
.task {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(45))
|
||||
guard !Task.isCancelled else { break }
|
||||
await viewModel.checkForRemoteChanges(tab: selectedTab)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pinnedSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Pinned")
|
||||
.font(.title3.weight(.semibold))
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
|
||||
if viewModel.isLoadingPinned {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: pinnedGridColumns, spacing: 8) {
|
||||
ForEach(viewModel.pinnedDocuments) { document in
|
||||
Button {
|
||||
onOpenDocument(document)
|
||||
} label: {
|
||||
PinnedDocumentCard(document: document)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var tabSection: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
tabBar
|
||||
|
||||
Divider()
|
||||
|
||||
let documents = viewModel.documents(for: selectedTab)
|
||||
|
||||
Group {
|
||||
if viewModel.isLoadingTab && documents.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let errorMessage = viewModel.errorMessage, documents.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("Couldn't Load Documents", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(errorMessage)
|
||||
} actions: {
|
||||
Button("Retry") {
|
||||
Task { await viewModel.load(tab: selectedTab) }
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if documents.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Documents",
|
||||
systemImage: "doc.text",
|
||||
description: Text("Nothing to show here yet.")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: tabGridColumns, spacing: 12) {
|
||||
ForEach(documents) { document in
|
||||
Button {
|
||||
onOpenDocument(document)
|
||||
} label: {
|
||||
DocumentCardView(document: document)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors `CollectionOverviewView.tabBar`'s exact style, rather than the
|
||||
// native `.pickerStyle(.segmented)` this started with.
|
||||
private var tabBar: some View {
|
||||
HStack(spacing: 4) {
|
||||
Spacer(minLength: 0)
|
||||
ForEach(HomeTab.allCases) { tab in
|
||||
Button {
|
||||
selectedTab = tab
|
||||
} label: {
|
||||
Text(tab.rawValue)
|
||||
.font(.callout.weight(selectedTab == tab ? .semibold : .regular))
|
||||
.foregroundStyle(selectedTab == tab ? Color.primary : Color.secondary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 6)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,143 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OutlineKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class HomeViewModel {
|
||||
private(set) var pinnedDocuments: [OutlineDocument] = []
|
||||
private(set) var recentlyViewed: [OutlineDocument] = []
|
||||
private(set) var popular: [OutlineDocument] = []
|
||||
private(set) var recentlyUpdated: [OutlineDocument] = []
|
||||
private(set) var createdByMe: [OutlineDocument] = []
|
||||
|
||||
var isLoadingPinned = false
|
||||
var isLoadingTab = false
|
||||
var errorMessage: String?
|
||||
/// Drives a "Refresh" banner rather than silently swapping content out
|
||||
/// from under whoever's looking at it — see `CollectionsViewModel`'s
|
||||
/// identical pattern.
|
||||
var hasRemoteChanges = false
|
||||
|
||||
private let apiClient: OutlineAPIClient
|
||||
private var currentUserID: String?
|
||||
|
||||
init(apiClient: OutlineAPIClient) {
|
||||
self.apiClient = apiClient
|
||||
}
|
||||
|
||||
func documents(for tab: HomeTab) -> [OutlineDocument] {
|
||||
switch tab {
|
||||
case .recentlyViewed: recentlyViewed
|
||||
case .popular: popular
|
||||
case .recentlyUpdated: recentlyUpdated
|
||||
case .createdByMe: createdByMe
|
||||
}
|
||||
}
|
||||
|
||||
func loadPinned() async {
|
||||
isLoadingPinned = true
|
||||
defer { isLoadingPinned = false }
|
||||
pinnedDocuments = await fetchPinned()
|
||||
}
|
||||
|
||||
func load(tab: HomeTab) async {
|
||||
isLoadingTab = true
|
||||
errorMessage = nil
|
||||
defer { isLoadingTab = false }
|
||||
do {
|
||||
let documents = try await fetch(tab: tab)
|
||||
set(documents, for: tab)
|
||||
hasRemoteChanges = false
|
||||
} catch {
|
||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load documents.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches fresh pinned docs and the current tab's documents to compare
|
||||
/// against what's displayed, without replacing either. Bails silently
|
||||
/// on a fetch failure rather than treating it as "changed" — a
|
||||
/// transient network hiccup (or, offline, `listPins` failing outright —
|
||||
/// it isn't one of the cached endpoints) shouldn't pop the refresh
|
||||
/// banner. This needs the *throwing* pinned-fetch specifically: the
|
||||
/// plain `fetchPinned()` used elsewhere collapses any failure to `[]`,
|
||||
/// which used to read here as "pins changed" against whatever was
|
||||
/// already displayed and falsely popped the banner on every offline
|
||||
/// poll.
|
||||
func checkForRemoteChanges(tab: HomeTab) async {
|
||||
async let freshPinnedTask = fetchPinnedThrowing()
|
||||
guard let freshTab = try? await fetch(tab: tab) else { return }
|
||||
guard let pinned = try? await freshPinnedTask else { return }
|
||||
if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments)
|
||||
|| Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) {
|
||||
hasRemoteChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchPinned() async -> [OutlineDocument] {
|
||||
(try? await fetchPinnedThrowing()) ?? []
|
||||
}
|
||||
|
||||
/// `pins.list` only returns pin records, not the documents themselves —
|
||||
/// fetches each pinned document individually. Pins are a small curated
|
||||
/// set (unlike a full collection tree), so the N+1 here is acceptable
|
||||
/// where it wouldn't be in the sidebar.
|
||||
private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
|
||||
let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil))
|
||||
var documents: [OutlineDocument] = []
|
||||
for pin in pins {
|
||||
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
|
||||
documents.append(document)
|
||||
}
|
||||
}
|
||||
return documents
|
||||
}
|
||||
|
||||
private func fetch(tab: HomeTab) async throws -> [OutlineDocument] {
|
||||
switch tab {
|
||||
case .recentlyViewed:
|
||||
return try await apiClient.listViewedDocuments(offset: 0, limit: 25)
|
||||
case .popular:
|
||||
// `sort: "viewCount"` was a guess and the server rejected it
|
||||
// outright ("sort: Invalid input") — sort is validated
|
||||
// server-side against a fixed set, not free-form like the
|
||||
// vendored spec's typing implies. Same conclusion as
|
||||
// `CollectionTab.popular`: there's no real popularity
|
||||
// ranking exposed via the REST API, so this falls back to
|
||||
// the default list order rather than guessing again.
|
||||
return try await apiClient.documentsList(DocumentsListRequest(limit: 25))
|
||||
case .recentlyUpdated:
|
||||
return try await apiClient.documentsList(
|
||||
DocumentsListRequest(sort: "updatedAt", direction: "DESC", limit: 25)
|
||||
)
|
||||
case .createdByMe:
|
||||
let userId = try await resolveCurrentUserID()
|
||||
return try await apiClient.documentsList(
|
||||
DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func set(_ documents: [OutlineDocument], for tab: HomeTab) {
|
||||
switch tab {
|
||||
case .recentlyViewed: recentlyViewed = documents
|
||||
case .popular: popular = documents
|
||||
case .recentlyUpdated: recentlyUpdated = documents
|
||||
case .createdByMe: createdByMe = documents
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveCurrentUserID() async throws -> String {
|
||||
if let currentUserID { return currentUserID }
|
||||
let user = try await apiClient.currentUser()
|
||||
currentUserID = user.id
|
||||
return user.id
|
||||
}
|
||||
|
||||
private static func fingerprint(_ documents: [OutlineDocument]) -> String {
|
||||
documents
|
||||
.map { "\($0.id):\($0.updatedAt.timeIntervalSince1970)" }
|
||||
.sorted()
|
||||
.joined(separator: "|")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Deliberately distinct from `DocumentCardView` — the pinned section is for
|
||||
/// a quick scan of a small curated set, not browsing, so this is a dense
|
||||
/// single-line row rather than a tall card, with an explicit pin glyph so
|
||||
/// it doesn't read the same as the tab grids below it.
|
||||
struct PinnedDocumentCard: View {
|
||||
@Environment(StarStore.self) private var starStore
|
||||
let document: OutlineDocument
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
if let emoji = document.emoji {
|
||||
Text(emoji)
|
||||
.font(.title3)
|
||||
} else {
|
||||
Image(systemName: "doc.text")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(document.title.isEmpty ? "Untitled" : document.title)
|
||||
.font(.callout.weight(.medium))
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if starStore.isStarred(documentId: document.id) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
|
||||
Image(systemName: "pin.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+14
-13
@@ -14,10 +14,10 @@ import AppKit
|
||||
@main
|
||||
struct OutpostApp: App {
|
||||
@State private var session = SessionStore()
|
||||
@State private var navigation = AppNavigation()
|
||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||
|
||||
#if os(macOS)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@State private var isShowingLogoutConfirmation = false
|
||||
#endif
|
||||
|
||||
@@ -25,6 +25,7 @@ struct OutpostApp: App {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environment(session)
|
||||
.environment(navigation)
|
||||
#if os(iOS)
|
||||
.preferredColorScheme(appearance.colorScheme)
|
||||
#endif
|
||||
@@ -38,9 +39,20 @@ struct OutpostApp: App {
|
||||
.commands {
|
||||
CommandGroup(replacing: .appInfo) {
|
||||
Button("About Outpost") {
|
||||
openWindow(id: "about")
|
||||
navigation.selectedSettingsSection = .about
|
||||
navigation.isShowingSettings = true
|
||||
}
|
||||
}
|
||||
// No `Settings {}` scene anymore — Settings renders inside the
|
||||
// root window (see `AppNavigation`), not a separate popup, so
|
||||
// ⌘, has to be wired up by hand instead of coming for free.
|
||||
CommandGroup(replacing: .appSettings) {
|
||||
Button("Settings…") {
|
||||
navigation.isShowingSettings = true
|
||||
}
|
||||
.keyboardShortcut(",")
|
||||
.disabled(!session.isSignedIn)
|
||||
}
|
||||
CommandGroup(after: .appSettings) {
|
||||
Divider()
|
||||
Button("Log Out…") {
|
||||
@@ -52,22 +64,11 @@ struct OutpostApp: App {
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
Window("About Outpost", id: "about") {
|
||||
AboutView()
|
||||
.disablesFullScreen()
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
Window("Keyboard Shortcuts", id: "keyboard-shortcuts") {
|
||||
KeyboardShortcutsView()
|
||||
.disablesFullScreen()
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
Settings {
|
||||
PreferencesView()
|
||||
.environment(session)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import Observation
|
||||
|
||||
/// Top-level groupings shown as section headers in `SettingsSidebarList`.
|
||||
/// `general` holds everything that's ours, not Outline's own settings
|
||||
/// categories — labeled "Outpost" so it reads as clearly distinct from the
|
||||
/// Outline-sourced groups below it.
|
||||
enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||
case general
|
||||
case account
|
||||
case workspace
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String? {
|
||||
switch self {
|
||||
case .general: return "Outpost"
|
||||
case .account: return "Account"
|
||||
case .workspace: return "Workspace"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry in the Settings sidebar. Mirrors Outline's own settings
|
||||
/// categories (Account/Workspace) so this app's settings read as a native
|
||||
/// counterpart to the web app's, plus a `general` group for things that are
|
||||
/// ours and don't map onto Outline's structure (offline/sync, advanced,
|
||||
/// about, appearance). Outline's own version info moved to the sidebar
|
||||
/// footer (`SettingsSidebarList`) instead of a standalone
|
||||
/// Integrations & Installation section.
|
||||
///
|
||||
/// Most of the Account/Workspace cases are navigation-only for now —
|
||||
/// `SettingsView` renders a "Coming Soon" placeholder for anything not
|
||||
/// explicitly built yet. Content lands section by section.
|
||||
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
// General (ours)
|
||||
case appearance, offlineSync, advanced, about
|
||||
|
||||
// Account
|
||||
case profile, preferences, notifications, passkeys, apiAccess
|
||||
|
||||
// Workspace
|
||||
case details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var category: SettingsCategory {
|
||||
switch self {
|
||||
case .appearance, .offlineSync, .advanced, .about:
|
||||
return .general
|
||||
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||
return .account
|
||||
case .details, .authentication, .security, .ai, .members, .groups, .templates, .emojis, .applications, .shared, .links, .webhooks, .importData, .exportData:
|
||||
return .workspace
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .appearance: return "Appearance"
|
||||
case .offlineSync: return "Offline & Sync"
|
||||
case .advanced: return "Advanced"
|
||||
case .about: return "About"
|
||||
case .profile: return "Profile"
|
||||
case .preferences: return "Preferences"
|
||||
case .notifications: return "Notifications"
|
||||
case .passkeys: return "Passkeys"
|
||||
case .apiAccess: return "API & Access"
|
||||
case .details: return "Details"
|
||||
case .authentication: return "Authentication"
|
||||
case .security: return "Security"
|
||||
case .ai: return "AI"
|
||||
case .members: return "Members"
|
||||
case .groups: return "Groups"
|
||||
case .templates: return "Templates"
|
||||
case .emojis: return "Emojis"
|
||||
case .applications: return "Applications"
|
||||
case .shared: return "Shared"
|
||||
case .links: return "Links"
|
||||
case .webhooks: return "Webhooks"
|
||||
case .importData: return "Import"
|
||||
case .exportData: return "Export"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .appearance: return "paintbrush"
|
||||
case .offlineSync: return "arrow.triangle.2.circlepath"
|
||||
case .advanced: return "wrench.and.screwdriver"
|
||||
case .about: return "info.circle"
|
||||
case .profile: return "person.crop.circle"
|
||||
case .preferences: return "gearshape"
|
||||
case .notifications: return "bell"
|
||||
case .passkeys: return "key"
|
||||
case .apiAccess: return "chevron.left.forwardslash.chevron.right"
|
||||
case .details: return "building.2"
|
||||
case .authentication: return "lock"
|
||||
case .security: return "shield"
|
||||
case .ai: return "sparkles"
|
||||
case .members: return "person.2"
|
||||
case .groups: return "person.3"
|
||||
case .templates: return "doc.on.doc"
|
||||
case .emojis: return "face.smiling"
|
||||
case .applications: return "app.badge"
|
||||
case .shared: return "square.and.arrow.up.on.square"
|
||||
case .links: return "link"
|
||||
case .webhooks: return "bolt.horizontal"
|
||||
case .importData: return "square.and.arrow.down"
|
||||
case .exportData: return "square.and.arrow.up"
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything actually built so far — everything else in Account/
|
||||
/// Workspace renders a "Coming Soon" placeholder until its content is
|
||||
/// specified and built.
|
||||
var isImplemented: Bool {
|
||||
switch self {
|
||||
case .appearance, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cross-cutting UI state that doesn't belong to any one screen. Lives at
|
||||
/// `OutpostApp` (so both `RootView`'s content and its `⌘,` command can reach
|
||||
/// it) and is read wherever something needs to open Settings (the profile
|
||||
/// menu) or render it — as a swap of the *existing* sidebar/detail panes in
|
||||
/// `ContentView_macOS`, not a separate popup window or an overlay that hides
|
||||
/// the sidebar.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class AppNavigation {
|
||||
var isShowingSettings = false
|
||||
var selectedSettingsSection: SettingsSection? = .appearance
|
||||
}
|
||||
@@ -5,6 +5,19 @@ struct RootView: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@State private var welcomeName: String?
|
||||
@State private var starStore = StarStore()
|
||||
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
|
||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||
|
||||
/// Real connectivity is only half of "can talk to the server" — the
|
||||
/// manual Offline Mode toggle is the other half. Syncing needs to
|
||||
/// resume on either one clearing, not just a real reconnect: turning
|
||||
/// the toggle off while the network had been up the whole time never
|
||||
/// changes `networkMonitor.isOnline`, so keying the flush task off that
|
||||
/// alone left pending operations stuck until the next real network
|
||||
/// blip.
|
||||
private var isEffectivelyOnline: Bool {
|
||||
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -32,6 +45,48 @@ struct RootView: View {
|
||||
starStore.reset()
|
||||
}
|
||||
}
|
||||
// Replay whatever queued up while offline the moment either signal
|
||||
// clears — a real reconnect, or the user turning Offline Mode back
|
||||
// off — no need to wait for the user to open Settings and hit Retry.
|
||||
// Also catches Full Local Sync back up immediately, rather than
|
||||
// leaving it to wait out the rest of the periodic loop below.
|
||||
//
|
||||
// The flush itself gets a retry loop with a cooloff, not just one
|
||||
// attempt: a single transient failure (one bad operation, a blip
|
||||
// mid-flush) used to leave everything else stuck until the user
|
||||
// manually hit Retry or connectivity changed again. Keeps retrying
|
||||
// every 5 minutes for as long as *anything* is still pending and
|
||||
// this signal stays on — stops on its own once the queue is empty,
|
||||
// and `.task(id:)` cancels/restarts it automatically if
|
||||
// isEffectivelyOnline flips again in the meantime.
|
||||
.task(id: isEffectivelyOnline) {
|
||||
guard isEffectivelyOnline, let cachingClient = session.cachingClient else { return }
|
||||
if isFullLocalSyncEnabled {
|
||||
_ = await cachingClient.performFullSync()
|
||||
}
|
||||
while !Task.isCancelled {
|
||||
_ = await cachingClient.flushPendingOperations()
|
||||
let remaining = await cachingClient.pendingOperations()
|
||||
guard !remaining.isEmpty else { break }
|
||||
try? await Task.sleep(for: .seconds(300))
|
||||
}
|
||||
}
|
||||
// Fully automatic — this is the only place Full Local Sync actually
|
||||
// runs from (Settings' "Sync Now" is just an on-demand nudge at the
|
||||
// same call). Syncs immediately whenever this task (re)starts, which
|
||||
// covers both "just switched on" and "was already on at launch" —
|
||||
// `.task(id:)` restarts on either, an `@AppStorage`-backed toggle
|
||||
// changing anywhere updates every view reading that key — then every
|
||||
// 20 minutes after, for as long as it stays enabled.
|
||||
.task(id: isFullLocalSyncEnabled) {
|
||||
guard isFullLocalSyncEnabled else { return }
|
||||
while !Task.isCancelled {
|
||||
if isEffectivelyOnline, let cachingClient = session.cachingClient {
|
||||
_ = await cachingClient.performFullSync()
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(1200))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
|
||||
|
||||
@@ -11,12 +11,26 @@ final class SessionStore {
|
||||
private let defaults: UserDefaults
|
||||
|
||||
var isSignedIn: Bool
|
||||
private(set) var userId: String?
|
||||
var userName: String?
|
||||
var userEmail: String?
|
||||
var userAvatarURL: URL?
|
||||
var userLanguage: String?
|
||||
var userPreferences: OutlineUserPreferences?
|
||||
var userNotificationSettings: [String: Bool]?
|
||||
var teamName: String?
|
||||
var teamAvatarURL: URL?
|
||||
private(set) var apiClient: OutlineAPIClient?
|
||||
/// Same object as `apiClient` when the offline cache is available — kept
|
||||
/// as a separately-typed reference so Settings can reach cache/sync-queue
|
||||
/// specific methods (`flushPendingOperations`, `performFullSync`, etc.)
|
||||
/// without downcasting the protocol-typed `apiClient` everywhere.
|
||||
private(set) var cachingClient: CachingOutlineAPIClient?
|
||||
let networkMonitor = NetworkMonitor()
|
||||
/// `nil` only if the on-disk SwiftData store failed to open (e.g. disk
|
||||
/// full) — in that case `apiClient` falls back to talking to the server
|
||||
/// directly with no offline cache, rather than failing sign-in outright.
|
||||
private let cacheStore: OfflineCacheStore?
|
||||
|
||||
var serverURL: URL? {
|
||||
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
|
||||
@@ -25,36 +39,66 @@ final class SessionStore {
|
||||
init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) {
|
||||
self.tokenStore = tokenStore
|
||||
self.defaults = defaults
|
||||
self.isSignedIn = (try? tokenStore.token()) != nil
|
||||
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
|
||||
|
||||
if isSignedIn, let serverURL {
|
||||
apiClient = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: serverURL),
|
||||
tokenStore: tokenStore
|
||||
)
|
||||
let hasToken = (try? tokenStore.token()) != nil
|
||||
let storedServerURL = defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
|
||||
|
||||
if hasToken, let storedServerURL {
|
||||
isSignedIn = true
|
||||
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore)
|
||||
} else {
|
||||
// Keychain and the sandboxed UserDefaults container don't
|
||||
// always survive together — a Keychain item written by an
|
||||
// older-signed build can outlive a reinstall that wipes the
|
||||
// container (or vice versa), leaving a token with no server or
|
||||
// a server with no token. Clear whichever half survived rather
|
||||
// than showing a broken "signed in" UI with no working
|
||||
// apiClient — a fresh sign-in rewrites both consistently.
|
||||
if hasToken {
|
||||
try? tokenStore.clear()
|
||||
}
|
||||
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
||||
isSignedIn = false
|
||||
}
|
||||
}
|
||||
|
||||
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
|
||||
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
|
||||
apiClient = LiveOutlineAPIClient(
|
||||
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
|
||||
apply(user: user, team: team, serverURL: serverURL)
|
||||
isSignedIn = true
|
||||
}
|
||||
|
||||
private static func makeAPIClient(
|
||||
serverURL: URL,
|
||||
tokenStore: TokenStoring,
|
||||
cache: OfflineCacheStore?
|
||||
) -> (OutlineAPIClient, CachingOutlineAPIClient?) {
|
||||
let live = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: serverURL),
|
||||
tokenStore: tokenStore
|
||||
)
|
||||
apply(user: user, team: team, serverURL: serverURL)
|
||||
isSignedIn = true
|
||||
guard let cache else { return (live, nil) }
|
||||
let caching = CachingOutlineAPIClient(live: live, cache: cache)
|
||||
return (caching, caching)
|
||||
}
|
||||
|
||||
func signOut() {
|
||||
try? tokenStore.clear()
|
||||
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
||||
isSignedIn = false
|
||||
userId = nil
|
||||
userName = nil
|
||||
userEmail = nil
|
||||
userAvatarURL = nil
|
||||
userLanguage = nil
|
||||
userPreferences = nil
|
||||
userNotificationSettings = nil
|
||||
teamName = nil
|
||||
teamAvatarURL = nil
|
||||
apiClient = nil
|
||||
cachingClient = nil
|
||||
}
|
||||
|
||||
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
|
||||
@@ -65,13 +109,30 @@ final class SessionStore {
|
||||
apply(user: auth.user, team: auth.team, serverURL: serverURL)
|
||||
}
|
||||
|
||||
/// Settings calls this after a successful name/avatar change so the
|
||||
/// sidebar's account footer and everywhere else reading these reflect
|
||||
/// it immediately, without waiting for the next `auth.info` refresh.
|
||||
func applyUpdatedProfile(_ user: OutlineUser) {
|
||||
guard let serverURL else { return }
|
||||
userId = user.id
|
||||
userName = user.name
|
||||
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||
userLanguage = user.language
|
||||
userPreferences = user.preferences
|
||||
userNotificationSettings = user.notificationSettings
|
||||
}
|
||||
|
||||
private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
|
||||
userId = user.id
|
||||
userName = user.name
|
||||
userEmail = user.email
|
||||
// Outline can return either an absolute URL or a server-relative path
|
||||
// (e.g. `/api/files.get?key=...`) for avatarUrl — resolve against the
|
||||
// configured server so relative paths don't fail as "unsupported URL".
|
||||
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||
userLanguage = user.language
|
||||
userPreferences = user.preferences
|
||||
userNotificationSettings = user.notificationSettings
|
||||
teamName = team.name
|
||||
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -41,11 +42,46 @@ struct AvatarBadge: View {
|
||||
.task(id: avatarURL) {
|
||||
loadedImage = nil
|
||||
guard let avatarURL else { return }
|
||||
guard let (data, _) = try? await URLSession.shared.data(from: avatarURL) else { return }
|
||||
loadedImage = PlatformImage(data: data)
|
||||
loadedImage = await Self.loadImage(from: avatarURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// The real fix, confirmed against a live network capture: every other
|
||||
/// request this app makes attaches `Authorization: Bearer <token>` —
|
||||
/// this one never did, sending a bare unauthenticated GET. Outline's
|
||||
/// browser session authenticates `attachments.redirect` via cookies
|
||||
/// instead, which a native app doesn't have; the API-token equivalent
|
||||
/// is the same Bearer header every RPC call already uses. Almost
|
||||
/// certainly means no avatar image (not just a freshly-uploaded one)
|
||||
/// has ever actually loaded in this app — a 401 and a "no avatar set"
|
||||
/// look identical here, both just fall back to the placeholder icon
|
||||
/// with nothing on screen to flag it as an error.
|
||||
///
|
||||
/// The retry loop is a secondary, independent hardening — cheap
|
||||
/// insurance against a self-hosted reverse-proxied storage backend not
|
||||
/// being instantly consistent right after an upload — kept alongside
|
||||
/// the auth fix rather than instead of it.
|
||||
private static func loadImage(from url: URL) async -> PlatformImage? {
|
||||
var request = URLRequest(url: url)
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
if let token = try? KeychainTokenStore().token() {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
for attempt in 0..<3 {
|
||||
if attempt > 0 {
|
||||
try? await Task.sleep(for: .milliseconds(400))
|
||||
}
|
||||
if let (data, response) = try? await URLSession.shared.data(for: request),
|
||||
let httpResponse = response as? HTTPURLResponse,
|
||||
(200...299).contains(httpResponse.statusCode),
|
||||
let image = PlatformImage(data: data) {
|
||||
return image
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func platformImage(_ image: PlatformImage) -> Image {
|
||||
#if os(macOS)
|
||||
Image(nsImage: image)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
extension NSImage {
|
||||
/// `NSImage` has no built-in JPEG encoder (unlike `UIImage`) — routes
|
||||
/// through a bitmap representation to get one.
|
||||
func jpegData(compressionQuality: CGFloat) -> Data? {
|
||||
guard let tiffData = tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData) else {
|
||||
return nil
|
||||
}
|
||||
return bitmap.representation(using: .jpeg, properties: [.compressionFactor: compressionQuality])
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
|
||||
/// Backs the sidebar's offline badge — purely a UI signal. Unrelated to
|
||||
/// `CachingOutlineAPIClient`'s own fallback logic, which reacts to actual
|
||||
/// request failures rather than pre-checking reachability.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class NetworkMonitor {
|
||||
private(set) var isOnline = true
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue(label: "com.outpost.network-monitor")
|
||||
|
||||
init() {
|
||||
// Weak on the outer closure (it's held by `monitor` for the object's
|
||||
// whole lifetime, so a strong capture here would be a retain cycle),
|
||||
// then unwrapped once into a plain strong local — the inner `Task`
|
||||
// closure capturing that local `self` is what a nested closure needs
|
||||
// to be consistent about capture semantics with its enclosing one.
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
guard let self else { return }
|
||||
|
||||
let online = path.status == .satisfied
|
||||
Task { @MainActor in
|
||||
self.isOnline = online
|
||||
}
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
deinit {
|
||||
monitor.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
/// Outline's interface-language options. Not exhaustive — Outline accepts
|
||||
/// community translations via its own translation portal, so the real list
|
||||
/// on any given server can be longer than this; this covers the common
|
||||
/// cases and falls back to showing whatever code the server already has
|
||||
/// set even if it isn't in this list.
|
||||
struct OutlineLocale: Identifiable, Hashable {
|
||||
let code: String
|
||||
let label: String
|
||||
|
||||
var id: String { code }
|
||||
|
||||
static let all: [OutlineLocale] = [
|
||||
OutlineLocale(code: "en_US", label: "English (US)"),
|
||||
OutlineLocale(code: "en_GB", label: "English (UK)"),
|
||||
OutlineLocale(code: "de_DE", label: "Deutsch"),
|
||||
OutlineLocale(code: "fr_FR", label: "Français"),
|
||||
OutlineLocale(code: "es_ES", label: "Español"),
|
||||
OutlineLocale(code: "pt_PT", label: "Português"),
|
||||
OutlineLocale(code: "pt_BR", label: "Português (Brasil)"),
|
||||
OutlineLocale(code: "it_IT", label: "Italiano"),
|
||||
OutlineLocale(code: "nl_NL", label: "Nederlands"),
|
||||
OutlineLocale(code: "pl_PL", label: "Polski"),
|
||||
OutlineLocale(code: "ru_RU", label: "Русский"),
|
||||
OutlineLocale(code: "ja_JP", label: "日本語"),
|
||||
OutlineLocale(code: "ko_KR", label: "한국어"),
|
||||
OutlineLocale(code: "zh_CN", label: "中文 (简体)"),
|
||||
OutlineLocale(code: "zh_TW", label: "中文 (繁體)"),
|
||||
]
|
||||
|
||||
static func label(for code: String) -> String {
|
||||
all.first(where: { $0.code == code })?.label ?? code
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
/// Single source of truth for how Outpost's own version is formatted —
|
||||
/// used by both the About page and the Settings sidebar footer, so they
|
||||
/// can't drift out of sync the way `AboutInfoView` was already written to
|
||||
/// avoid for its own two call sites.
|
||||
enum OutpostVersion {
|
||||
/// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out
|
||||
/// of the bundle version itself since `CFBundleShortVersionString` is
|
||||
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
|
||||
static let releaseStage = "ALPHA"
|
||||
|
||||
static var shortVersion: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
|
||||
}
|
||||
|
||||
static var buildNumber: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
||||
}
|
||||
|
||||
/// e.g. `"0.0.3-ALPHA"` — for compact display (sidebar footer).
|
||||
static var displayString: String {
|
||||
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
|
||||
return "\(shortVersion)\(stageSuffix)"
|
||||
}
|
||||
|
||||
/// e.g. `"Version 0.0.3-ALPHA (1)"` — for the About page.
|
||||
static var fullVersionString: String {
|
||||
"Version \(displayString) (\(buildNumber))"
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,29 @@
|
||||
# Outpost
|
||||
<p align="center">
|
||||
<img src="Outpost/Assets.xcassets/AppLogo.imageset/outpost-ios-1024.png" width="120" alt="Outpost logo">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Outpost</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://testflight.apple.com/join/y1mYcYAM">
|
||||
<img src="https://img.shields.io/badge/Download-TestFlight-0D96F6?style=for-the-badge&logo=apple&logoColor=white" alt="Download on TestFlight">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
A native Apple ecosystem client for [Outline](https://github.com/outline/outline) — built for iOS, iPadOS, and macOS from a single SwiftUI codebase, aiming for full editing parity with Outline's web app, including realtime collaborative editing.
|
||||
|
||||
> **Early alpha — macOS only for now.** Expect missing features and rough edges. iOS/iPadOS support is planned but not in the current build. See the [releases page](https://git.psmattas.com/psmattas/Outpost/releases) for changelogs, and [open an issue](https://git.psmattas.com/psmattas/Outpost/issues) if you hit anything.
|
||||
|
||||
## Why
|
||||
|
||||
Outline's web app is great, but there's no native Apple client with full editing parity. This project connects to a self-hosted Outline instance over its REST API and realtime collaboration socket to provide a proper native experience across the Apple ecosystem.
|
||||
|
||||
## Status
|
||||
|
||||
Early development. See `CLAUDE.md` for the current architecture and phased build plan.
|
||||
|
||||
- [ ] Phase 1 — Auth, browse, search, REST-only editing
|
||||
- [ ] Phase 2 — Realtime collaborative editing (Yjs/Hocuspocus)
|
||||
- [ ] Phase 3 — Offline cache, tables, embeds, comments, macOS polish
|
||||
|
||||
## Requirements
|
||||
|
||||
- Xcode 16+
|
||||
- iOS 17+ / iPadOS 17+ / macOS 14+
|
||||
- Xcode 27+ (currently developed against an Xcode 27 beta — this is a hard minimum, not a suggestion)
|
||||
- macOS 27+. iOS/iPadOS support is planned but not in the current build (see the alpha note above) — same 27+ minimum will apply once it lands
|
||||
- A self-hosted (or hosted) Outline instance with API access
|
||||
|
||||
> They will be updated. These are old requirements.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Clone the repo and open the `.xcodeproj` in Xcode.
|
||||
@@ -42,6 +44,16 @@ Parts of this codebase are AI-assisted (built with the help of AI coding tools).
|
||||
|
||||
This project is a client only — it does not include, vendor, or redistribute any of Outline's (BSL 1.1 licensed) server source. See [`LICENSE`](./LICENSE) for this repository's own license.
|
||||
|
||||
## Privacy
|
||||
|
||||
Outpost collects nothing about you — no analytics, no telemetry, no crash reporting of its own, no age or demographic data, nothing. The only thing stored locally is your Outline server URL and API token (in the device Keychain) and, optionally, a local offline cache of what you've viewed. Everything else goes straight from your device to whatever Outline server you configure — there's no backend in between, and the developer has no access to your data or your server.
|
||||
|
||||
Full policy, terms of service, and data-processing statement are on the [wiki](https://git.psmattas.com/psmattas/Outpost/wiki):
|
||||
|
||||
- [Privacy Policy](https://git.psmattas.com/psmattas/Outpost/wiki/Privacy-Policy.-)
|
||||
- [Terms of Service](https://git.psmattas.com/psmattas/Outpost/wiki/Terms-of-Service.-)
|
||||
- [Data Processing Statement](https://git.psmattas.com/psmattas/Outpost/wiki/Data-Processing-Statement.-)
|
||||
|
||||
## Not affiliated with Outline
|
||||
|
||||
This is an independent, unofficial client. Not affiliated with or endorsed by General Outline, Inc.
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability in Outpost, please do **not**
|
||||
open a public issue.
|
||||
|
||||
Report it privately to: **security@psmattas.com**
|
||||
|
||||
Include:
|
||||
- A description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Any suggested fixes if available
|
||||
|
||||
We will acknowledge receipt within 48 hours and aim to release a fix
|
||||
within 14 days depending on severity.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Outpost is in early alpha (`0.0.x`) — there's no stable release line
|
||||
yet. Only the most recent tagged release receives fixes; please make
|
||||
sure you're on the latest alpha before reporting.
|
||||
|
||||
| Version | Supported |
|
||||
| :--- | :---: |
|
||||
| Latest tagged release | ✅ |
|
||||
| Older releases | ❌ |
|
||||
|
||||
## Scope
|
||||
|
||||
Outpost is a client application that talks to a self-hosted Outline
|
||||
instance you control — it doesn't run any server infrastructure of its
|
||||
own. Vulnerabilities in Outline itself belong to
|
||||
[outline/outline](https://github.com/outline/outline), not this repo.
|
||||
API tokens are stored in the system Keychain only (never `UserDefaults`,
|
||||
never logged) — see [`CLAUDE.md`](CLAUDE.md) for the relevant
|
||||
conventions if you're reviewing that code path.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Outpost Setup Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- macOS with a recent Xcode (Xcode 16 or later)
|
||||
- A self-hosted Outline instance (or getoutline.com) and a personal API
|
||||
key — Settings → API Keys on that instance. Outpost only ever talks
|
||||
to Outline's public REST/WebSocket API; it doesn't vendor or run any
|
||||
part of Outline's server.
|
||||
|
||||
## 1. Clone with submodules
|
||||
|
||||
The vendored OpenAPI reference (`docs/reference/outline-openapi`) is a
|
||||
git submodule.
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules <repo-url>
|
||||
# or, if you already cloned without it:
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
To pull that submodule up to whatever's newest upstream:
|
||||
|
||||
```bash
|
||||
git submodule update --remote docs/reference/outline-openapi
|
||||
```
|
||||
|
||||
## 2. Build and test `OutlineKit`
|
||||
|
||||
`OutlineKit` is a standalone Swift package (the REST client layer) and
|
||||
the only part of this repo with a reliable command-line build/test path.
|
||||
|
||||
```bash
|
||||
cd OutlineKit
|
||||
swift build
|
||||
swift test
|
||||
```
|
||||
|
||||
## 3. Open the app in Xcode
|
||||
|
||||
```bash
|
||||
open Outpost.xcodeproj
|
||||
```
|
||||
|
||||
- Select the **Outpost** scheme.
|
||||
- In **Signing & Capabilities**, pick your own team. A free personal
|
||||
Apple ID team works fine for building and running locally — it just
|
||||
means the app isn't notarized, so distributed builds trigger
|
||||
Gatekeeper's "unidentified developer" warning on other machines (see
|
||||
`scripts/package-dmg.sh`, which ships a README explaining the bypass).
|
||||
- Run. There's no way to build the app target reliably from the CLI in
|
||||
this project's current setup — use Xcode.
|
||||
|
||||
## 4. Sign in
|
||||
|
||||
On first launch, enter your Outline instance's URL and the API key
|
||||
from step 0. The token is stored in the system Keychain only.
|
||||
|
||||
## 5. Packaging a release build
|
||||
|
||||
After archiving in Xcode (Product → Archive → Distribute App → Copy
|
||||
App), package it into a DMG:
|
||||
|
||||
```bash
|
||||
./scripts/package-dmg.sh /path/to/exported/Outpost.app
|
||||
```
|
||||
|
||||
This also prints a Markdown changelog (grouped by commit type, since
|
||||
the last git tag) for pasting into release notes.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [`CLAUDE.md`](CLAUDE.md) — project conventions, phased build order, what's in/out of scope right now.
|
||||
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — deeper technical rationale (API surface, the realtime collaboration transport, the CRDT engine, known risk areas).
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — branching, commit conventions, PR expectations.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `swift build`/`swift test` fails in `OutlineKit`
|
||||
Make sure you're running it from inside the `OutlineKit/` directory,
|
||||
not the repo root — it's a separate Swift package, not part of the
|
||||
Xcode project's own build.
|
||||
|
||||
### Submodule directory is empty
|
||||
You cloned without `--recurse-submodules`. Run
|
||||
`git submodule update --init --recursive` from the repo root.
|
||||
|
||||
### App builds but sign-in fails
|
||||
Double-check the server URL (including `https://`) and that the API
|
||||
key hasn't been revoked on the Outline instance's Settings → API Keys
|
||||
page.
|
||||
@@ -98,7 +98,12 @@ if [ -z "$LATEST_TAG" ]; then
|
||||
HEADING="Changelog"
|
||||
else
|
||||
HEAD_COMMIT="$(git -C "$REPO_ROOT" rev-parse HEAD)"
|
||||
TAG_COMMIT="$(git -C "$REPO_ROOT" rev-parse "$LATEST_TAG")"
|
||||
# `^{commit}` peels an annotated tag down to the commit it points at —
|
||||
# without it, `rev-parse` on an annotated tag (e.g. `git tag -a`) returns
|
||||
# the tag *object's* hash, which never equals a commit hash, so this
|
||||
# comparison would always take the "not tagged yet" branch below even
|
||||
# when HEAD genuinely is the tagged commit.
|
||||
TAG_COMMIT="$(git -C "$REPO_ROOT" rev-parse "${LATEST_TAG}^{commit}")"
|
||||
if [ "$HEAD_COMMIT" = "$TAG_COMMIT" ]; then
|
||||
PREV_TAG="$(git -C "$REPO_ROOT" describe --tags --abbrev=0 "${LATEST_TAG}^" 2>/dev/null || true)"
|
||||
RANGE="${PREV_TAG:+$PREV_TAG..}$LATEST_TAG"
|
||||
|
||||
Reference in New Issue
Block a user