Compare commits
27
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/Preview.html
|
||||||
fastlane/screenshots/**/*.png
|
fastlane/screenshots/**/*.png
|
||||||
fastlane/test_output
|
fastlane/test_output
|
||||||
|
|
||||||
|
# scripts/package-dmg.sh output
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
# Local working checklist - intentionally never version controlled
|
||||||
|
/TODO.local.md
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# Outpost Code Owners
|
||||||
|
# These users are automatically requested for review on PRs.
|
||||||
|
# Format: path @username
|
||||||
|
#
|
||||||
|
# Rules are evaluated in order, last match wins — so the specific paths
|
||||||
|
# below stay pinned to @psmattas even if `*` is ever opened up to other
|
||||||
|
# contributors/reviewers as the project grows. These are the
|
||||||
|
# supply-chain, governance, and CI-relevant files where an accidental or
|
||||||
|
# malicious change has outsized blast radius for a public repo.
|
||||||
|
|
||||||
|
* @psmattas
|
||||||
|
|
||||||
|
# Repo governance / legal — changes here affect every contributor.
|
||||||
|
/LICENSE @psmattas
|
||||||
|
/CODEOWNERS @psmattas
|
||||||
|
/CONTRIBUTING.md @psmattas
|
||||||
|
/SECURITY.md @psmattas
|
||||||
|
/SETUP.md @psmattas
|
||||||
|
|
||||||
|
# CI, issue/PR automation, review requirements — tampering here can
|
||||||
|
# bypass the protections this very file is trying to set up.
|
||||||
|
/.gitea/ @psmattas
|
||||||
|
|
||||||
|
# Dependency supply chain — a swapped or re-pinned package here can pull
|
||||||
|
# in arbitrary code at build time.
|
||||||
|
/OutlineKit/Package.swift @psmattas
|
||||||
|
/OutlineKit/Package.resolved @psmattas
|
||||||
|
/Outpost.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @psmattas
|
||||||
|
|
||||||
|
# Build/signing/versioning config and release tooling.
|
||||||
|
/Outpost.xcodeproj/project.pbxproj @psmattas
|
||||||
|
/scripts/ @psmattas
|
||||||
|
|
||||||
|
# Project direction — architecture/scope decisions shouldn't drift via a
|
||||||
|
# drive-by PR.
|
||||||
|
/CLAUDE.md @psmattas
|
||||||
|
/docs/ARCHITECTURE.md @psmattas
|
||||||
@@ -0,0 +1,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.
|
||||||
@@ -9,6 +9,10 @@ public protocol OutlineAPIClient: Sendable {
|
|||||||
|
|
||||||
func documentInfo(id: String) async throws -> OutlineDocument
|
func documentInfo(id: String) async throws -> OutlineDocument
|
||||||
func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument]
|
func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument]
|
||||||
|
/// Richer filtering (sort/direction/userId) for the Home page's tabs. See `DocumentsListRequest`.
|
||||||
|
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument]
|
||||||
|
/// Documents the current user has recently viewed. Backed by `documents.viewed`.
|
||||||
|
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument]
|
||||||
/// Full-text search with snippets/ranking. Backed by `documents.search`.
|
/// Full-text search with snippets/ranking. Backed by `documents.search`.
|
||||||
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
|
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
|
||||||
/// Title-only search — faster, no snippets. Backed by `documents.search_titles`.
|
/// Title-only search — faster, no snippets. Backed by `documents.search_titles`.
|
||||||
@@ -31,6 +35,8 @@ public protocol OutlineAPIClient: Sendable {
|
|||||||
func createShare(_ request: CreateShareRequest) async throws -> OutlineShare
|
func createShare(_ request: CreateShareRequest) async throws -> OutlineShare
|
||||||
func shareInfo(documentId: String) async throws -> OutlineShare?
|
func shareInfo(documentId: String) async throws -> OutlineShare?
|
||||||
func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare
|
func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare
|
||||||
|
func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare]
|
||||||
|
func revokeShare(id: String) async throws
|
||||||
/// See `OutlinePin` — best-effort, not in the vendored spec.
|
/// See `OutlinePin` — best-effort, not in the vendored spec.
|
||||||
func createPin(_ request: CreatePinRequest) async throws -> OutlinePin
|
func createPin(_ request: CreatePinRequest) async throws -> OutlinePin
|
||||||
func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin]
|
func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin]
|
||||||
@@ -42,6 +48,14 @@ public protocol OutlineAPIClient: Sendable {
|
|||||||
/// Historical view records, not live presence. Backed by `views.list`.
|
/// Historical view records, not live presence. Backed by `views.list`.
|
||||||
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView]
|
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView]
|
||||||
|
|
||||||
|
/// See `OutlineMembership`/`OutlineDocumentMember` — `add` is confirmed
|
||||||
|
/// from Outline's official docs, the rest are best-effort.
|
||||||
|
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership
|
||||||
|
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws
|
||||||
|
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember]
|
||||||
|
/// For searching workspace members to invite. Backed by `users.list`.
|
||||||
|
func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser]
|
||||||
|
|
||||||
func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection]
|
func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection]
|
||||||
func collectionInfo(id: String) async throws -> OutlineCollection
|
func collectionInfo(id: String) async throws -> OutlineCollection
|
||||||
func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection
|
func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection
|
||||||
|
|||||||
@@ -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] {
|
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
|
||||||
try await post("documents.search", body: request)
|
try await post("documents.search", body: request)
|
||||||
}
|
}
|
||||||
@@ -113,23 +121,38 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func shareInfo(documentId: String) async throws -> OutlineShare? {
|
public func shareInfo(documentId: String) async throws -> OutlineShare? {
|
||||||
do {
|
// Real shape confirmed against a live server: `data` is
|
||||||
return try await post("shares.info", body: ShareInfoRequest(documentId: documentId))
|
// `{ shares: [...] }`, not the bare share object the docs imply —
|
||||||
} catch OutlineAPIError.notFound {
|
// same pattern as `pins.list`. A document could in principle have
|
||||||
return nil
|
// more than one share record; the first is what the reader's
|
||||||
}
|
// share sheet cares about.
|
||||||
|
let payload: SharesInfoPayload? = try await postOptional("shares.info", body: ShareInfoRequest(documentId: documentId))
|
||||||
|
return payload?.shares.first
|
||||||
}
|
}
|
||||||
|
|
||||||
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
|
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
|
||||||
try await post("shares.update", body: request)
|
try await post("shares.update", body: request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] {
|
||||||
|
try await post("shares.list", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func revokeShare(id: String) async throws {
|
||||||
|
try await postForSuccess("shares.revoke", body: StarIDParams(id: id))
|
||||||
|
}
|
||||||
|
|
||||||
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
||||||
try await post("pins.create", body: request)
|
try await post("pins.create", body: request)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
|
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
|
||||||
try await post("pins.list", body: request)
|
// `data` here is `{ pins: [...], documents: [...] }`, not a bare
|
||||||
|
// array — confirmed against a live server. Decoding straight to
|
||||||
|
// `[OutlinePin]` throws on every call, which `try?` at call sites
|
||||||
|
// swallows silently, so pins never showed up anywhere.
|
||||||
|
let payload: PinsListPayload = try await post("pins.list", body: request)
|
||||||
|
return payload.pins
|
||||||
}
|
}
|
||||||
|
|
||||||
public func deletePin(id: String) async throws {
|
public func deletePin(id: String) async throws {
|
||||||
@@ -152,6 +175,22 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await post("views.list", body: request)
|
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] {
|
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
||||||
try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit))
|
try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit))
|
||||||
}
|
}
|
||||||
@@ -230,6 +269,52 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like `post(_:body:)`, but for endpoints where "no result" comes back
|
||||||
|
/// as a 200 with a completely empty body instead of a real 404 —
|
||||||
|
/// confirmed against a live server for `shares.info` (no share yet for
|
||||||
|
/// a given document). A 404 is still treated as nil too.
|
||||||
|
private func postOptional<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response? {
|
||||||
|
guard let token = try? tokenStore.token() else {
|
||||||
|
throw OutlineAPIError.tokenUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: baseURL.appendingPathComponent("api/\(path)"))
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.httpBody = try encoder.encode(body)
|
||||||
|
|
||||||
|
let data: Data
|
||||||
|
let response: HTTPURLResponse
|
||||||
|
do {
|
||||||
|
(data, response) = try await httpClient.send(request)
|
||||||
|
} catch let error as OutlineAPIError {
|
||||||
|
throw error
|
||||||
|
} catch {
|
||||||
|
throw OutlineAPIError.transport(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard (200...299).contains(response.statusCode) else {
|
||||||
|
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
|
||||||
|
switch response.statusCode {
|
||||||
|
case 401:
|
||||||
|
throw OutlineAPIError.unauthorized
|
||||||
|
case 404:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !data.isEmpty else { return nil }
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try decoder.decode(OutlineEnvelope<Response>.self, from: data).data
|
||||||
|
} catch {
|
||||||
|
throw OutlineAPIError.decoding(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// For endpoints shaped `{ "success": true }` instead of `{ "data": ... }`
|
/// For endpoints shaped `{ "success": true }` instead of `{ "data": ... }`
|
||||||
/// (e.g. `collections.delete`) — `post(_:body:)`'s envelope decode doesn't fit.
|
/// (e.g. `collections.delete`) — `post(_:body:)`'s envelope decode doesn't fit.
|
||||||
private func postForSuccess<Body: Encodable>(_ path: String, body: Body) async throws {
|
private func postForSuccess<Body: Encodable>(_ path: String, body: Body) async throws {
|
||||||
@@ -286,6 +371,15 @@ private struct DuplicateDocumentResponse: Decodable {
|
|||||||
let documents: [OutlineDocument]
|
let documents: [OutlineDocument]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct PinsListPayload: Decodable {
|
||||||
|
let pins: [OutlinePin]
|
||||||
|
let documents: [OutlineDocument]
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SharesInfoPayload: Decodable {
|
||||||
|
let shares: [OutlineShare]
|
||||||
|
}
|
||||||
|
|
||||||
private struct ListStarsResponse: Decodable {
|
private struct ListStarsResponse: Decodable {
|
||||||
let stars: [OutlineStar]
|
let stars: [OutlineStar]
|
||||||
}
|
}
|
||||||
@@ -314,6 +408,11 @@ private struct DocumentListParams: Encodable {
|
|||||||
let limit: Int
|
let limit: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct PaginationParams: Encodable {
|
||||||
|
let offset: Int
|
||||||
|
let limit: Int
|
||||||
|
}
|
||||||
|
|
||||||
private struct CollectionListParams: Encodable {
|
private struct CollectionListParams: Encodable {
|
||||||
let offset: Int
|
let offset: Int
|
||||||
let limit: Int
|
let limit: Int
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A user's explicit permission grant on a document, backed by
|
||||||
|
/// `documents.add_user`/`documents.remove_user`. Not in the vendored spec —
|
||||||
|
/// only `documents.add_user`'s shape is confirmed from Outline's official
|
||||||
|
/// docs; `remove` and the response shape here follow the create/delete and
|
||||||
|
/// `{data: ...}` conventions used throughout the rest of this API, but
|
||||||
|
/// aren't verified against a live server yet.
|
||||||
|
public struct OutlineMembership: Decodable, Identifiable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let userId: String
|
||||||
|
public let documentId: String?
|
||||||
|
/// `"read"` or `"read_write"` per the documented enum.
|
||||||
|
public let permission: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A workspace member as returned by `documents.users` in the context of a
|
||||||
|
/// specific document — same identity fields as `OutlineUser`, plus (if the
|
||||||
|
/// server includes it) the permission they hold on that document. Modeled
|
||||||
|
/// separately from `OutlineUser` rather than adding an optional field there,
|
||||||
|
/// since `permission` only makes sense in this document-scoped context.
|
||||||
|
/// Speculative — `documents.users` isn't in the vendored spec, its name is
|
||||||
|
/// inferred from Outline's usual `<resource>.<verb>` convention and hasn't
|
||||||
|
/// been confirmed against a live server.
|
||||||
|
public struct OutlineDocumentMember: Decodable, Identifiable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let name: String
|
||||||
|
public let email: String?
|
||||||
|
public let avatarUrl: String?
|
||||||
|
public let permission: String?
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// A document pinned to the top of a collection (or the team home), backed by
|
/// A document pinned to the top of a collection, or to team Home when
|
||||||
/// `pins.*`. Not in the vendored OpenAPI spec (`docs/reference/outline-openapi`)
|
/// `collectionId` is `nil`. Backed by `pins.*` — not in the vendored OpenAPI
|
||||||
/// — that spec has no `Pins` tag at all — but the endpoint exists on Outline's
|
/// spec (`docs/reference/outline-openapi`, no `Pins` tag at all), but
|
||||||
/// actual server (`server/routes/api/pins.ts` upstream). Shape reconstructed
|
/// confirmed real against a live server's network traffic. `collectionId: nil`
|
||||||
/// from general knowledge of Outline's API, not verified against this spec;
|
/// is what "Pin to Home" actually sends; a non-nil value is "Pin to
|
||||||
/// treat field names as best-effort until confirmed against a live server.
|
/// Collection", a distinct action.
|
||||||
public struct OutlinePin: Decodable, Identifiable, Sendable {
|
public struct OutlinePin: Decodable, Identifiable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let documentId: String
|
public let documentId: String
|
||||||
|
|||||||
@@ -1,10 +1,41 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// A public share link for a document or collection, backed by `shares.*`.
|
/// A public share link for a document or collection, backed by `shares.*`.
|
||||||
|
///
|
||||||
|
/// Only `id` and `published` are guaranteed present on every real share
|
||||||
|
/// object — everything else is modeled as optional even where the official
|
||||||
|
/// API docs don't explicitly mark it nullable, since self-hosted servers
|
||||||
|
/// have repeatedly diverged from the hosted app's documented response shape
|
||||||
|
/// (see `OutlinePin`'s history) and a single unexpectedly-null field used to
|
||||||
|
/// crash this decode entirely ("Got an unexpected response from the
|
||||||
|
/// server").
|
||||||
public struct OutlineShare: Decodable, Identifiable, Sendable {
|
public struct OutlineShare: Decodable, Identifiable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
|
public let published: Bool
|
||||||
|
|
||||||
public let documentId: String?
|
public let documentId: String?
|
||||||
public let collectionId: String?
|
public let collectionId: String?
|
||||||
public let url: String
|
public let documentTitle: String?
|
||||||
public let published: Bool
|
public let documentUrl: String?
|
||||||
|
public let sourceTitle: String?
|
||||||
|
public let sourcePath: String?
|
||||||
|
public let urlId: String?
|
||||||
|
public let url: String?
|
||||||
|
public let domain: String?
|
||||||
|
/// Overrides the source document/collection title on the publicly
|
||||||
|
/// shared page, if set.
|
||||||
|
public let title: String?
|
||||||
|
/// Overrides the workspace branding icon on the publicly shared page,
|
||||||
|
/// if set.
|
||||||
|
public let iconUrl: String?
|
||||||
|
public let includeChildDocuments: Bool?
|
||||||
|
public let allowSubscriptions: Bool?
|
||||||
|
public let allowIndexing: Bool?
|
||||||
|
public let showLastUpdated: Bool?
|
||||||
|
public let showTOC: Bool?
|
||||||
|
public let views: Int?
|
||||||
|
public let createdBy: OutlineUser?
|
||||||
|
public let createdAt: Date?
|
||||||
|
public let updatedAt: Date?
|
||||||
|
public let lastAccessedAt: Date?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// See `OutlinePin` — best-effort shape, not in the vendored spec.
|
/// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to
|
||||||
|
/// Collection" — these are distinct actions on the real server.
|
||||||
public struct CreatePinRequest: Encodable, Sendable {
|
public struct CreatePinRequest: Encodable, Sendable {
|
||||||
public let documentId: String
|
public let documentId: String
|
||||||
public let collectionId: String?
|
public let collectionId: 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,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
|
import Foundation
|
||||||
|
|
||||||
/// See `OutlinePin` — best-effort shape, not in the vendored spec.
|
/// See `OutlinePin`. `collectionId: nil` lists Home pins only.
|
||||||
public struct ListPinsRequest: Encodable, Sendable {
|
public struct ListPinsRequest: Encodable, Sendable {
|
||||||
public let collectionId: String?
|
public let collectionId: String?
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct ListSharesRequest: Encodable, Sendable {
|
||||||
|
public let offset: Int
|
||||||
|
public let limit: Int
|
||||||
|
public let sort: String?
|
||||||
|
public let direction: String?
|
||||||
|
/// Filter to shared documents matching a search query.
|
||||||
|
public let query: String?
|
||||||
|
|
||||||
|
public init(offset: Int = 0, limit: Int = 25, sort: String? = nil, direction: String? = nil, query: String? = nil) {
|
||||||
|
self.offset = offset
|
||||||
|
self.limit = limit
|
||||||
|
self.sort = sort
|
||||||
|
self.direction = direction
|
||||||
|
self.query = query
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// For searching workspace members to invite to a document. Backed by
|
||||||
|
/// `users.list`.
|
||||||
|
public struct ListUsersRequest: Encodable, Sendable {
|
||||||
|
public let query: String?
|
||||||
|
public let offset: Int
|
||||||
|
public let limit: Int
|
||||||
|
|
||||||
|
public init(query: String? = nil, offset: Int = 0, limit: Int = 25) {
|
||||||
|
self.query = query
|
||||||
|
self.offset = offset
|
||||||
|
self.limit = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,6 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
|
|||||||
public let append: Bool?
|
public let append: Bool?
|
||||||
public let fullWidth: Bool?
|
public let fullWidth: Bool?
|
||||||
public let insightsEnabled: Bool?
|
public let insightsEnabled: Bool?
|
||||||
/// 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(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -20,8 +14,7 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
|
|||||||
text: String? = nil,
|
text: String? = nil,
|
||||||
append: Bool? = nil,
|
append: Bool? = nil,
|
||||||
fullWidth: Bool? = nil,
|
fullWidth: Bool? = nil,
|
||||||
insightsEnabled: Bool? = nil,
|
insightsEnabled: Bool? = nil
|
||||||
documentEmbeds: Bool? = nil
|
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -29,6 +22,5 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
|
|||||||
self.append = append
|
self.append = append
|
||||||
self.fullWidth = fullWidth
|
self.fullWidth = fullWidth
|
||||||
self.insightsEnabled = insightsEnabled
|
self.insightsEnabled = insightsEnabled
|
||||||
self.documentEmbeds = documentEmbeds
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ import Foundation
|
|||||||
public struct UpdateShareRequest: Encodable, Sendable {
|
public struct UpdateShareRequest: Encodable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let published: Bool
|
public let published: Bool
|
||||||
|
/// Overrides the title displayed on the publicly shared page. `nil`
|
||||||
|
/// leaves it unset in the payload (server keeps whatever it already
|
||||||
|
/// has) rather than clearing it — send an empty string to clear.
|
||||||
|
public let title: String?
|
||||||
|
public let iconUrl: String?
|
||||||
|
|
||||||
public init(id: String, published: Bool) {
|
public init(id: String, published: Bool, title: String? = nil, iconUrl: String? = nil) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.published = published
|
self.published = published
|
||||||
|
self.title = title
|
||||||
|
self.iconUrl = iconUrl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,6 +280,65 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(decodedBody.parentDocumentId, "doc-1")
|
XCTAssertEqual(decodedBody.parentDocumentId, "doc-1")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDocumentsListSendsSortDirectionAndUserId() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{ "data": [] }
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = try await client.documentsList(
|
||||||
|
DocumentsListRequest(userId: "user-1", sort: "updatedAt", direction: "DESC")
|
||||||
|
)
|
||||||
|
|
||||||
|
struct SentBody: Decodable {
|
||||||
|
let userId: String?
|
||||||
|
let sort: String?
|
||||||
|
let direction: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
|
||||||
|
let decodedBody = try JSONDecoder().decode(SentBody.self, from: sentBody)
|
||||||
|
XCTAssertEqual(decodedBody.userId, "user-1")
|
||||||
|
XCTAssertEqual(decodedBody.sort, "updatedAt")
|
||||||
|
XCTAssertEqual(decodedBody.direction, "DESC")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListViewedDocumentsDecodesDocuments() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "doc-1",
|
||||||
|
"title": "Hello",
|
||||||
|
"text": "World",
|
||||||
|
"url": "/doc/hello-doc-1",
|
||||||
|
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-01-02T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let documents = try await client.listViewedDocuments(offset: 0, limit: 25)
|
||||||
|
|
||||||
|
XCTAssertEqual(documents.first?.id, "doc-1")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.viewed")
|
||||||
|
}
|
||||||
|
|
||||||
func testDocumentInfoDecodesEnvelopeAndSetsAuthHeader() async throws {
|
func testDocumentInfoDecodesEnvelopeAndSetsAuthHeader() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.responseData = """
|
httpClient.responseData = """
|
||||||
@@ -610,6 +669,117 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.create")
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.create")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testShareInfoDecodesRealShareArrayShape() async throws {
|
||||||
|
// Real shape confirmed against a live server — `data` is
|
||||||
|
// `{ shares: [...] }`, not the bare share object the official docs
|
||||||
|
// imply (same pattern as `pins.list`). This is the exact payload
|
||||||
|
// captured for an existing, unpublished share.
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"shares": [
|
||||||
|
{
|
||||||
|
"id": "861559ac-906b-4dec-9c1f-3a2d2000745d",
|
||||||
|
"sourceTitle": "Test Document 3",
|
||||||
|
"sourcePath": "/doc/test-document-3-VHxABl5RaD",
|
||||||
|
"collectionId": null,
|
||||||
|
"documentId": "b1196971-4239-4e35-970f-7fbbc60af044",
|
||||||
|
"documentTitle": "Test Document 3",
|
||||||
|
"documentUrl": "/doc/test-document-3-VHxABl5RaD",
|
||||||
|
"published": false,
|
||||||
|
"url": "https://docs.psmattas.com/s/861559ac-906b-4dec-9c1f-3a2d2000745d",
|
||||||
|
"urlId": null,
|
||||||
|
"createdBy": {
|
||||||
|
"id": "1e2ef39c-aa82-475b-b5af-d76bb4f023ed",
|
||||||
|
"name": "Puranjay Savar Mattas",
|
||||||
|
"avatarUrl": "/api/files.get?key=public/avatar.png",
|
||||||
|
"color": "#1c9152",
|
||||||
|
"role": "admin",
|
||||||
|
"isSuspended": false,
|
||||||
|
"createdAt": "2025-07-30T17:36:58.560Z",
|
||||||
|
"updatedAt": "2026-08-14T16:47:45.037Z",
|
||||||
|
"deletedAt": null,
|
||||||
|
"lastActiveAt": "2026-08-14T16:47:45.037Z",
|
||||||
|
"timezone": "Europe/Dublin"
|
||||||
|
},
|
||||||
|
"includeChildDocuments": false,
|
||||||
|
"allowIndexing": false,
|
||||||
|
"allowSubscriptions": true,
|
||||||
|
"showLastUpdated": false,
|
||||||
|
"showTOC": false,
|
||||||
|
"title": null,
|
||||||
|
"iconUrl": null,
|
||||||
|
"views": 0,
|
||||||
|
"domain": null,
|
||||||
|
"createdAt": "2026-08-14T16:49:40.146Z",
|
||||||
|
"updatedAt": "2026-08-14T16:49:40.146Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"policies": [
|
||||||
|
{ "id": "861559ac-906b-4dec-9c1f-3a2d2000745d", "abilities": { "read": true, "update": false, "revoke": true } }
|
||||||
|
],
|
||||||
|
"status": 200,
|
||||||
|
"ok": true
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let share = try await client.shareInfo(documentId: "doc-1")
|
||||||
|
|
||||||
|
XCTAssertEqual(share?.id, "861559ac-906b-4dec-9c1f-3a2d2000745d")
|
||||||
|
XCTAssertEqual(share?.published, false)
|
||||||
|
XCTAssertEqual(share?.views, 0)
|
||||||
|
XCTAssertEqual(share?.createdBy?.name, "Puranjay Savar Mattas")
|
||||||
|
XCTAssertEqual(share?.documentId, "b1196971-4239-4e35-970f-7fbbc60af044")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListSharesDecodesSharesArray() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{ "id": "share-1", "documentId": "doc-1", "collectionId": null, "url": "https://outline.example.com/s/share-1", "published": true }
|
||||||
|
],
|
||||||
|
"pagination": { "offset": 0, "limit": 25 }
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let shares = try await client.listShares(ListSharesRequest())
|
||||||
|
|
||||||
|
XCTAssertEqual(shares.first?.id, "share-1")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRevokeShareSendsRequest() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{ "success": true }
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
try await client.revokeShare(id: "share-1")
|
||||||
|
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.revoke")
|
||||||
|
}
|
||||||
|
|
||||||
func testShareInfoReturnsNilOnNotFound() async throws {
|
func testShareInfoReturnsNilOnNotFound() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.statusCode = 404
|
httpClient.statusCode = 404
|
||||||
@@ -628,6 +798,24 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertNil(share)
|
XCTAssertNil(share)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testShareInfoReturnsNilOnEmptyBody() async throws {
|
||||||
|
// Confirmed against a live server: shares.info returns a 200 with a
|
||||||
|
// completely empty body (not a 404) when no share exists yet for a
|
||||||
|
// given document.
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = Data()
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let share = try await client.shareInfo(documentId: "doc-1")
|
||||||
|
|
||||||
|
XCTAssertNil(share)
|
||||||
|
}
|
||||||
|
|
||||||
func testListViewsDecodesViewsAndFiltersNilLastViewedAt() async throws {
|
func testListViewsDecodesViewsAndFiltersNilLastViewedAt() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.responseData = """
|
httpClient.responseData = """
|
||||||
@@ -676,6 +864,34 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create")
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testListPinsDecodesNestedPinsArray() async throws {
|
||||||
|
// Real shape confirmed against a live server: `data` is
|
||||||
|
// `{ pins: [...], documents: [...] }`, not a bare array.
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"pagination": { "limit": 25, "offset": 0 },
|
||||||
|
"data": {
|
||||||
|
"pins": [
|
||||||
|
{ "id": "pin-1", "documentId": "doc-1", "collectionId": null, "index": "h" }
|
||||||
|
],
|
||||||
|
"documents": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let pins = try await client.listPins(ListPinsRequest(collectionId: nil))
|
||||||
|
|
||||||
|
XCTAssertEqual(pins.first?.id, "pin-1")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.list")
|
||||||
|
}
|
||||||
|
|
||||||
func testCreateSubscriptionDecodesSubscription() async throws {
|
func testCreateSubscriptionDecodesSubscription() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.responseData = """
|
httpClient.responseData = """
|
||||||
@@ -694,6 +910,89 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/subscriptions.create")
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/subscriptions.create")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAddDocumentUserDecodesMembership() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{ "data": { "id": "mem-1", "userId": "user-1", "documentId": "doc-1", "permission": "read" } }
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let membership = try await client.addDocumentUser(
|
||||||
|
AddDocumentUserRequest(id: "doc-1", userId: "user-1", permission: "read")
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(membership.id, "mem-1")
|
||||||
|
XCTAssertEqual(membership.permission, "read")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.add_user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemoveDocumentUserSendsRequest() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{ "success": true }
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
try await client.removeDocumentUser(RemoveDocumentUserRequest(id: "doc-1", userId: "user-1"))
|
||||||
|
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.remove_user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDocumentUsersDecodesMembersArray() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{ "id": "user-1", "name": "Jane Doe", "email": "jane@example.com", "avatarUrl": null, "permission": "read_write" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let members = try await client.documentUsers(ListDocumentUsersRequest(id: "doc-1"))
|
||||||
|
|
||||||
|
XCTAssertEqual(members.first?.name, "Jane Doe")
|
||||||
|
XCTAssertEqual(members.first?.permission, "read_write")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.users")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListUsersDecodesUsersArray() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{ "id": "user-1", "name": "Jane Doe", "email": "jane@example.com", "role": "member" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let users = try await client.listUsers(ListUsersRequest(query: "Jane"))
|
||||||
|
|
||||||
|
XCTAssertEqual(users.first?.name, "Jane Doe")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.list")
|
||||||
|
}
|
||||||
|
|
||||||
func testMissingTokenThrowsTokenUnavailable() async throws {
|
func testMissingTokenThrowsTokenUnavailable() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
let client = LiveOutlineAPIClient(
|
let client = LiveOutlineAPIClient(
|
||||||
|
|||||||
@@ -408,7 +408,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
MARKETING_VERSION = 0.0.1;
|
MARKETING_VERSION = 0.0.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -453,7 +453,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
MARKETING_VERSION = 0.0.1;
|
MARKETING_VERSION = 0.0.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -9,14 +9,27 @@ import OutlineKit
|
|||||||
/// client-side rather than `collections.documents`.
|
/// client-side rather than `collections.documents`.
|
||||||
struct CollectionDocumentsOutline: View {
|
struct CollectionDocumentsOutline: View {
|
||||||
let apiClient: OutlineAPIClient
|
let apiClient: OutlineAPIClient
|
||||||
|
let collection: OutlineCollection
|
||||||
@State private var viewModel: DocumentsViewModel
|
@State private var viewModel: DocumentsViewModel
|
||||||
let sortOption: SidebarSortOption
|
let sortOption: SidebarSortOption
|
||||||
let refreshToken: Int
|
let refreshToken: Int
|
||||||
|
/// Bumped from `ContentView_macOS` whenever a document is created from
|
||||||
|
/// somewhere that has no direct handle on this row — the reader's
|
||||||
|
/// toolbar "New Document" button and Home's, specifically. Those can't
|
||||||
|
/// call `onDocumentsChanged()` the way a same-row sheet does, since they
|
||||||
|
/// don't know which (if any) sidebar row corresponds to where the new
|
||||||
|
/// document landed, so every expanded row just reloads itself.
|
||||||
|
let externalRefreshToken: Int
|
||||||
let selectedDocumentID: String?
|
let selectedDocumentID: String?
|
||||||
/// Full chain from root to the clicked document (inclusive) — lets the
|
/// Full chain from root to the clicked document (inclusive) — lets the
|
||||||
/// toolbar render the real hierarchy instead of just the leaf title.
|
/// toolbar render the real hierarchy instead of just the leaf title.
|
||||||
let onSelectDocument: ([OutlineDocument]) -> Void
|
let onSelectDocument: ([OutlineDocument]) -> Void
|
||||||
|
|
||||||
|
/// 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] {
|
private var tree: [DocumentNode] {
|
||||||
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
|
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
|
||||||
}
|
}
|
||||||
@@ -26,13 +39,16 @@ struct CollectionDocumentsOutline: View {
|
|||||||
collection: OutlineCollection,
|
collection: OutlineCollection,
|
||||||
sortOption: SidebarSortOption,
|
sortOption: SidebarSortOption,
|
||||||
refreshToken: Int,
|
refreshToken: Int,
|
||||||
|
externalRefreshToken: Int,
|
||||||
selectedDocumentID: String?,
|
selectedDocumentID: String?,
|
||||||
onSelectDocument: @escaping ([OutlineDocument]) -> Void
|
onSelectDocument: @escaping ([OutlineDocument]) -> Void
|
||||||
) {
|
) {
|
||||||
self.apiClient = apiClient
|
self.apiClient = apiClient
|
||||||
|
self.collection = collection
|
||||||
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
||||||
self.sortOption = sortOption
|
self.sortOption = sortOption
|
||||||
self.refreshToken = refreshToken
|
self.refreshToken = refreshToken
|
||||||
|
self.externalRefreshToken = externalRefreshToken
|
||||||
self.selectedDocumentID = selectedDocumentID
|
self.selectedDocumentID = selectedDocumentID
|
||||||
self.onSelectDocument = onSelectDocument
|
self.onSelectDocument = onSelectDocument
|
||||||
}
|
}
|
||||||
@@ -56,13 +72,26 @@ struct CollectionDocumentsOutline: View {
|
|||||||
depth: 0,
|
depth: 0,
|
||||||
ancestors: [],
|
ancestors: [],
|
||||||
selectedDocumentID: selectedDocumentID,
|
selectedDocumentID: selectedDocumentID,
|
||||||
|
pinsByDocumentID: pinsByDocumentID,
|
||||||
onSelectDocument: onSelectDocument,
|
onSelectDocument: onSelectDocument,
|
||||||
onDocumentsChanged: { await viewModel.load() }
|
onDocumentsChanged: { await viewModel.load() },
|
||||||
|
onPinsChanged: { await loadPins() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task(id: refreshToken) { await viewModel.load() }
|
// Combined into one identity rather than two separate `.task(id:)`
|
||||||
|
// modifiers — each of those fires once unconditionally on first
|
||||||
|
// appear, so two of them would double the initial load.
|
||||||
|
.task(id: "\(refreshToken)-\(externalRefreshToken)") {
|
||||||
|
await viewModel.load()
|
||||||
|
await loadPins()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
/// Chain from root down to (not including) this node.
|
||||||
let ancestors: [OutlineDocument]
|
let ancestors: [OutlineDocument]
|
||||||
let selectedDocumentID: String?
|
let selectedDocumentID: String?
|
||||||
|
let pinsByDocumentID: [String: OutlinePin]
|
||||||
let onSelectDocument: ([OutlineDocument]) -> Void
|
let onSelectDocument: ([OutlineDocument]) -> Void
|
||||||
let onDocumentsChanged: () async -> Void
|
let onDocumentsChanged: () async -> Void
|
||||||
|
let onPinsChanged: () async -> Void
|
||||||
|
|
||||||
@State private var isExpanded = false
|
@State private var isExpanded = false
|
||||||
|
|
||||||
@@ -96,6 +127,7 @@ private struct DocumentNodeRow: View {
|
|||||||
@State private var isShowingInsightsSheet = false
|
@State private var isShowingInsightsSheet = false
|
||||||
@State private var isShowingPresentSheet = false
|
@State private var isShowingPresentSheet = false
|
||||||
@State private var isShowingSearchSheet = false
|
@State private var isShowingSearchSheet = false
|
||||||
|
@State private var isShowingNewDocumentSheet = false
|
||||||
@State private var actionErrorMessage: String?
|
@State private var actionErrorMessage: String?
|
||||||
|
|
||||||
private var isSelected: Bool {
|
private var isSelected: Bool {
|
||||||
@@ -172,8 +204,10 @@ private struct DocumentNodeRow: View {
|
|||||||
depth: depth + 1,
|
depth: depth + 1,
|
||||||
ancestors: ancestors + [node.document],
|
ancestors: ancestors + [node.document],
|
||||||
selectedDocumentID: selectedDocumentID,
|
selectedDocumentID: selectedDocumentID,
|
||||||
|
pinsByDocumentID: pinsByDocumentID,
|
||||||
onSelectDocument: onSelectDocument,
|
onSelectDocument: onSelectDocument,
|
||||||
onDocumentsChanged: onDocumentsChanged
|
onDocumentsChanged: onDocumentsChanged,
|
||||||
|
onPinsChanged: onPinsChanged
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,6 +286,11 @@ private struct DocumentNodeRow: View {
|
|||||||
.sheet(isPresented: $isShowingSearchSheet) {
|
.sheet(isPresented: $isShowingSearchSheet) {
|
||||||
DocumentSearchSheet(apiClient: apiClient, document: node.document)
|
DocumentSearchSheet(apiClient: apiClient, document: node.document)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $isShowingNewDocumentSheet) {
|
||||||
|
NewDocumentSheet(apiClient: apiClient, initialParentDocument: node.document) { _ in
|
||||||
|
Task { await onDocumentsChanged() }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -259,7 +298,11 @@ private struct DocumentNodeRow: View {
|
|||||||
Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "Star") {
|
Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "Star") {
|
||||||
Task { await star() }
|
Task { await star() }
|
||||||
}
|
}
|
||||||
// No `subscriptions.*` endpoint in the API — nothing to back this with.
|
// subscriptions.* does exist and works (confirmed against a live
|
||||||
|
// server via the reader's menu) — not shown here because
|
||||||
|
// subscriptions.list is per-document, so reflecting accurate
|
||||||
|
// per-row state for every document in the tree would mean an N+1
|
||||||
|
// call storm. Use the reader's ⋯ menu instead.
|
||||||
Button("Unsubscribe") {}
|
Button("Unsubscribe") {}
|
||||||
.disabled(true)
|
.disabled(true)
|
||||||
|
|
||||||
@@ -273,8 +316,9 @@ private struct DocumentNodeRow: View {
|
|||||||
renameText = node.document.title
|
renameText = node.document.title
|
||||||
isShowingRenameAlert = true
|
isShowingRenameAlert = true
|
||||||
}
|
}
|
||||||
// Sharing/membership management is its own subsystem, not a one-off
|
// DocumentShareSheet now has a real "People with access" section —
|
||||||
// action — deferred rather than half-built here.
|
// this row just doesn't have a sheet wired up to present it yet
|
||||||
|
// (only the reader toolbar does). Use the reader's ⋯ menu instead.
|
||||||
Button("Permissions…") {}
|
Button("Permissions…") {}
|
||||||
.disabled(true)
|
.disabled(true)
|
||||||
|
|
||||||
@@ -303,11 +347,13 @@ private struct DocumentNodeRow: View {
|
|||||||
Button("Import Document…") {}
|
Button("Import Document…") {}
|
||||||
.disabled(true)
|
.disabled(true)
|
||||||
Button("New Document") {
|
Button("New Document") {
|
||||||
Task { await createChildDocument() }
|
isShowingNewDocumentSheet = true
|
||||||
|
}
|
||||||
|
// Scoped to this collection (`collection.id`) — "Pin to Collection",
|
||||||
|
// distinct from the reader toolbar's "Pin to Home" (collectionId: nil).
|
||||||
|
Button(pinsByDocumentID[node.document.id] != nil ? "Unpin from Collection" : "Pin to Collection") {
|
||||||
|
Task { await togglePin() }
|
||||||
}
|
}
|
||||||
// No `pins.*` endpoint in the API — nothing to back this with.
|
|
||||||
Button("Pin") {}
|
|
||||||
.disabled(true)
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
@@ -351,6 +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 {
|
private func rename() async {
|
||||||
do {
|
do {
|
||||||
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: node.document.id, title: renameText))
|
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: node.document.id, title: renameText))
|
||||||
@@ -395,26 +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 {
|
private func delete() async {
|
||||||
do {
|
do {
|
||||||
try await apiClient.deleteDocument(DeleteDocumentRequest(id: node.document.id))
|
try await apiClient.deleteDocument(DeleteDocumentRequest(id: node.document.id))
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ struct CollectionTreeRow: View {
|
|||||||
let isExpanded: Bool
|
let isExpanded: Bool
|
||||||
let isSelected: Bool
|
let isSelected: Bool
|
||||||
let selectedDocumentID: String?
|
let selectedDocumentID: String?
|
||||||
|
/// See the identical parameter on `CollectionDocumentsOutline`.
|
||||||
|
let externalRefreshToken: Int
|
||||||
let onToggle: () -> Void
|
let onToggle: () -> Void
|
||||||
let onSelectDocument: ([OutlineDocument]) -> Void
|
let onSelectDocument: ([OutlineDocument]) -> Void
|
||||||
let onSearchInCollection: (OutlineCollection) -> Void
|
let onSearchInCollection: (OutlineCollection) -> Void
|
||||||
@@ -22,6 +24,7 @@ struct CollectionTreeRow: View {
|
|||||||
@State private var isShowingRenameAlert = false
|
@State private var isShowingRenameAlert = false
|
||||||
@State private var renameText = ""
|
@State private var renameText = ""
|
||||||
@State private var isShowingDeleteConfirmation = false
|
@State private var isShowingDeleteConfirmation = false
|
||||||
|
@State private var isShowingNewDocumentSheet = false
|
||||||
@State private var actionErrorMessage: String?
|
@State private var actionErrorMessage: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -62,6 +65,7 @@ struct CollectionTreeRow: View {
|
|||||||
collection: collection,
|
collection: collection,
|
||||||
sortOption: sortOption,
|
sortOption: sortOption,
|
||||||
refreshToken: documentsRefreshToken,
|
refreshToken: documentsRefreshToken,
|
||||||
|
externalRefreshToken: externalRefreshToken,
|
||||||
selectedDocumentID: selectedDocumentID,
|
selectedDocumentID: selectedDocumentID,
|
||||||
onSelectDocument: onSelectDocument
|
onSelectDocument: onSelectDocument
|
||||||
)
|
)
|
||||||
@@ -92,6 +96,12 @@ struct CollectionTreeRow: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(actionErrorMessage ?? "")
|
Text(actionErrorMessage ?? "")
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $isShowingNewDocumentSheet) {
|
||||||
|
NewDocumentSheet(apiClient: apiClient, initialCollectionID: collection.id) { _ in
|
||||||
|
documentsRefreshToken += 1
|
||||||
|
Task { await onCollectionsChanged() }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -103,7 +113,7 @@ struct CollectionTreeRow: View {
|
|||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button("New Document") {
|
Button("New Document") {
|
||||||
Task { await createDocument() }
|
isShowingNewDocumentSheet = true
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
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 {
|
private func rename() async {
|
||||||
do {
|
do {
|
||||||
_ = try await apiClient.updateCollection(UpdateCollectionRequest(id: collection.id, name: renameText))
|
_ = try await apiClient.updateCollection(UpdateCollectionRequest(id: collection.id, name: renameText))
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ struct CollectionsTreeView: View {
|
|||||||
@Binding private var selectedCollection: OutlineCollection?
|
@Binding private var selectedCollection: OutlineCollection?
|
||||||
let selectedDocumentID: String?
|
let selectedDocumentID: String?
|
||||||
@State private var expandedCollectionIDs: Set<String> = []
|
@State private var expandedCollectionIDs: Set<String> = []
|
||||||
|
/// Bumped from `ContentView_macOS` whenever a document is created
|
||||||
|
/// somewhere with no direct handle on the sidebar row it belongs
|
||||||
|
/// under — see the identical parameter on `CollectionDocumentsOutline`.
|
||||||
|
let externalRefreshToken: Int
|
||||||
|
let isShowingHome: Bool
|
||||||
|
let onSelectHome: () -> Void
|
||||||
let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void
|
let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void
|
||||||
let onSearchInCollection: (OutlineCollection) -> Void
|
let onSearchInCollection: (OutlineCollection) -> Void
|
||||||
|
|
||||||
@@ -14,12 +20,18 @@ struct CollectionsTreeView: View {
|
|||||||
apiClient: OutlineAPIClient,
|
apiClient: OutlineAPIClient,
|
||||||
selectedCollection: Binding<OutlineCollection?>,
|
selectedCollection: Binding<OutlineCollection?>,
|
||||||
selectedDocumentID: String?,
|
selectedDocumentID: String?,
|
||||||
|
externalRefreshToken: Int,
|
||||||
|
isShowingHome: Bool,
|
||||||
|
onSelectHome: @escaping () -> Void,
|
||||||
onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void,
|
onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void,
|
||||||
onSearchInCollection: @escaping (OutlineCollection) -> Void
|
onSearchInCollection: @escaping (OutlineCollection) -> Void
|
||||||
) {
|
) {
|
||||||
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
|
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
|
||||||
_selectedCollection = selectedCollection
|
_selectedCollection = selectedCollection
|
||||||
self.selectedDocumentID = selectedDocumentID
|
self.selectedDocumentID = selectedDocumentID
|
||||||
|
self.externalRefreshToken = externalRefreshToken
|
||||||
|
self.isShowingHome = isShowingHome
|
||||||
|
self.onSelectHome = onSelectHome
|
||||||
self.onSelectDocument = onSelectDocument
|
self.onSelectDocument = onSelectDocument
|
||||||
self.onSearchInCollection = onSearchInCollection
|
self.onSearchInCollection = onSearchInCollection
|
||||||
}
|
}
|
||||||
@@ -31,6 +43,7 @@ struct CollectionsTreeView: View {
|
|||||||
Task { await viewModel.load() }
|
Task { await viewModel.load() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
homeRow
|
||||||
content
|
content
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
@@ -42,6 +55,32 @@ struct CollectionsTreeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pinned above the collections list, not inside the scroll region —
|
||||||
|
/// Home isn't a collection, so it doesn't belong in `viewModel.collections`
|
||||||
|
/// or compete with them for scroll space.
|
||||||
|
private var homeRow: some View {
|
||||||
|
Button(action: onSelectHome) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "house.fill")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Home")
|
||||||
|
.font(.body)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.background(
|
||||||
|
isShowingHome ? Color.accentColor.opacity(0.15) : Color.clear,
|
||||||
|
in: RoundedRectangle(cornerRadius: 6)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var content: some View {
|
private var content: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -81,6 +120,7 @@ struct CollectionsTreeView: View {
|
|||||||
isExpanded: expandedCollectionIDs.contains(collection.id),
|
isExpanded: expandedCollectionIDs.contains(collection.id),
|
||||||
isSelected: selectedCollection?.id == collection.id,
|
isSelected: selectedCollection?.id == collection.id,
|
||||||
selectedDocumentID: selectedDocumentID,
|
selectedDocumentID: selectedDocumentID,
|
||||||
|
externalRefreshToken: externalRefreshToken,
|
||||||
onToggle: { toggle(collection) },
|
onToggle: { toggle(collection) },
|
||||||
onSelectDocument: { chain in onSelectDocument(collection, chain) },
|
onSelectDocument: { chain in onSelectDocument(collection, chain) },
|
||||||
onSearchInCollection: onSearchInCollection,
|
onSearchInCollection: onSearchInCollection,
|
||||||
@@ -95,13 +135,6 @@ struct CollectionsTreeView: View {
|
|||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.load()
|
await viewModel.load()
|
||||||
// Stand-in for Outline's own configured "Start view" — we don't have
|
|
||||||
// a confirmed schema for `team.preferences` to read the actual
|
|
||||||
// setting, so this defaults to the first collection instead of
|
|
||||||
// landing on an empty "No Collection Selected" placeholder.
|
|
||||||
if selectedCollection == nil, let first = viewModel.collections.first {
|
|
||||||
selectedCollection = first
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// A document opened from outside the sidebar (detail pane's list,
|
// A document opened from outside the sidebar (detail pane's list,
|
||||||
// global search) wouldn't otherwise expand its collection here, so
|
// global search) wouldn't otherwise expand its collection here, so
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import OutlineKit
|
|||||||
|
|
||||||
struct ContentView_macOS: View {
|
struct ContentView_macOS: View {
|
||||||
@Environment(SessionStore.self) private var session
|
@Environment(SessionStore.self) private var session
|
||||||
|
/// The landing state — no collection selected yet is what Home actually
|
||||||
|
/// means, so this starts `true` rather than auto-selecting the first
|
||||||
|
/// collection the way this used to work.
|
||||||
|
@State private var isShowingHome = true
|
||||||
@State private var selectedCollection: OutlineCollection?
|
@State private var selectedCollection: OutlineCollection?
|
||||||
/// The real navigation stack, root to leaf — also the source of truth for
|
/// The real navigation stack, root to leaf — also the source of truth for
|
||||||
/// the toolbar breadcrumb, so the two can't drift out of sync.
|
/// the toolbar breadcrumb, so the two can't drift out of sync.
|
||||||
@@ -12,6 +16,11 @@ struct ContentView_macOS: View {
|
|||||||
@State private var contextualSearchQuery = ""
|
@State private var contextualSearchQuery = ""
|
||||||
@State private var isContextualSearchExpanded = false
|
@State private var isContextualSearchExpanded = false
|
||||||
@FocusState private var isContextualSearchFocused: Bool
|
@FocusState private var isContextualSearchFocused: Bool
|
||||||
|
/// Bumped whenever a document is created from somewhere with no direct
|
||||||
|
/// handle on the sidebar row it belongs under (the reader toolbar's and
|
||||||
|
/// Home's "New Document" buttons) — every expanded sidebar row reloads
|
||||||
|
/// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`.
|
||||||
|
@State private var documentsChangedToken = 0
|
||||||
|
|
||||||
private var trimmedGlobalQuery: String {
|
private var trimmedGlobalQuery: String {
|
||||||
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -43,17 +52,47 @@ struct ContentView_macOS: View {
|
|||||||
// mutually exclusive: whenever a document's pushed (back button
|
// mutually exclusive: whenever a document's pushed (back button
|
||||||
// visible), this shows the document hierarchy instead of falling
|
// visible), this shows the document hierarchy instead of falling
|
||||||
// back to the workspace badge.
|
// back to the workspace badge.
|
||||||
|
ToolbarItem(placement: .navigation) {
|
||||||
|
Button {
|
||||||
|
goHome()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "house")
|
||||||
|
}
|
||||||
|
.help("Home")
|
||||||
|
}
|
||||||
ToolbarItem(placement: .navigation) {
|
ToolbarItem(placement: .navigation) {
|
||||||
leadingToolbarContent
|
leadingToolbarContent
|
||||||
}
|
}
|
||||||
// Custom instead of `.searchable`: that modifier always renders a
|
// Custom instead of `.searchable`: that modifier always renders a
|
||||||
// full-width field, but this is meant to sit alongside the other
|
// full-width field, but this is meant to sit alongside the other
|
||||||
// per-document toolbar buttons as a plain icon that only expands
|
// per-document toolbar buttons as a plain icon that only expands
|
||||||
// into a field once clicked.
|
// into a field once clicked. Hidden on the Home landing page
|
||||||
ToolbarItem(placement: .primaryAction) {
|
// itself — `contextualSearchQuery` is only ever read by
|
||||||
contextualSearchField
|
// `CollectionOverviewView`, so on Home it was a dead end: a
|
||||||
|
// user could click it, type, and nothing would happen. The
|
||||||
|
// sidebar's global search already covers "search everything."
|
||||||
|
if !(isShowingHome && documentPath.isEmpty) {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
contextualSearchField
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Any explicit collection pick — sidebar click, "Search in
|
||||||
|
// Collection" — means the user has navigated away from Home.
|
||||||
|
.onChange(of: selectedCollection) { _, newValue in
|
||||||
|
if newValue != nil {
|
||||||
|
isShowingHome = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func goHome() {
|
||||||
|
globalSearchQuery = ""
|
||||||
|
contextualSearchQuery = ""
|
||||||
|
isContextualSearchExpanded = false
|
||||||
|
selectedCollection = nil
|
||||||
|
isShowingHome = true
|
||||||
|
replaceDocumentPath(with: [])
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -106,12 +145,19 @@ struct ContentView_macOS: View {
|
|||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
Text("Search")
|
Text("Search")
|
||||||
}
|
}
|
||||||
} else if !documentPath.isEmpty, let selectedCollection {
|
} else if !documentPath.isEmpty {
|
||||||
// Collection → every ancestor (icon only) → current document
|
// Origin (collection, or Home if opened from there) → every
|
||||||
// (icon + full title) — ancestors stay icon-only so a deep
|
// ancestor (icon only) → current document (icon + full
|
||||||
// chain doesn't blow out the toolbar width.
|
// title) — ancestors stay icon-only so a deep chain doesn't
|
||||||
|
// blow out the toolbar width. Checked before `isShowingHome`
|
||||||
|
// since opening a document from Home still leaves that flag
|
||||||
|
// set — the pushed document should win either way.
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
CollectionRowView(collection: selectedCollection)
|
if let selectedCollection {
|
||||||
|
CollectionRowView(collection: selectedCollection)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "house.fill")
|
||||||
|
}
|
||||||
|
|
||||||
ForEach(Array(documentPath.enumerated()), id: \.element.id) { index, document in
|
ForEach(Array(documentPath.enumerated()), id: \.element.id) { index, document in
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
@@ -130,6 +176,11 @@ struct ContentView_macOS: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if isShowingHome {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "house.fill")
|
||||||
|
Text("Home")
|
||||||
|
}
|
||||||
} else if let selectedCollection {
|
} else if let selectedCollection {
|
||||||
CollectionRowView(collection: selectedCollection)
|
CollectionRowView(collection: selectedCollection)
|
||||||
} else {
|
} else {
|
||||||
@@ -153,6 +204,9 @@ struct ContentView_macOS: View {
|
|||||||
apiClient: apiClient,
|
apiClient: apiClient,
|
||||||
selectedCollection: $selectedCollection,
|
selectedCollection: $selectedCollection,
|
||||||
selectedDocumentID: documentPath.last?.id,
|
selectedDocumentID: documentPath.last?.id,
|
||||||
|
externalRefreshToken: documentsChangedToken,
|
||||||
|
isShowingHome: isShowingHome,
|
||||||
|
onSelectHome: goHome,
|
||||||
onSelectDocument: selectDocumentChain,
|
onSelectDocument: selectDocumentChain,
|
||||||
onSearchInCollection: searchInCollection
|
onSearchInCollection: searchInCollection
|
||||||
)
|
)
|
||||||
@@ -205,6 +259,8 @@ struct ContentView_macOS: View {
|
|||||||
Group {
|
Group {
|
||||||
if !trimmedGlobalQuery.isEmpty {
|
if !trimmedGlobalQuery.isEmpty {
|
||||||
GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery, onOpenDocument: openDocument)
|
GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery, onOpenDocument: openDocument)
|
||||||
|
} else if isShowingHome {
|
||||||
|
HomeView(apiClient: apiClient, onOpenDocument: openDocument, onDocumentCreated: { documentsChangedToken += 1 })
|
||||||
} else if let selectedCollection {
|
} else if let selectedCollection {
|
||||||
CollectionOverviewView(
|
CollectionOverviewView(
|
||||||
apiClient: apiClient,
|
apiClient: apiClient,
|
||||||
@@ -229,11 +285,12 @@ struct ContentView_macOS: View {
|
|||||||
if !documentPath.isEmpty {
|
if !documentPath.isEmpty {
|
||||||
documentPath.removeLast()
|
documentPath.removeLast()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
onDocumentCreated: { documentsChangedToken += 1 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.id(trimmedGlobalQuery.isEmpty ? (selectedCollection?.id ?? "none") : "search")
|
.id(trimmedGlobalQuery.isEmpty ? (isShowingHome ? "home" : (selectedCollection?.id ?? "none")) : "search")
|
||||||
} else {
|
} else {
|
||||||
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
|
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ struct DocumentReaderView: View {
|
|||||||
/// longer visible in the collection it was opened from, so the reader
|
/// longer visible in the collection it was opened from, so the reader
|
||||||
/// pops itself off the navigation stack.
|
/// pops itself off the navigation stack.
|
||||||
let onDeleted: () -> Void
|
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 isShowingUnpublishConfirmation = false
|
||||||
@State private var isShowingArchiveConfirmation = false
|
@State private var isShowingArchiveConfirmation = false
|
||||||
@@ -33,19 +38,22 @@ struct DocumentReaderView: View {
|
|||||||
@State private var isShowingPresentSheet = false
|
@State private var isShowingPresentSheet = false
|
||||||
@State private var isShowingSearchSheet = false
|
@State private var isShowingSearchSheet = false
|
||||||
@State private var isShowingShareSheet = false
|
@State private var isShowingShareSheet = false
|
||||||
|
@State private var isShowingNewDocumentSheet = false
|
||||||
@State private var actionErrorMessage: String?
|
@State private var actionErrorMessage: String?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
apiClient: OutlineAPIClient,
|
apiClient: OutlineAPIClient,
|
||||||
document: OutlineDocument,
|
document: OutlineDocument,
|
||||||
onOpenChild: @escaping (OutlineDocument) -> Void,
|
onOpenChild: @escaping (OutlineDocument) -> Void,
|
||||||
onDeleted: @escaping () -> Void
|
onDeleted: @escaping () -> Void,
|
||||||
|
onDocumentCreated: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
self.apiClient = apiClient
|
self.apiClient = apiClient
|
||||||
self.document = document
|
self.document = document
|
||||||
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
|
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
|
||||||
self.onOpenChild = onOpenChild
|
self.onOpenChild = onOpenChild
|
||||||
self.onDeleted = onDeleted
|
self.onDeleted = onDeleted
|
||||||
|
self.onDocumentCreated = onDocumentCreated
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -103,6 +111,9 @@ struct DocumentReaderView: View {
|
|||||||
Image(systemName: "square.and.arrow.up")
|
Image(systemName: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
.help("Share")
|
.help("Share")
|
||||||
|
.popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) {
|
||||||
|
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
||||||
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
Task { await viewModel.toggleEditing() }
|
Task { await viewModel.toggleEditing() }
|
||||||
@@ -116,7 +127,7 @@ struct DocumentReaderView: View {
|
|||||||
.disabled(viewModel.isSaving)
|
.disabled(viewModel.isSaving)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
Task { await createChildDocument() }
|
isShowingNewDocumentSheet = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "doc.badge.plus")
|
Image(systemName: "doc.badge.plus")
|
||||||
}
|
}
|
||||||
@@ -127,12 +138,22 @@ struct DocumentReaderView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "ellipsis.circle")
|
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.loadFullContent() }
|
||||||
.task {
|
.task {
|
||||||
await viewModel.loadPinAndSubscriptionState()
|
await viewModel.loadPinAndSubscriptionState()
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadInsightsEnabledState()
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
await viewModel.loadViewers()
|
await viewModel.loadViewers()
|
||||||
@@ -197,14 +218,33 @@ struct DocumentReaderView: View {
|
|||||||
.sheet(isPresented: $isShowingSearchSheet) {
|
.sheet(isPresented: $isShowingSearchSheet) {
|
||||||
DocumentSearchSheet(apiClient: apiClient, document: document)
|
DocumentSearchSheet(apiClient: apiClient, document: document)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $isShowingShareSheet) {
|
.sheet(isPresented: $isShowingNewDocumentSheet) {
|
||||||
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
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
|
||||||
|
].map(String.init).joined(separator: "-")
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var viewerAvatars: some View {
|
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) {
|
HStack(spacing: -6) {
|
||||||
ForEach(viewModel.viewers.prefix(5)) { viewer in
|
ForEach(viewModel.viewers.prefix(5)) { viewer in
|
||||||
AvatarBadge(
|
AvatarBadge(
|
||||||
@@ -249,19 +289,22 @@ struct DocumentReaderView: View {
|
|||||||
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
|
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
|
||||||
Task { await star() }
|
Task { await star() }
|
||||||
}
|
}
|
||||||
Button(viewModel.isSubscribed ? "Unsubscribe" : "Subscribe") {
|
Toggle("Subscribed", isOn: Binding(
|
||||||
Task { await toggleSubscription() }
|
get: { viewModel.isSubscribed },
|
||||||
}
|
set: { _ in Task { await toggleSubscription() } }
|
||||||
|
))
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
|
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
|
||||||
Task { await viewModel.toggleEditing() }
|
Task { await viewModel.toggleEditing() }
|
||||||
}
|
}
|
||||||
// Sharing/membership management is its own subsystem, not a one-off
|
// Membership management now lives in DocumentShareSheet's "People
|
||||||
// action — deferred rather than half-built here.
|
// with access" section, alongside the share link — same sheet,
|
||||||
Button("Permissions…") {}
|
// same isShowingShareSheet state.
|
||||||
.disabled(true)
|
Button("Permissions…") {
|
||||||
|
isShowingShareSheet = true
|
||||||
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
@@ -288,9 +331,9 @@ struct DocumentReaderView: View {
|
|||||||
Button("Import Document…") {}
|
Button("Import Document…") {}
|
||||||
.disabled(true)
|
.disabled(true)
|
||||||
Button("New Document") {
|
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() }
|
Task { await togglePin() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,15 +366,20 @@ struct DocumentReaderView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button("Enable Viewer Insights") {
|
Toggle("Viewer Insights", isOn: Binding(
|
||||||
Task { await enableInsights() }
|
get: { viewModel.isInsightsEnabled ?? false },
|
||||||
}
|
set: { _ in Task { await toggleInsights() } }
|
||||||
Button("Enable Embeds") {
|
))
|
||||||
Task { await enableEmbeds() }
|
// Confirmed against a live server: there's no per-document embeds
|
||||||
}
|
// field. Only a workspace-level setting exists, and that's not
|
||||||
Button(viewModel.isFullWidth ? "Default Width" : "Full Width") {
|
// reachable via the API either (no `team.update` endpoint in the
|
||||||
Task { await toggleFullWidth() }
|
// 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()
|
Divider()
|
||||||
|
|
||||||
@@ -372,19 +420,11 @@ struct DocumentReaderView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func enableInsights() async {
|
private func toggleInsights() async {
|
||||||
do {
|
do {
|
||||||
try await viewModel.enableViewerInsights()
|
try await viewModel.toggleViewerInsights()
|
||||||
} catch {
|
} catch {
|
||||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable viewer insights.")
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update viewer insights.")
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func enableEmbeds() async {
|
|
||||||
do {
|
|
||||||
try await viewModel.enableEmbeds()
|
|
||||||
} catch {
|
|
||||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable embeds.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,21 +471,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 {
|
private func download() async {
|
||||||
do {
|
do {
|
||||||
let markdown = try await apiClient.exportDocument(id: viewModel.documentId)
|
let markdown = try await apiClient.exportDocument(id: viewModel.documentId)
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ final class DocumentReaderViewModel {
|
|||||||
private var pinId: String?
|
private var pinId: String?
|
||||||
private(set) var isSubscribed = false
|
private(set) var isSubscribed = false
|
||||||
private var subscriptionId: String?
|
private var subscriptionId: String?
|
||||||
private(set) var share: OutlineShare?
|
|
||||||
|
/// `nil` until checked. Inferred from whether `documents.insights`
|
||||||
|
/// succeeds or fails — `insightsEnabled` isn't readable back off
|
||||||
|
/// `Document` in the vendored spec, so there's no direct field to read.
|
||||||
|
/// This is a heuristic, not confirmed server behavior.
|
||||||
|
private(set) var isInsightsEnabled: Bool?
|
||||||
|
|
||||||
let documentId: String
|
let documentId: String
|
||||||
private let apiClient: OutlineAPIClient
|
private let apiClient: OutlineAPIClient
|
||||||
@@ -75,7 +80,9 @@ final class DocumentReaderViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadPinAndSubscriptionState() async {
|
func loadPinAndSubscriptionState() async {
|
||||||
if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collectionId)),
|
// `collectionId: nil` = Home pins. This menu's Pin action is "Pin to
|
||||||
|
// Home", not "Pin to Collection" — those are distinct on the server.
|
||||||
|
if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)),
|
||||||
let match = pins.first(where: { $0.documentId == documentId }) {
|
let match = pins.first(where: { $0.documentId == documentId }) {
|
||||||
isPinned = true
|
isPinned = true
|
||||||
pinId = match.id
|
pinId = match.id
|
||||||
@@ -94,8 +101,13 @@ final class DocumentReaderViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadShare() async {
|
func loadInsightsEnabledState() async {
|
||||||
share = try? await apiClient.shareInfo(documentId: documentId)
|
do {
|
||||||
|
_ = try await apiClient.documentInsights(DocumentInsightsRequest(id: documentId))
|
||||||
|
isInsightsEnabled = true
|
||||||
|
} catch {
|
||||||
|
isInsightsEnabled = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func togglePin() async throws {
|
func togglePin() async throws {
|
||||||
@@ -110,7 +122,7 @@ final class DocumentReaderViewModel {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: collectionId))
|
let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: nil))
|
||||||
pinId = pin.id
|
pinId = pin.id
|
||||||
isPinned = true
|
isPinned = true
|
||||||
}
|
}
|
||||||
@@ -134,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.
|
/// Turning editing off saves; turning it on is just a mode switch.
|
||||||
func toggleEditing() async {
|
func toggleEditing() async {
|
||||||
guard isEditing else {
|
guard isEditing else {
|
||||||
@@ -168,14 +176,14 @@ final class DocumentReaderViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fire-and-forget: `insightsEnabled` isn't readable back off `Document`
|
func toggleViewerInsights() async throws {
|
||||||
/// in the vendored spec, so there's no state to reflect as a checkmark.
|
let newValue = !(isInsightsEnabled ?? false)
|
||||||
func enableViewerInsights() async throws {
|
isInsightsEnabled = newValue
|
||||||
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: true))
|
do {
|
||||||
}
|
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: newValue))
|
||||||
|
} catch {
|
||||||
/// Fire-and-forget, speculative field — see `UpdateDocumentRequest.documentEmbeds`.
|
isInsightsEnabled = !newValue
|
||||||
func enableEmbeds() async throws {
|
throw error
|
||||||
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, documentEmbeds: true))
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,68 +3,306 @@ import AppKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
|
/// Content of the Share popover anchored to the reader toolbar's Share
|
||||||
|
/// button (see `DocumentReaderView`'s `.popover(isPresented:)`). Was a
|
||||||
|
/// modal `.sheet` originally — moved to a popover so it reads as "options
|
||||||
|
/// for this button" instead of interrupting the whole window.
|
||||||
@MainActor
|
@MainActor
|
||||||
struct DocumentShareSheet: View {
|
struct DocumentShareSheet: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
let apiClient: OutlineAPIClient
|
let apiClient: OutlineAPIClient
|
||||||
let documentId: String
|
let documentId: String
|
||||||
|
|
||||||
@State private var share: OutlineShare?
|
@State private var share: OutlineShare?
|
||||||
@State private var isLoading = false
|
@State private var titleOverride = ""
|
||||||
@State private var isUpdating = false
|
@State private var isLoadingShare = false
|
||||||
@State private var errorMessage: String?
|
@State private var isUpdatingShare = false
|
||||||
|
@State private var isRevoking = false
|
||||||
|
@State private var isShowingRevokeConfirmation = false
|
||||||
|
@State private var isShowingTitleField = false
|
||||||
|
@State private var shareErrorMessage: String?
|
||||||
@State private var didCopy = false
|
@State private var didCopy = false
|
||||||
|
|
||||||
var body: some View {
|
@State private var members: [OutlineDocumentMember] = []
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
@State private var isLoadingMembers = false
|
||||||
HStack {
|
@State private var isShowingAddPerson = false
|
||||||
Text("Share")
|
@State private var userSearchQuery = ""
|
||||||
.font(.headline)
|
@State private var userSearchResults: [OutlineUser] = []
|
||||||
Spacer()
|
@State private var isSearchingUsers = false
|
||||||
Button("Done") { dismiss() }
|
@State private var selectedPermission = "read"
|
||||||
}
|
@State private var isAddingUser = false
|
||||||
|
@State private var actionErrorMessage: String?
|
||||||
|
|
||||||
if isLoading {
|
var body: some View {
|
||||||
ProgressView().frame(maxWidth: .infinity)
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
} else if let errorMessage {
|
Text("Share")
|
||||||
Text(errorMessage)
|
.font(.headline)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 14)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
shareLinkSection
|
||||||
|
peopleSection
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.frame(minHeight: 150, maxHeight: 900)
|
||||||
|
}
|
||||||
|
.frame(width: 280)
|
||||||
|
.task { await loadShare() }
|
||||||
|
.task { await loadMembers() }
|
||||||
|
.task(id: userSearchQuery) {
|
||||||
|
try? await Task.sleep(for: .milliseconds(250))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await searchUsers(userSearchQuery)
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Revoke this share link?",
|
||||||
|
isPresented: $isShowingRevokeConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Revoke", role: .destructive) {
|
||||||
|
Task { await revoke() }
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Anyone using this link will no longer be able to access the document.")
|
||||||
|
}
|
||||||
|
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
|
||||||
|
Button("OK") { actionErrorMessage = nil }
|
||||||
|
} message: {
|
||||||
|
Text(actionErrorMessage ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Link section
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var shareLinkSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
sectionHeader(icon: "link", title: "Public Link")
|
||||||
|
|
||||||
|
if isLoadingShare {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
} else if let shareErrorMessage {
|
||||||
|
Text(shareErrorMessage)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
} else if let share {
|
} else if let share {
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(share.url)
|
if let url = share.url {
|
||||||
.font(.callout)
|
HStack(spacing: 8) {
|
||||||
.lineLimit(1)
|
Image(systemName: "globe")
|
||||||
.truncationMode(.middle)
|
.foregroundStyle(.secondary)
|
||||||
Spacer()
|
.font(.callout)
|
||||||
Button {
|
Text(url)
|
||||||
copyLink(share.url)
|
.font(.callout)
|
||||||
} label: {
|
.lineLimit(1)
|
||||||
Image(systemName: didCopy ? "checkmark" : "doc.on.doc")
|
.truncationMode(.middle)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Button {
|
||||||
|
copyLink(url)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: didCopy ? "checkmark" : "doc.on.doc")
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(didCopy ? .green : .secondary)
|
||||||
|
.help("Copy link")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isShowingTitleField {
|
||||||
|
TextField("Public page title", text: $titleOverride)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.callout)
|
||||||
|
.disabled(isUpdatingShare)
|
||||||
|
.onSubmit {
|
||||||
|
Task { await setTitle(titleOverride) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(isShowingTitleField ? "Hide title field" : "Set public title") {
|
||||||
|
isShowingTitleField.toggle()
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
.font(.caption)
|
||||||
.padding(8)
|
.foregroundStyle(.secondary)
|
||||||
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6))
|
|
||||||
|
|
||||||
Toggle(
|
Spacer()
|
||||||
"Published — accessible without sign-in",
|
|
||||||
isOn: Binding(
|
Button("Revoke", role: .destructive) {
|
||||||
get: { share.published },
|
isShowingRevokeConfirmation = true
|
||||||
set: { newValue in Task { await setPublished(newValue) } }
|
}
|
||||||
)
|
.buttonStyle(.plain)
|
||||||
)
|
.font(.caption)
|
||||||
.disabled(isUpdating)
|
.foregroundStyle(.red)
|
||||||
|
.disabled(isRevoking)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Button("Create Share Link") {
|
Button {
|
||||||
Task { await create() }
|
Task { await create() }
|
||||||
|
} label: {
|
||||||
|
Label("Create Share Link", systemImage: "link.badge.plus")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.regular)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - People section
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var peopleSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
sectionHeader(icon: "person.2", title: "People with Access")
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
isShowingAddPerson.toggle()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: isShowingAddPerson ? "xmark.circle.fill" : "person.badge.plus")
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.help("Add a person")
|
||||||
|
}
|
||||||
|
|
||||||
|
if isShowingAddPerson {
|
||||||
|
addPersonSection
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLoadingMembers {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
} else if members.isEmpty {
|
||||||
|
Text("No one else has explicit access yet.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
ForEach(members) { member in
|
||||||
|
memberRow(member)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(20)
|
}
|
||||||
.frame(width: 380)
|
|
||||||
.task { await load() }
|
private func sectionHeader(icon: String, title: String) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(title)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var addPersonSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
TextField("Search people by name or email", text: $userSearchQuery)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
|
||||||
|
Picker("Permission", selection: $selectedPermission) {
|
||||||
|
Text("Can view").tag("read")
|
||||||
|
Text("Can edit").tag("read_write")
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.labelsHidden()
|
||||||
|
|
||||||
|
if isSearchingUsers {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
} else if !userSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
if userSearchResults.isEmpty {
|
||||||
|
Text("No matches.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
ForEach(userSearchResults) { user in
|
||||||
|
Button {
|
||||||
|
Task { await addUser(user) }
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
avatar(for: user.name)
|
||||||
|
Text(user.name)
|
||||||
|
.font(.callout)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Image(systemName: "plus.circle")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(isAddingUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func memberRow(_ member: OutlineDocumentMember) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
avatar(for: member.name)
|
||||||
|
Text(member.name)
|
||||||
|
.font(.callout)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if let permission = member.permission {
|
||||||
|
Text(permission == "read_write" ? "Can edit" : "Can view")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(.fill.tertiary, in: Capsule())
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
Task { await removeUser(member) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func avatar(for name: String) -> some View {
|
||||||
|
Circle()
|
||||||
|
.fill(.fill.secondary)
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
.overlay {
|
||||||
|
Text(initials(for: name))
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initials(for name: String) -> String {
|
||||||
|
let parts = name.split(separator: " ").prefix(2)
|
||||||
|
let letters = parts.compactMap { $0.first }
|
||||||
|
return letters.isEmpty ? "?" : String(letters).uppercased()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func copyLink(_ url: String) {
|
private func copyLink(_ url: String) {
|
||||||
@@ -78,34 +316,96 @@ struct DocumentShareSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func load() async {
|
private func loadShare() async {
|
||||||
isLoading = true
|
isLoadingShare = true
|
||||||
defer { isLoading = false }
|
defer { isLoadingShare = false }
|
||||||
do {
|
do {
|
||||||
share = try await apiClient.shareInfo(documentId: documentId)
|
share = try await apiClient.shareInfo(documentId: documentId)
|
||||||
|
titleOverride = share?.title ?? ""
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.")
|
shareErrorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func create() async {
|
private func create() async {
|
||||||
isLoading = true
|
isLoadingShare = true
|
||||||
defer { isLoading = false }
|
defer { isLoadingShare = false }
|
||||||
do {
|
do {
|
||||||
share = try await apiClient.createShare(CreateShareRequest(documentId: documentId))
|
share = try await apiClient.createShare(CreateShareRequest(documentId: documentId))
|
||||||
|
titleOverride = share?.title ?? ""
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.")
|
shareErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setPublished(_ published: Bool) async {
|
private func setTitle(_ title: String) async {
|
||||||
guard let share else { return }
|
guard let share else { return }
|
||||||
isUpdating = true
|
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
defer { isUpdating = false }
|
guard trimmed != (share.title ?? "") else { return }
|
||||||
|
isUpdatingShare = true
|
||||||
|
defer { isUpdatingShare = false }
|
||||||
do {
|
do {
|
||||||
self.share = try await apiClient.updateShare(UpdateShareRequest(id: share.id, published: published))
|
self.share = try await apiClient.updateShare(
|
||||||
|
UpdateShareRequest(id: share.id, published: share.published, title: trimmed)
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.")
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func revoke() async {
|
||||||
|
guard let share else { return }
|
||||||
|
isRevoking = true
|
||||||
|
defer { isRevoking = false }
|
||||||
|
do {
|
||||||
|
try await apiClient.revokeShare(id: share.id)
|
||||||
|
self.share = nil
|
||||||
|
titleOverride = ""
|
||||||
|
isShowingTitleField = false
|
||||||
|
} catch {
|
||||||
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't revoke this share link.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadMembers() async {
|
||||||
|
isLoadingMembers = true
|
||||||
|
defer { isLoadingMembers = false }
|
||||||
|
members = (try? await 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,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,135 @@
|
|||||||
|
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 shouldn't pop the refresh banner.
|
||||||
|
func checkForRemoteChanges(tab: HomeTab) async {
|
||||||
|
async let freshPinned = fetchPinned()
|
||||||
|
guard let freshTab = try? await fetch(tab: tab) else { return }
|
||||||
|
let pinned = await freshPinned
|
||||||
|
if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments)
|
||||||
|
|| Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) {
|
||||||
|
hasRemoteChanges = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pins.list` only returns pin records, not the documents themselves —
|
||||||
|
/// fetches each pinned document individually. Pins are a small curated
|
||||||
|
/// set (unlike a full collection tree), so the N+1 here is acceptable
|
||||||
|
/// where it wouldn't be in the sidebar.
|
||||||
|
private func fetchPinned() async -> [OutlineDocument] {
|
||||||
|
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
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
|
||||||
+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"
|
HEADING="Changelog"
|
||||||
else
|
else
|
||||||
HEAD_COMMIT="$(git -C "$REPO_ROOT" rev-parse HEAD)"
|
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
|
if [ "$HEAD_COMMIT" = "$TAG_COMMIT" ]; then
|
||||||
PREV_TAG="$(git -C "$REPO_ROOT" describe --tags --abbrev=0 "${LATEST_TAG}^" 2>/dev/null || true)"
|
PREV_TAG="$(git -C "$REPO_ROOT" describe --tags --abbrev=0 "${LATEST_TAG}^" 2>/dev/null || true)"
|
||||||
RANGE="${PREV_TAG:+$PREV_TAG..}$LATEST_TAG"
|
RANGE="${PREV_TAG:+$PREV_TAG..}$LATEST_TAG"
|
||||||
|
|||||||
Reference in New Issue
Block a user