114 Commits
Author SHA1 Message Date
Puranjay Savar Mattas 0da26e0fed Merge pull request 'fix(about): About Outpost opens Settings' About section, no popup window' (#10) from fix/about-menu-no-popup into main
Reviewed-on: #10
2026-08-19 14:22:40 +01:00
Puranjay Savar Mattas 6ffba3be02 fix(about): About Outpost opens Settings' About section, no popup window
"About Outpost" in the app menu used to open a separate standalone
window (Window(id: "about") wrapping AboutView). Now just opens Settings
and jumps straight to the About section instead — same content
(AboutInfoView, unchanged), one less window/code path to maintain.

Also removed the "Check for Updates…" button — deferred per explicit
instruction, not deleted for a real reason beyond "not now." A simple
button can come back later; no update-checking logic was ever built; ,
nothing else to clean up alongside it.
2026-08-19 14:08:43 +01:00
Puranjay Savar Mattas 187f2eaa25 Merge pull request 'docs: fix wiki links to include trailing .-' (#9) from docs/wiki-link-fix into main
Reviewed-on: #9
2026-08-19 02:43:10 +01:00
Puranjay Savar Mattas 3fadd008f4 Merge branch 'main' into docs/wiki-link-fix 2026-08-19 02:42:56 +01:00
Puranjay Savar Mattas 410049d161 docs: fix wiki links, they need a trailing .- to resolve correctly
Confirmed live against the actual Gitea wiki — hyperlinks to
hyphenated wiki page names 404 without a trailing .-, e.g.
.../wiki/Privacy-Policy needs to be .../wiki/Privacy-Policy.-
2026-08-19 02:40:43 +01:00
Puranjay Savar Mattas 5301187413 Merge pull request 'docs: README - logo, TestFlight link, drop Status, expand Privacy' (#8) from docs/readme-testflight-privacy into main
Reviewed-on: #8
2026-08-19 02:38:12 +01:00
Puranjay Savar Mattas fe275ac17c docs: README — logo, real TestFlight link, drop Status, expand Privacy
- Logo and a TestFlight download badge at the top.
- Removed the Status/phase checklist — CLAUDE.md already tracks this,
  duplicating it in the README just goes stale.
- Requirements corrected to what the project actually targets right
  now (Xcode 27+, macOS 27+) instead of stale Xcode 16/iOS 17 numbers.
- Privacy section expanded with a real inline summary, linking out to
  the wiki for the full Privacy Policy, Terms of Service, and Data
  Processing Statement rather than duplicating them as repo files.
2026-08-19 02:26:36 +01:00
Puranjay Savar Mattas 710a6617f5 Merge pull request 'Settings parity: Profile, Preferences, Notifications, Passkeys, API & Access' (#7) from feature/settings-parity into main
Reviewed-on: #7
2026-08-18 17:13:52 +01:00
Puranjay Savar Mattas 55c23afaff chore: bump version to 0.0.4 2026-08-18 17:01:17 +01:00
Puranjay Savar Mattas f07c5055fa fix(settings): style Coming Soon as a capsule badge instead of plain text 2026-08-18 16:59:06 +01:00
Puranjay Savar Mattas 8af860cd7e feat(settings): mark Workspace category Coming Soon in the sidebar
Shows a small tertiary-styled 'Coming Soon' next to the category header
whenever every section under it is still !isImplemented — not hardcoded
to Workspace specifically, so it stops on its own once real Workspace
sections start landing instead of needing a manual follow-up removal.
2026-08-18 16:58:14 +01:00
Puranjay Savar Mattas 78e3566a8e fix(settings): show offline hint on Passkeys, clearer API key web-only message
Passkeys was the only implemented section missing the standard offline
banner. Reworded the API key create/delete popup to state plainly it's
only supported on Outline's web version, matching Passkeys' own phrasing
style instead of the more roundabout original wording.
2026-08-18 16:54:45 +01:00
Puranjay Savar Mattas 64e970fc92 fix(settings): API key create/delete need Outline's web session, not this app
Same limitation as Passkeys — apiKeys.create/apiKeys.delete need
Outline's cookie+CSRF web session, not this app's Bearer-token auth.
"New API Key…" and the per-row trash button now show an explanatory
popup instead of attempting a request that doesn't work.

The real create sheet, reveal-once flow, delete confirmation, and their
backing functions are untouched and still fully built/tested at the
OutlineKit layer — only the two trigger points were redirected, each
marked with a TODO pointing back to how to re-enable them (swap the
button action back) once there's a supported native auth path or
Outline adds Bearer support for these two endpoints.
2026-08-18 16:53:46 +01:00
Puranjay Savar Mattas 1e541979e9 fix(settings): sidebar version footer — alpha tag, offline handling, auto-refresh
Outpost's version was missing the -ALPHA suffix About already shows —
extracted OutpostVersion (Support/) as the one shared source for both,
so a third divergent copy can't happen the way AboutInfoView's own doc
comment already warns against for its two call sites.

Outline's version now accounts for the manual Offline Mode toggle too,
not just real connectivity, shows "Outline — offline" instead of just
disappearing when nothing's been fetched yet, and re-fetches
automatically via .task(id: isEffectivelyOnline) whenever connectivity
changes — previously a one-shot fetch on first sidebar mount only.
2026-08-18 16:51:13 +01:00
Puranjay Savar Mattas 26b0d7b118 fix(settings): grey out API & Access while offline
New API Key/delete were already gated on isEffectivelyOnline, but
nothing visually signaled offline state the way Preferences/Profile do
— added the same offline hint banner plus dimmed+disabled the key list
itself, and gated the sheet's own Create button too (in case Offline
Mode gets toggled on while the sheet is already open).
2026-08-18 16:49:45 +01:00
Puranjay Savar Mattas 45c950d474 feat(settings): API key create, reveal-once, and delete
Create: name + expiration picker (No expiration/1/3/6 months/1 year,
computed client-side and sent as expiresAt — omitted entirely for no
expiration, confirmed live that's what produces a non-expiring key).

Reveal: plaintext value only ever shown in a dedicated one-time sheet,
separate from the persisted apiKeys list (which is refreshed from the
server right after creating, so it never carries the value at all).
Requires clicking Copy before the confirm button unlocks, copies to the
system pasteboard, and clears the value from @State the moment the sheet
closes however it closes (explicit confirm, Escape, or otherwise) via
onDisappear — not just hidden behind dismissed UI.

Delete: confirmation dialog naming the key, per-row spinner while in
flight.

Both New API Key and per-row delete disable while offline, consistent
with every other server-synced action in Settings.
2026-08-18 16:48:12 +01:00
Puranjay Savar Mattas 044a706bd4 feat(outlinekit): add apiKeys.create/delete
value is only ever present in the create response (confirmed live —
apiKeys.list never includes it), so the model and call sites treat it
as a one-time reveal, not persisted state. expiresAt omitted (not null)
for a non-expiring key, matches synthesized Encodable's default
encodeIfPresent behavior.
2026-08-18 16:45:18 +01:00
Puranjay Savar Mattas fa22ac616b feat(settings): move Outline version to sidebar footer, drop Installation section
Removed the Integrations & Installation category and its lone
Installation section entirely — Outline's server version now shows in
the settings sidebar footer instead, alongside Outpost's own version
(installation.info, fetched once when the sidebar appears). About stays
where it was, unaffected.
2026-08-18 16:39:19 +01:00
Puranjay Savar Mattas adfa477ecf feat(settings): Installation section
Server version + up-to-date/behind indicator via installation.info.
2026-08-18 14:20:26 +01:00
Puranjay Savar Mattas dc780e420f feat(settings): API & Access section (personal keys, read-only)
Lists existing personal API keys via apiKeys.list (name, masked last4,
created/last-used dates) with a link to Outline's developer docs.
Creation/revocation deferred per instruction — read-only for this pass.
2026-08-18 14:19:50 +01:00
Puranjay Savar Mattas 6eb780d1bd feat(settings): Passkeys section
Informational only, per Outline itself — passkey/WebAuthn registration
needs a browser context, so this stays web-only rather than a
placeholder for missing native functionality.
2026-08-18 14:18:26 +01:00
Puranjay Savar Mattas 78a0e31897 feat(settings): mark Passkeys, API & Access, Installation implemented 2026-08-18 14:17:55 +01:00
Puranjay Savar Mattas e3ad8650c6 feat(outlinekit): add apiKeys.list and installation.info plumbing
Read-only for now, per instruction to defer key creation/revocation.
Both confirmed against live network captures.
2026-08-18 14:17:41 +01:00
Puranjay Savar Mattas 4faa23f79e chore(xcode): sync project signing config after bundle ID rename
Picks up Xcode's own automatic-signing additions from setting up the
Development provisioning profile in the IDE (CODE_SIGN_IDENTITY,
PROVISIONING_PROFILE_SPECIFIER, ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS,
Info.plist display name/category keys), alongside the earlier
com.psmattas.OutpostApp bundle ID and CW6GQT9SK5 team fixes.
2026-08-18 13:52:32 +01:00
Puranjay Savar Mattas 09e1ab8f65 fix(auth): properly clean the login logo edge, previous pass left a fringe
Plain -fuzz -transparent white color-keyed every pixel in the whole
image close enough to white by color distance — including an
antialiased blend inside the artwork itself (where the light beam meets
the window frame), which punched a stray transparent hole there, and
still left a faint opaque fringe ring around the outer edge since the
binary cutoff didn't fully consume the antialiasing ramp.

Redone with a flood fill seeded from a corner pixel instead: only
transforms the background region actually connected to that seed (the
four corners + thin white border, confirmed contiguous), leaving
disconnected internal antialiased pixels untouched. Then eroded the
alpha channel by ~2px to consume the remaining boundary ramp cleanly.
Verified against both dark and light composited backgrounds, and
confirmed the beam/window-frame area is no longer punctured.
2026-08-18 12:39:42 +01:00
Puranjay Savar Mattas bec8135c74 fix(auth): remove white corners around the login logo
The App Store marketing icon source (outpost-ios-1024.png) is a flat
opaque square with the squircle baked in and pure-white corners outside
it — meant to be masked by the OS everywhere it's normally shown as an
app icon, but nothing masks it when used as a plain in-app image.
Color-keyed white to transparent (fuzz 8%, safe here since nothing in
the actual artwork is white/near-white) so the corners disappear instead
of showing a white border.
2026-08-18 12:30:05 +01:00
Puranjay Savar Mattas fcb72ba9d6 fix(auth): login logo was blank, AppIcon app-icon set isn't Image()-loadable
Image("AppIcon") resolved to nothing at runtime (confirmed live) — App
Icon-type asset catalog entries aren't reliably retrievable through
Image(_:)/UIImage(named:) the way a normal Image Set is. Added AppLogo,
a plain Image Set duplicating the same outpost-ios-1024.png artwork, and
pointed AuthHeaderView at that instead.
2026-08-18 12:22:16 +01:00
Puranjay Savar Mattas 2b0924be04 fix(auth): use the real app icon on the login screen, not a placeholder
AuthHeaderView was still a generic gradient-circle + SF Symbol book icon
from before AppIcon.appiconset had real branded artwork — never swapped
out once the actual icon was added. Now renders Image("AppIcon") directly
so the login screen can't drift from whatever the Dock/Home Screen icon
actually is.
2026-08-18 12:09:52 +01:00
Puranjay Savar Mattas 931338c9d3 fix(session): don't show signed-in UI when only half the session survived
SessionStore.init treated a Keychain token alone as "signed in," without
checking the paired serverURL in UserDefaults also existed. UserDefaults
is scoped to the app's sandboxed container (keyed by bundle ID), while
Keychain items can survive a reinstall or bundle ID change independently
of it — hit this live after renaming the bundle ID: Keychain still had
the old token, the new container had no serverURL, so isSignedIn came
back true with apiClient nil. App landed on Home with nothing able to
load instead of the login screen.

Now requires both to be present to consider the session valid, and clears
whichever half survived otherwise so a fresh sign-in rewrites both
consistently.
2026-08-18 12:07:02 +01:00
Puranjay Savar Mattas 2740eaeaaf feat(settings): Notifications document-access-requested toggle
Closes out the Notifications page — all 13 event toggles + the All
notifications master toggle now wired.
2026-08-17 21:53:06 +01:00
Puranjay Savar Mattas 6986601b8b feat(settings): Notifications invited-to-collection/export-completed toggles 2026-08-17 21:52:58 +01:00
Puranjay Savar Mattas ddd6701446 feat(settings): Notifications invite-accepted/invited-to-document toggles 2026-08-17 21:52:50 +01:00
Puranjay Savar Mattas aee09cb60a feat(settings): Notifications reaction-added/collection-created toggles 2026-08-17 21:52:42 +01:00
Puranjay Savar Mattas 944d0bf373 feat(settings): Notifications group-mentions/resolved toggles 2026-08-17 21:52:33 +01:00
Puranjay Savar Mattas a1df1b3d3d feat(settings): Notifications comment-posted/mentioned toggles
"Mentioned" groups comments.mentioned + documents.mentioned under one
visible toggle, per Outline's own settings copy.
2026-08-17 21:52:25 +01:00
Puranjay Savar Mattas 8faa94f3cc feat(settings): Notifications document-published/document-updated toggles 2026-08-17 21:52:15 +01:00
Puranjay Savar Mattas bf1e28c9ac feat(settings): Notifications section skeleton + All-notifications toggle
Marks .notifications implemented, adds the shared setNotifications save
path (per-event subscribe/unsubscribe, sequential calls for toggles that
group more than one wire event type) and the master "All notifications"
row. Individual event toggles land in follow-up commits.
2026-08-17 21:52:07 +01:00
Puranjay Savar Mattas c05b787bb7 feat(session): track user notification settings
Same pattern as language/preferences — Notifications settings page reads
current subscribed state from here.
2026-08-17 21:51:24 +01:00
Puranjay Savar Mattas 960919227b feat(outlinekit): add notification subscription plumbing
NotificationEventType (wire keys confirmed live), OutlineUser.notification
Settings dictionary, subscribeToNotifications/unsubscribeFromNotifications
(users.notificationsSubscribe/Unsubscribe, nil eventType = all — confirmed
against the "All notifications" master toggle live too).
2026-08-17 21:51:07 +01:00
Puranjay Savar Mattas a11be29ba9 feat(settings): grey out server-synced settings while offline
Profile's name/avatar and every Preferences toggle (except Appearance,
which is local-only) now disable + show a hint when isEffectivelyOnline
is false, instead of letting a save silently fail. Matches how Share/
Permissions/Search already behave — these are a "needs a real connection"
category, not queued through the offline write queue (infrequent writes,
not worth a second offline-sync path for).
2026-08-17 21:49:11 +01:00
Puranjay Savar Mattas 564cce0637 fix(outlinekit): correct preferences wire keys to the real server shape
All preference toggles showed as off regardless of server state, and
saving notificationBadge 400'd with "notificationBadge: Invalid Input" —
the guessed wire keys/values from the earlier speculative commit were
wrong. Fixed against a live network capture of Outline's own web app
toggling every one of these settings:

- separateEditing -> seamlessEdit, and inverted (seamlessEdit is
  separate editing's negation, confirmed by toggling it live)
- showCommentMarker -> commentsInGutter
- smartText -> enableSmartText
- notificationBadge values -> "disabled"/"indicator"/"count", not the
  guessed "none"/"unread"/"all"
- rememberLastPath/useCursorPointer/codeBlockLineNumbers were already
  correct

Also preserves fullWidthDocuments (a real preference this app has no UI
for) on round-trip, since the client always sends the whole preferences
object back on save — dropping an unrecognized key during decode would
otherwise silently clear it the next time any toggle here gets saved.
2026-08-17 21:35:17 +01:00
Puranjay Savar Mattas 7e34d58a81 fix(settings): language picker breaks on a server locale not in our list
Live warning: "en_GB" invalid tag, undefined Picker display — the curated
OutlineLocale.all didn't include it. Added en_GB explicitly, and made the
Picker's item list always include whatever code the server actually
reports (falling back to the code itself as the label) so any future
unlisted locale degrades gracefully instead of breaking the control.
2026-08-17 21:26:12 +01:00
Puranjay Savar Mattas 384dfe3cc1 feat(settings): Preferences delete-account action
Danger subsection, confirmation dialog before calling users.delete
(deleteAccount()), signs out locally on success. Closes out the
Preferences page.
2026-08-15 20:31:50 +01:00
Puranjay Savar Mattas cac11f0a22 feat(settings): Preferences notification-badge picker
Closes out the Behavior subsection. Uses NotificationBadgeStyle
(None/Unread Indicator/Unread Count) — wire values are a guess same as
the other preference keys, flagged in OutlineUserPreferences.
2026-08-15 20:31:24 +01:00
Puranjay Savar Mattas 78e1c158aa feat(settings): Preferences smart-text-replacements toggle 2026-08-15 20:31:11 +01:00
Puranjay Savar Mattas eeb6dc2564 feat(settings): Preferences remember-previous-location toggle 2026-08-15 20:31:05 +01:00
Puranjay Savar Mattas 286fe80e58 feat(settings): Preferences separate-editing toggle
Starts the Behavior subsection.
2026-08-15 20:30:58 +01:00
Puranjay Savar Mattas 32600bf081 feat(settings): Preferences show-comment-marker toggle
Closes out the Display subsection.
2026-08-15 20:30:51 +01:00
Puranjay Savar Mattas bdb1b4977f feat(settings): Preferences show-line-numbers toggle 2026-08-15 20:30:44 +01:00
Puranjay Savar Mattas f5cfb53f96 feat(settings): Preferences use-pointer-cursor toggle 2026-08-15 20:30:38 +01:00
Puranjay Savar Mattas 73602d1ade feat(settings): shared save path for preference toggles
savePreference(_:) reads the current OutlineUserPreferences, flips one
field, sends the whole object via updateUserPreferences. Every Behavior/
Display toggle added next reuses this instead of its own copy of the
same read-modify-write.
2026-08-15 20:30:31 +01:00
Puranjay Savar Mattas a3651a9364 feat(settings): Preferences appearance setting
Reuses the existing local Appearance color-scheme picker instead of a
second divergent implementation — this has always been a device-side
preference in this app, not a server-synced one.
2026-08-15 20:30:08 +01:00
Puranjay Savar Mattas cfdae2f9ba feat(settings): Preferences section skeleton + Language setting
Marks .preferences implemented, adds the section header/description and
a Display subsection. Language is real and wired end-to-end (users.update
language field, high confidence per Outline's public API docs) — the rest
of the toggles land in follow-up commits, one setting at a time.
2026-08-15 20:29:54 +01:00
Puranjay Savar Mattas fdb4ba5c06 feat(session): track user language + preferences
Mirrors the existing name/avatar/id fields — Settings' Preferences page
needs somewhere to read current values from and applyUpdatedProfile
already refreshes everything else on save.
2026-08-15 20:29:06 +01:00
Puranjay Savar Mattas eb4e1472b9 feat(outlinekit): add language/preferences plumbing for Preferences settings
OutlineUser gains language + preferences fields, new updateUserLanguage/
updateUserPreferences/deleteAccount client methods. Preference wire keys
are best-effort against Outline's own naming, same speculative treatment
as OutlinePin/OutlineDocumentMember — expect a correction round once
tested against a live server.
2026-08-15 20:28:38 +01:00
Puranjay Savar Mattas cd6277f5aa fix(avatar): attach the Bearer token — the real bug, not propagation delay
Your network capture of the working web-app request showed session
cookies (accessToken, authelia_session) on the attachments.redirect
call. This app authenticates every other request with an Authorization:
Bearer header instead — AvatarBadge's fetch never attached one, sending
a bare unauthenticated GET. Almost certainly a 401 the whole time, for
every avatar image, not just a freshly-uploaded one — a failed fetch
and "no avatar set" render identically here (placeholder icon, no
visible error), so there was nothing on screen to reveal it before now.

Uses KeychainTokenStore() directly, same keychain entry SessionStore
already reads, rather than threading a token through every AvatarBadge
call site. Kept the retry loop from the previous attempt too — genuinely
useful insurance against upload-consistency timing, just not the actual
cause here.
2026-08-15 16:44:45 +01:00
Puranjay Savar Mattas bde6be91ca fix(avatar): retry the redirect fetch instead of giving up on one failure
You confirmed the whole upload chain works — the new avatar shows
instantly on Outline's web app — but this app kept showing the old/
placeholder picture in both Settings and the sidebar, which read the
same underlying session.userAvatarURL. Since that state is provably
correct (the same URL that works on web), the bug has to be in how
AvatarBadge loads it, not in the update itself.

Leading theory: a freshly-uploaded attachment's redirect URL can fail
on the very first request right after upload — self-hosted storage
behind a reverse proxy isn't necessarily instantly consistent —  and
AvatarBadge's .task(id:) only ever fires once per URL with no retry,
so a single transient failure right after uploading would leave the
placeholder showing forever even though the exact same URL works fine
moments later (which lines up with it looking fine on a fresh web
load). Now retries twice with a short delay and explicitly checks the
HTTP status before treating the body as image data, instead of
silently accepting whatever came back. Also drops any URLCache
involvement (.reloadIgnoringLocalCacheData) as a second, independent
possible cause, cheap to rule out at the same time.

Couldn't confirm this is the actual root cause without being able to
run the app — worth retesting.
2026-08-15 16:37:44 +01:00
Puranjay Savar Mattas cb8bbd53c4 fix(profile): refresh from the server every time the page opens
SessionStore only ever populated from auth.info once per app launch
— a name (or avatar) changed elsewhere, like Outline's web app, never
reached it until a full quit-and-relaunch. Profile now refetches via
currentUser() every time it's opened and applies the result, same as
any live edit made from this app already does. Fails silently offline,
same as everything else that needs a live read.
2026-08-15 16:31:40 +01:00
Puranjay Savar Mattas 90df5f056d fix: missing UniformTypeIdentifiers import for NSOpenPanel content types 2026-08-15 16:27:39 +01:00
Puranjay Savar Mattas 077042295b feat(profile): avatar upload/remove with crop editor, name editing
Profile now has a real avatar row (AvatarBadge + Upload Photo…/Remove),
wired through last commit's presigned-upload backend: pick a file via
NSOpenPanel, crop/rotate/zoom in a new AvatarCropperView, upload,
point users.update at the result, then best-effort delete whatever
attachment the previous avatar pointed to so replacing/removing a
photo doesn't leak an orphaned blob in Outline's storage every time.

AvatarCropperView builds its on-screen preview and its final exported
image from the exact same SwiftUI view composition (just instantiated
twice — once for display, once through ImageRenderer) rather than a
separately hand-derived set of crop math for a higher resolution —
that's deliberate: there's no way to visually verify a second
independent set of transform math agrees with what the user actually
saw and confirmed without running the app, so making the export
WYSIWYG by construction was the safer choice here.

Name is now editable too (TextField + Save, users.update name-only).
Email is read-only with a note pointing to Outline's web app instead —
per instruction, changing it here isn't supported since email is
tied to sign-in.

SessionStore gained userId (never stored before — needed for every
users.update call) and applyUpdatedProfile(_:), so a successful
change reflects immediately in the account footer and everywhere
else without waiting for the next auth.info refresh.
2026-08-15 16:27:01 +01:00
Puranjay Savar Mattas 052db93d69 feat(profile): name update + attachment cleanup backend
UpdateUserNameRequest (users.update, name only) and deleteAttachment
(attachments.delete, id-only — same shape as every other simple
delete in this API, not yet confirmed live) so the app can clean up
the previous avatar attachment when replacing or removing it instead
of leaking an orphaned blob in Outline's storage every time. 2 new
tests, 65/65 passing.
2026-08-15 16:23:43 +01:00
Puranjay Savar Mattas 373c681c10 feat(profile): avatar upload/remove backend (attachments + users.update)
Adds the presigned-upload plumbing Outline's own web client uses for
any file upload, not just avatars: attachments.create requests an
upload target (server-assigned key, ACL, a short-lived signed form),
then a direct multipart POST to that target (uploadUrl/form) actually
uploads the bytes — confirmed against a live server's own request/
response shapes pulled from network capture. MultipartFormDataBuilder
is a pure, fully-tested function (no network) building that S3-style
presigned-POST body.

uploadAttachmentFile deliberately sends no Bearer/CSRF header — the
presigned form's `sig` field is what authorizes that specific request,
same as an S3 presigned POST; flagged as best-effort pending live
confirmation, cheap to add if the server turns out to also want one.

UpdateUserAvatarRequest (users.update, avatar only) needed a custom
encode(to:) — Swift's synthesized Encodable omits nil Optional keys
via encodeIfPresent, but removing an avatar needs a literal
"avatarUrl": null in the body, not the key missing. Confirmed the
request shape (id + avatarUrl) from the captured request's
Content-Length matching that shape and no shorter alternative.

6 new tests (multipart body structure/field ordering, explicit-null
encoding, create/upload/update round trip) — 63/63 passing.
2026-08-15 16:19:33 +01:00
Puranjay Savar Mattas 2e6de4baf4 fix(settings): label our own settings group "Outpost"
The general category (Appearance, Offline & Sync, Advanced, About)
sat unlabeled at the top of the sidebar, ambiguous next to the named
Outline categories below it. Now has its own "Outpost" header.
2026-08-15 16:06:05 +01:00
Puranjay Savar Mattas 67bef76716 feat(settings): grouped sidebar matching Outline's own settings categories
SettingsSection now carries a SettingsCategory (general/account/
workspace/integrationsInstallation) and SettingsSidebarList renders it
as a grouped, headered list — general (ours: Appearance, Offline &
Sync, Advanced, About) unlabeled at the top, then Account (Profile,
Preferences, Notifications, Passkeys, API & Access), Workspace
(Details, Authentication, Security, AI, Members, Groups, Templates,
Emojis, Applications, Shared, Links, Webhooks, Import, Export), and
Integrations & Installation (Installation).

Nav skeleton only, per instruction — everything except the ones
already built (renamed the old flat "Account" page to "Profile", its
natural new home; content unchanged) shows a "Coming Soon" placeholder
until each section's real content is specified and built one at a
time.
2026-08-15 16:04:03 +01:00
Puranjay Savar Mattas 861f596f5f Merge pull request 'Offline-first support: read cache, write queue, settings redesign' (#6) from feature/offline-cache into main
Reviewed-on: #6
2026-08-15 15:47:48 +01:00
Puranjay Savar Mattas badb206f8a chore: bump version to 0.0.3 2026-08-15 15:45:08 +01:00
Puranjay Savar Mattas bf9eec6a9b fix(offline): retry pending sync with a cooloff instead of one shot
The auto-flush that runs when reconnecting (or turning Offline Mode
back off) only ever tried once — any operation that still failed on
that attempt sat stuck until the user manually hit Retry or
connectivity changed again. Now retries every 5 minutes for as long as
anything's still pending and the signal stays on, stopping on its own
once the queue is empty. Full Local Sync's existing 20-minute loop is
unaffected/separate.
2026-08-15 15:43:00 +01:00
Puranjay Savar Mattas 21e81feed7 fix(settings): stop non-About sections from vertically centering
.leading as an Alignment means horizontally-leading but vertically
centered, not top-left — the minHeight fix for centering About ended
up vertically centering every other section too inside that same tall
box. .topLeading is what was actually meant; About still centers
correctly since its own .frame(maxHeight: .infinity, alignment: .center)
fills and self-centers within whatever space it's given regardless.
2026-08-15 01:49:05 +01:00
Puranjay Savar Mattas 8b4c5852ed feat(offline): support creating documents offline
createDocument now queues and syncs like the other offline-capable
writes, closing the gap deliberately left open earlier this session.
Synthesizes a pending-<uuid> document (same placeholder scheme as
pin/star/subscribe), caches it so it's immediately readable/openable,
and queues the real create. On sync, the server's real document
replaces the placeholder in the cache — no dead orphan entries.

Editing that same still-unsynced document folds the edit into the
pending create's payload instead of queuing a separate update, which
would otherwise target the placeholder id and 404 forever once
flushed. CreateDocumentRequest widened Encodable -> Codable for the
queue round-trip.

Known limitation: a reader still open on that exact document at the
moment it syncs in the background keeps holding the stale placeholder
id until navigated away and back. Narrow, not solved generally here.

3 new OutlineKit tests (57/57). Removed the now-unneeded offline
restriction on the reader's New Document button/menu item.
2026-08-15 01:46:32 +01:00
Puranjay Savar Mattas 284ad07251 fix(offline): false "New changes available" banner, sync stuck on toggle-off
Home's checkForRemoteChanges compared fresh pinned docs against what
was displayed, but the pinned fetch (listPins isn't a cached endpoint)
silently collapsed any failure to []  — every poll while offline
compared "[]" against the real non-empty pinned list, which always
looked like a change and popped the banner every ~45s. Split fetchPinned
into a throwing variant checkForRemoteChanges can bail on (matching the
already-correct pattern in CollectionsViewModel/DocumentsViewModel,
which don't have this bug), keeping the non-throwing version for the
initial load where collapsing to [] is the right behavior.

RootView's auto-flush was keyed only to session.networkMonitor.isOnline
— turning the manual Offline Mode toggle back off while the real
network had been up the whole time never changes that value, so queued
operations sat stuck until the next real network blip. Keyed the flush
(and the Full Local Sync loop's online check) to a combined
isEffectivelyOnline instead, so either signal clearing resumes sync
immediately.
2026-08-15 01:40:07 +01:00
Puranjay Savar Mattas 8d8c8fead8 style(settings): center the About section on the page
AboutInfoView (icon/name/version/links) now centers both
horizontally and vertically within the detail pane instead of sitting
pinned to the top-left like the other sections. Needed a GeometryReader
+ minHeight on the shared ScrollView wrapper — a ScrollView proposes
unbounded height to its content, so maxHeight: .infinity alone doesn't
give short content anything to center within; minHeight pinned to the
real viewport height does. Harmless for the other sections, which stay
top/leading-aligned as before.
2026-08-15 01:35:24 +01:00
Puranjay Savar Mattas e2e4b746c1 fix(reader): pass a real per-document id to NativeTextViewWrapper
Every document was using the library's literal "default" documentId
— never our own viewModel.documentId — meaning undo stacks, content-
divergence snapshots, scroll-offset memory, and pending inline
replacements were all keyed to the same slot across every document
instead of being scoped per-document. That's a real, unambiguous bug
regardless of the click/cursor issue: opening a second document could
replay or discard the wrong document's undo history.

Investigated the click-does-nothing-in-edit-mode report by reading
through the library's (vendored, external — swift-markdown-engine)
own isEditable/isSelectable wiring in both makeNSView and updateNSView
directly; both looked correctly applied on every pass, and a clean-
relaunch test ruled out the settings/NavigationSplitView bug from
earlier in this session as the cause. Couldn't confirm this documentId
fix resolves the click issue without being able to run the app here —
worth retesting either way.
2026-08-15 01:33:54 +01:00
Puranjay Savar Mattas 0d9f7d752d fix(reader): grey out share/permissions and other live-only actions offline
Share, Permissions, Templatize, Duplicate, Unpublish, Archive, Move,
New Document, History, Insights (sheet + Viewer Insights toggle), and
Download all hit the server directly with no offline path — disabled
(with a tooltip on the two toolbar buttons) whenever the app isn't
effectively online, instead of failing confusingly on tap.

Left enabled: Edit, Pin/Unpin, Star/Unstar, Subscribed, Full Width
(all queue via CachingOutlineAPIClient and sync later), Present and
Search in Document (both read documents.info, which is cached), and
Copy/Print (read already-loaded text directly, no network at all).
2026-08-15 01:22:47 +01:00
Puranjay Savar Mattas a9db3e1412 fix(settings): make Settings its own top-level branch, not swapped content
The .id() fix on the detail pane wasn't enough — still reported as
the document staying visible with only the sidebar switching.
NavigationSplitView bridges to NSSplitViewController on macOS, and
apparently doesn't reliably replace already-mounted detail content
(a NavigationStack with real push history) for something unrelated
inside one persisting split view instance, identity hints or not.

Restructured so `navigation.isShowingSettings` picks between two
entirely separate NavigationSplitView instances (settingsContent /
mainContent) at the top of body, instead of branching on content
inside a single one. A different top-level view hierarchy is a
guaranteed full teardown of whatever AppKit was holding onto — no
split-view instance persists across the switch for it to get
confused about reusing.

Also dropped the now-redundant `isShowingSettings` guards scattered
through mainContent's toolbar/leadingToolbarContent — moot once
mainContent only ever renders while Settings isn't showing.
2026-08-15 01:13:28 +01:00
Puranjay Savar Mattas 82ff3b49ff fix(settings): force detail pane teardown when opening/closing Settings
Opening Settings while a document was pushed left the document
visible in the detail pane even though the sidebar correctly switched
to the settings list — NavigationSplitView on macOS doesn't reliably
replace the detail pane's content on an implicit branch change alone
between very different subtrees (a NavigationStack with a real push
history vs. a plain view). An explicit .id() keyed to
isShowingSettings forces a real teardown/remount; documentPath itself
is untouched, so returning via Done still lands back on the same
document.
2026-08-15 01:10:29 +01:00
Puranjay Savar Mattas 924f84525c fix(offline): manual mode never skipped live reads, storage/banner cleanup
The main fix: CachingOutlineAPIClient.cachedFetch (documentInfo,
listCollections, listDocuments, etc.) never actually checked manual
Offline Mode — only the write path did. With a real connection still
up, turning Offline Mode on did nothing for reads: the sidebar kept
fetching live, showing collections/documents beyond what was actually
cached. Reads now skip `live` entirely under manual offline mode, same
as writes already did — the sidebar and document lists are now
genuinely limited to whatever's cached, and only those documents are
openable, once the toggle is on. 1 new regression test (54/54).

Settings: removed the cache-size readout from Offline & Sync (storage
management is Advanced-only now, per the "one place that can touch the
cache" design) and added it next to Advanced's Clear All Cache instead,
where it was missing.

Sidebar: replaced the old passive "Offline — showing cached content"
banner for a real dropped connection with an actionable prompt
(OfflineConnectionPromptBanner, styled like the existing
RemoteChangesBanner) offering to turn Offline Mode on — doesn't change
any behavior on its own, same as RemoteChangesBanner never auto-
refreshes. The informational banner still shows once Offline Mode is
actually on (manually, or via this prompt).
2026-08-15 01:08:03 +01:00
Puranjay Savar Mattas 1ce2aced90 fix(settings): red Clear Cache button, confirm when it's done
role: .destructive alone wasn't rendering red on its own — explicit
.buttonStyle(.borderedProminent).tint(.red), matching how other
destructive actions in the app (DocumentShareSheet's Revoke) had to
be styled explicitly too. Also shows a transient "Cleared" checkmark
for 2s after finishing, same timed-reset pattern as the share sheet's
copy-link feedback, since the storage count updating alone was easy
to miss.
2026-08-15 01:01:19 +01:00
Puranjay Savar Mattas d4d81bb848 feat(settings): Advanced section with safeguards, fully-automatic sync
Full Local Sync and Clear Cache both need a real connection or a real
cache to be safe against — clearing while offline, or letting sync
think it should run with nothing to talk to, can leave the app with
no local copy and no way to rebuild one. Both are now disabled
(Toggle/button, with a tooltip explaining why) whenever the app isn't
effectively online (real network down OR the manual Offline Mode
toggle).

Clear Cache moves out of Offline & Sync entirely into a new Advanced
section (sidebar, above About) — the one deliberate escape hatch that
works even offline, gated behind an off-by-default "Enable Advanced
Options" master toggle that only turns on after a confirmation dialog
warning about data loss. A few not-yet-implemented settings sit under
it as permanently-dimmed "Coming Soon" rows. Nothing destructive is
reachable outside this one screen, so there's no path to breaking
Full Local Sync's cache by accident.

Full Local Sync itself is now fully automatic: RootView's background
task syncs immediately whenever it (re)starts — covers both "just
turned on" and "was already on at a fresh launch" — then every 20
minutes, and again immediately on reconnect. No more needing to press
Sync Now by hand.
2026-08-15 00:58:24 +01:00
Puranjay Savar Mattas ce235d656d fix(offline): pagination-limit bug in full sync, missing manual-mode badge, capture error
- performFullSync was requesting listCollections with limit: 250 —
  Outline caps pagination at 100 and rejects anything over that
  outright, which was the actual "Synced with 1 error" (now visible
  in the UI as of the last fix: "Pagination limit is too large (max
  100)"). Paginate collections in increments of 100 the same way
  documents already were, continuing until a short page signals the
  end — the protocol doesn't expose a total count to ask for up front,
  so this is the only way to know when to stop. 2 new regression tests.
- OfflineBanner only ever reflected NetworkMonitor (a real dropped
  connection) — turning on the manual "Offline Mode" toggle did
  nothing to it, since that's a separate AppStorage flag the banner
  never read. ContentView_macOS's sidebar now shows the banner for
  either condition, with distinct copy for "you turned this on" vs
  "the network's actually down".
- NetworkMonitor: the previous fix (weak self only on the inner Task)
  traded one Swift 6 error for another ("'weak' ownership of capture
  'self' differs from implicitly-captured strong reference in outer
  scope") since the inner closure's capture forced the outer one to
  implicitly capture self too. Standard fix: weak capture on the outer
  closure, guard-let into a strong local immediately, let the inner
  Task closure capture that plain local instead.
2026-08-15 00:45:09 +01:00
Puranjay Savar Mattas ae6cb7dc7d fix: real Xcode compile errors from Swift 6 strict concurrency
- SettingsView: ternary between .secondary (HierarchicalShapeStyle)
  and .red (Color) doesn't unify — both sides now explicit Color.
- CollectionRowView: passing OutlineIconMapping.sfSymbolName as a bare
  function reference to flatMap loses its (inferred default) MainActor
  isolation; wrapping it in a closure keeps the call inside body's
  already-MainActor context.
- NetworkMonitor: [weak self] was captured on NWPathMonitor's
  non-isolated pathUpdateHandler closure, which must cross into the
  inner @MainActor Task — moved the weak capture onto the Task closure
  itself instead, which is where it's actually used.
2026-08-15 00:38:15 +01:00
Puranjay Savar Mattas d1ed52b825 redesign(settings): reuse the real sidebar instead of a mini one
Settings now swaps the actual app sidebar's content (search field,
collections tree, account footer) for a section list, instead of
SettingsView drawing its own nested sidebar inside the detail pane.
Done, wherever it's triggered from, restores the collections tree and
detail content exactly as they were.

AppNavigation gains selectedSettingsSection (and the SettingsSection
enum moves there, shared by the new list and the sidebar host) so the
sidebar's list and the detail pane agree on which section is showing.

Also deferred the profile menu's "Settings…" action by one runloop
tick — setting isShowingSettings synchronously in the same call that
dismisses the popover was very likely the source of the AppKit "CA
commit" transaction warnings in the console.
2026-08-15 00:36:28 +01:00
Puranjay Savar Mattas 45a72f8d10 fix(settings): surface the actual full-sync error, not just a count
"Synced with 1 error" gave no way to tell what failed. Now lists each
FullSyncSummary.errors entry underneath the summary line.
2026-08-15 00:30:55 +01:00
Puranjay Savar Mattas 19eb1bae69 redesign(settings): section list + single-section detail
Replaces the stacked/gridded cards with the same shape as macOS System
Settings: a section list on the left, one section's content in the
detail pane on the right. Sections never render next to each other
anymore, so there's no card-height mismatch to look weird, and the
layout holds up at any window size or aspect ratio without needing a
grid to reflow.
2026-08-15 00:29:51 +01:00
Puranjay Savar Mattas e991f4ed4c style(settings): grid the short cards, keep the tall ones stacked
Appearance and Account are both short — a LazyVGrid lets them sit
side-by-side when the window's wide enough instead of each wasting a
full-width row. Offline & Sync and About stay full-width: pairing
those with anything in the same grid row looked worse (very uneven
row heights) than just stacking them. Also widened the content column
(640 -> 900) so the grid actually has room to go two-up.
2026-08-15 00:23:37 +01:00
Puranjay Savar Mattas 4f423ef971 fix(settings): keep the sidebar visible instead of a full-window overlay
Settings now swaps into the same NavigationSplitView detail pane as
Home/collections (gated on AppNavigation.isShowingSettings) instead of
covering the whole root window — the sidebar, and the ability to just
click something else in it to leave Settings, stays available. Any
sidebar navigation (Home, a collection, a document) exits Settings.
2026-08-15 00:17:51 +01:00
Puranjay Savar Mattas 4769645489 feat(offline): write queue with sync-on-reconnect + settings redesign
Extends CachingOutlineAPIClient with a scoped offline write queue:
updateDocument, updateCollection, pin/unpin, star/unstar, and
subscribe/unsubscribe now apply optimistically and queue via a new
PendingOperation (SwiftData) when they fail (or when a new manual
"Offline Mode" toggle forces it), then replay on reconnect via
flushPendingOperations(). Same-target edits coalesce into one queued
operation; a pin/unpin pair that never syncs cancels out instead of
queuing a delete the server never saw. Actions that would invent new
tree structure (create/move/archive/delete/duplicate) stay live-only —
reconciling a locally-invented id against the server's real one is a
separate, harder problem this pass doesn't take on. Sharing,
permissions, search, and export also stay live-only.

Added a "Full Local Sync" toggle that eagerly walks and caches the
whole workspace instead of only what's been opened, running
immediately on enable and every 20 minutes after while online.

Settings moved from a popup (Settings {} scene / PreferencesView) to
a full-page view rendered inside the root window (AppNavigation),
including the ⌘, shortcut. Folds in offline/sync management (storage
size, clear cache, pending-sync list with per-item retry) and the
About window's content (version, check for updates) so it's all in
one place.

8 new OutlineKit tests covering coalescing, cancel-out, flush
success/failure, and manual offline mode — 51/51 passing.
2026-08-15 00:12:05 +01:00
Puranjay Savar Mattas eee00197bb feat(offline): read-through cache for browse endpoints + offline badge
CachingOutlineAPIClient (OutlineKit) wraps LiveOutlineAPIClient at the
existing protocol boundary: always tries live, falls back to a SwiftData
key/value cache only on failure, and overwrites the cache on every live
success. Applies to the read/browse path only (collections, document
lists, document content) — search, writes, and sharing pass straight
through uncached. Wired in once at SessionStore.makeAPIClient, so no
view model needed to change. 5 new tests via a stub OutlineAPIClient
(43/43 passing).

NetworkMonitor (NWPathMonitor-backed, app target) is unrelated to the
cache's own fallback logic — it only drives a new sidebar OfflineBanner
so the user knows when they might be looking at stale content.
2026-08-14 23:41:00 +01:00
Puranjay Savar Mattas b28dc019aa Merge pull request 'Document sharing: fix decode failures, replace broken Published toggle with real permissions' (#5) from fix/document-sharing into main
Reviewed-on: #5
2026-08-14 22:13:02 +01:00
Puranjay Savar Mattas a870e99404 chore: bump version to 0.0.2 2026-08-14 22:08:19 +01:00
Puranjay Savar Mattas 9ca1c77bb9 refactor(share): popover instead of modal sheet, redesigned layout
Share button now opens a popover anchored to the toolbar icon instead
of a full modal window. Redesigned the content as a narrow vertical
card (icon section headers, avatar-initial rows for members, link
card with collapsible title override) sized to fit the People section
without scrolling in the common case.
2026-08-14 22:06:04 +01:00
Puranjay Savar Mattas 7216633299 feat(share): drop broken Published toggle, add real document permissions
Flipping "Published" 403'd with authorization_error, confirmed via a
raw curl (bypassing our client entirely, real token) to be a genuine
server-side restriction independent of this app — workspace public
sharing is enabled, token is full-scope, it's not document-specific.
Root cause is most likely Outline gating that action behind an
interactive session rather than API-token auth, but that's not
definitively confirmed server-side. Removed the toggle per explicit
instruction; kept link create/copy/revoke and title override (same
endpoint, not reported broken).

Replaced it with real per-document user permissions:
- OutlineMembership/OutlineDocumentMember models
- documents.add_user (confirmed shape from official docs),
  documents.remove_user/documents.users (speculative, same "best-effort
  until a live server confirms" treatment OutlinePin originally got),
  users.list for the invite search (standard, high-confidence)
- DocumentShareSheet gets a "People with access" section: search and
  invite with a Can-view/Can-edit picker, existing members listed with
  a remove button
- Reader toolbar's long-disabled "Permissions…" menu item now opens
  this same sheet instead of doing nothing

Sidebar's own disabled "Permissions…" stub has no sheet wired up to it
yet — comment updated to be accurate, not fixed (use the reader's menu
instead). documents.users/documents.remove_user are unverified against
a live server, same as every other speculative endpoint this session —
expect a correction round once tested.
2026-08-14 20:18:03 +01:00
Puranjay Savar Mattas 013df89b4f fix(shares): shares.info wraps the share in {shares: [...]}, not bare
Empty-body handling fixed "no share yet" but re-opening the share
sheet for a document that already had one still errored — confirmed
via a raw response capture that data is { shares: [...] }, a
one-element array, not the bare share object the docs show. Same
pattern as pins.list.

Added SharesInfoPayload (mirrors PinsListPayload), shareInfo now takes
.shares.first. createShare/updateShare are unaffected — the user's
earlier successful create-and-copy confirms those aren't wrapped this
way, only shares.info. Test rewritten with the exact captured payload.
2026-08-14 17:55:07 +01:00
Puranjay Savar Mattas 1476c6c6ba fix(shares): treat shares.info's empty-body response as no-share, not an error
The full-shape fix wasn't the actual bug — confirmed live that
shares.info returns HTTP 200 with a completely empty body (not a 404,
not {data: null}) when no share exists yet for a document. post(_:)
assumed any 2xx had a non-empty envelope to decode, so this hit
JSONDecoder with zero bytes and threw "not valid JSON" on every first
share-sheet open.

Added postOptional(_:body:), mirroring post/postForSuccess, that
treats an empty body the same as a 404: nil, not a decode failure.
shareInfo calls it directly instead of wrapping post() with a
notFound-only catch.
2026-08-14 17:49:37 +01:00
Puranjay Savar Mattas 43f9d6052f fix(shares): match documented API shape, fill in list/revoke, harden share sheet
Share sheet errored "Got an unexpected response from the server" (a
client-side decode failure) on every open. OutlineShare only declared
5 fields against the real response's ~20, with url non-optional —
something in a real payload came back null and blew up the strict
decode, same class of bug as OutlinePin's history: self-hosted
responses keep diverging from what the hosted-app docs imply is
non-nullable.

- Rewrote OutlineShare to match the full documented shares.* response
  shape. Only id/published are trusted non-optional; everything else
  (documentTitle, sourceTitle, urlId, domain, title, iconUrl,
  includeChildDocuments, allowSubscriptions, allowIndexing,
  showLastUpdated, showTOC, views, createdBy, createdAt, updatedAt,
  lastAccessedAt) is optional so an unexpectedly-null field can't crash
  the decode again.
- shares.list and shares.revoke were never wrapped at all — added both.
- UpdateShareRequest was missing the documented title/iconUrl overrides.
- DocumentShareSheet: handles share.url being optional, adds a
  public-page title override field, adds a Revoke Link button
  (confirmation dialog, resets back to "Create Share Link" after).
- Removed dead DocumentReaderViewModel.share/loadShare()/
  createOrLoadShare() — never called by anything; DocumentShareSheet
  manages its own share state independently.
- Added a decode test using the exact payload from Outline's official
  shares.info docs, plus tests for shares.list and shares.revoke.

Branched fresh off main (post home-page merge) rather than continuing
on feature/home-page, to keep this its own PR.
2026-08-14 17:41:28 +01:00
Puranjay Savar Mattas db59e3fbe5 Merge pull request 'Add Home page, universal New Document dialog, and fix Pin/toggle/CODEOWNERS gaps found in live testing' (#4) from feature/home-page into main
Reviewed-on: #4
2026-08-14 17:23:26 +01:00
Puranjay Savar Mattas 8ce0804c67 fix(home): remove dead search icon, add staleness polling and retry
Three parity gaps found reviewing Home against CollectionOverviewView:

- The toolbar's contextual search icon rendered on Home but
  contextualSearchQuery is only ever read by CollectionOverviewView —
  clicking it and typing did nothing. Hidden specifically on the Home
  landing page per explicit request (sidebar's global search already
  covers this); goHome() also clears the stale query/expanded state so
  it can't leak back in when returning to a collection.
- Home had no periodic remote-changes check, unlike CollectionsTreeView
  and CollectionOverviewView. Added the same 45s-poll + banner pattern,
  fingerprinting pinned docs and the current tab's docs against fresh
  fetches; bails silently on a fetch failure instead of false-positive
  triggering the banner.
- Tab load errors showed a message but no way to retry short of
  switching tabs and back. Added a Retry button matching the collection
  document list's.

Also hardens CODEOWNERS ahead of going public: kept `* @psmattas` as
the catch-all but pinned supply-chain/governance/CI paths (Package
manifests, .gitea/, xcodeproj build settings, scripts/, LICENSE,
CONTRIBUTING/SECURITY/SETUP, CLAUDE.md, docs/ARCHITECTURE.md)
explicitly to @psmattas so they stay owner-gated even if `*` opens up
to other contributors later.
2026-08-14 17:19:07 +01:00
Puranjay Savar Mattas 9b429b2e00 Merge branch 'main' into feature/home-page
# Conflicts:
#	Outpost/Features/Collections/CollectionDocumentsOutline.swift
#	Outpost/Features/Collections/CollectionsTreeView.swift
#	Outpost/Features/Collections/ContentView_macOS.swift
#	Outpost/Features/Collections/DocumentReaderView.swift
2026-08-14 17:10:12 +01:00
Puranjay Savar Mattas 1b6aede326 Merge pull request 'Fix Pin, Subscribe, sidebar toggles, and stale-sidebar bugs found in live testing' (#3) from chore/verify-pins-subscriptions-api into main
Reviewed-on: #3
2026-08-14 17:02:31 +01:00
Puranjay Savar Mattas 1096967645 feat(sidebar): add a Home row above the collections list
Home was only reachable via the toolbar house icon — added a pinned
row at the top of the sidebar (matching a collection row's style,
highlighted when active) so it's a first-class nav target alongside
collections rather than toolbar-only.
2026-08-14 16:52:34 +01:00
Puranjay Savar Mattas ed9fd26c85 fix(sidebar): auto-refresh after creating a doc from the reader toolbar
The reader's inline "New Document" button (createChildDocument())
only navigated to the new document — no handle on the sidebar row it
landed under, so the tree stayed stale until the periodic
remote-changes poll surfaced the reload banner.

Right-click "New Document" on a collection already refreshed correctly
(bumps its own documentsRefreshToken) and is untouched.

Threads a documentsChangedToken from ContentView_macOS down through
CollectionsTreeView -> CollectionTreeRow -> CollectionDocumentsOutline
as externalRefreshToken; every expanded row reloads itself when it
bumps, since the reader doesn't know which row (if any) corresponds to
where the new document landed.
2026-08-14 16:50:09 +01:00
Puranjay Savar Mattas c2b41ca960 fix(sidebar): auto-refresh after creating a doc from Home or the reader toolbar
The reader's inline "New Document" button and Home's New Document sheet
only navigated to the new document — neither had a handle on the
sidebar row it landed under, so the tree stayed stale until the
periodic remote-changes poll (45s) surfaced the "reload" banner.

New Document flows that already have a direct handle on their own
sidebar row (right-click a collection, right-click a document) already
refreshed correctly and are untouched.

Threads a documentsChangedToken from ContentView_macOS down through
CollectionsTreeView -> CollectionTreeRow -> CollectionDocumentsOutline
as externalRefreshToken; every expanded row reloads itself when it
bumps, since neither Home nor the reader knows which row (if any)
corresponds to where the new document landed.
2026-08-14 16:46:40 +01:00
Puranjay Savar Mattas b45d72238c fix(reader): real toggle checkmarks for Subscribed/Viewer Insights/Full Width
This branch never got the toggle-menu fixes that landed on
chore/verify-pins-subscriptions-api — Subscribed/Viewer Insights/Full
Width were plain Buttons with no on/off indicator, and the menu didn't
force a rebuild on state change, so even a real Toggle would've shown a
stale checkmark until the view was torn down and rebuilt.

Ported that branch's fixes: Subscribed/Viewer Insights/Full Width are
now real Toggle views bound to observable state; the overflow Menu is
keyed to a .id() built from every toggle-backed state so SwiftUI
actually re-evaluates the checkmarks; viewer avatars are gated on
isInsightsEnabled so they hide immediately when insights are turned
off. Also dropped the dead documentEmbeds field (confirmed no
per-document embeds endpoint exists) — Enable Embeds is a disabled
button with an explanation, matching the other branch.
2026-08-14 16:41:20 +01:00
Puranjay Savar Mattas ad3e35e28e fix(pins): decode pins.list correctly, distinguish Pin to Home vs Collection
Same root-cause fix as feature/home-page, applied to this branch's
copy of the pin code (which additionally has the sidebar's real
per-collection Pin wiring):

- pins.list's real response is {data: {pins: [...], documents: [...]}},
  not a bare array — confirmed against a live server. Decoding straight
  to [OutlinePin] threw every call; try? swallowed it, so pins never
  showed up (even a doc pinned for real via the web app).
- "Pin to Home" (web's actual label) sends collectionId: null; "Pin to
  Collection" sends a real id — distinct actions. The reader toolbar's
  Pin was sending the doc's own collectionId under a plain "Pin" label,
  silently doing the wrong one. Fixed to nil, relabeled "Pin to Home".
- Sidebar's per-document Pin was already correctly scoped to
  collection.id — relabeled "Pin to Collection" for clarity, no logic
  change.
2026-08-14 16:36:07 +01:00
Puranjay Savar Mattas c1810f38f1 fix(pins): decode pins.list correctly, use collectionId:nil for Pin to Home
pins.list's real response is {data: {pins: [...], documents: [...]}},
not a bare array — confirmed against a live server's network traffic.
Decoding straight to [OutlinePin] threw on every call, and call sites
swallow that with try?, so pins never showed up anywhere (including
docs already pinned via the real web app).

Also: the reader's Pin action was sending the document's own
collectionId, which is "Pin to Collection" — a different action from
"Pin to Home" (collectionId: null), which is what the web app's "Pin
to Home" menu item actually does and what the Home page's pinned
section filters for.
2026-08-14 16:32:59 +01:00
Puranjay Savar Mattas 23193034e6 fix(home): distinct pinned card style, matching tab bar, drop broken sort
- Pinned section uses a new dense PinnedDocumentCard (single-line,
  explicit pin glyph) instead of the same tall card as the tab grids -
  it's meant for a quick scan of a small curated set, not browsing,
  and needed to actually show a pin so pinned docs are recognizable
  at a glance.
- Tab bar now matches CollectionOverviewView.tabBar's exact style
  instead of the native segmented picker.
- Popular tab's sort: "viewCount" was a guess and the server rejected
  it outright ("sort: Invalid input") - sort is validated against a
  fixed set server-side, not free-form like the vendored spec's typing
  implies. Falls back to default order now, same conclusion already
  reached for CollectionTab.popular - no real popularity ranking is
  exposed via the REST API.
- Layout: pinned section now claims roughly the top half of the page
  (scrolling within itself if there are more pinned docs than fit)
  when there's anything pinned, collapsing away entirely otherwise so
  the tabs get full height.
2026-08-14 16:16:27 +01:00
Puranjay Savar Mattas 278e93ddeb fix(reader): tie viewer avatars to the Viewer Insights toggle
Turning off Viewer Insights didn't hide the avatar stack until the
view was torn down and rebuilt - that data belongs to the insights
feature, so gate it on isInsightsEnabled directly instead of only on
whether any viewers loaded.
2026-08-14 16:14:33 +01:00
Puranjay Savar Mattas 02023cf382 fix(reader): force menu rebuild so toggle checkmarks reflect state
Viewer Insights (and likely Subscribed/Full Width, same mechanism)
kept showing the pre-toggle checkmark in the overflow menu until the
whole view was torn down and rebuilt (navigate away and back) -
SwiftUI's macOS Menu doesn't reliably re-evaluate a Toggle's checkmark
against updated @Observable state on its own. Keying the Menu's .id()
to every toggle-backed state it displays forces a fresh rebuild
whenever any of them change.
2026-08-14 16:08:44 +01:00
Puranjay Savar Mattas c3083bf0c0 feat: Home page + universal New Document dialog
Home replaces "auto-select first collection" as the landing state -
new toolbar Home button, Home pill in the breadcrumb, and the sidebar
no longer picks a collection for you on launch.

Home page:
- Pinned docs as a card grid (pins.list -> per-document fetch, since
  the speculative pins.list response only carries pin records, not
  documents - N+1 is acceptable here since pins are a small curated
  set, unlike a full collection tree)
- Recently Viewed (documents.viewed), Recently Updated and Created by
  Me (documents.list with sort/direction/userId - new richer
  DocumentsListRequest alongside the existing simple listDocuments,
  left untouched for its callers), and Popular (best-effort sort:
  "viewCount" - no confirmed popularity key in the vendored spec,
  worth eyeballing against a real server)
- New Document button in Home's own toolbar

New Document is now one shared dialog (NewDocumentSheet, mirrors
MoveDocumentSheet's collection+parent picker) instead of three
separate call sites that silently created "Untitled" instantly:
sidebar collection's New Document, sidebar document's New Document,
and the reader's toolbar button + menu item all open it now,
pre-filled with whatever context they were opened from.

OutlineKit: documents.viewed, richer documents.list filtering, with
test coverage.
2026-08-14 16:03:17 +01:00
Puranjay Savar Mattas fa31cd707e fix(collections): wire up sidebar Pin, drop dead Embeds field, real toggles
Smoke-tested against a live server: Pin didn't work from the sidebar
context menu, Subscribe/Unsubscribe worked, Enable Embeds didn't.

- Sidebar document context menu still had the pre-pins.*-support
  disabled Pin/Unsubscribe stubs from before that endpoint existed -
  only the reader's menu got updated at the time. Wired up real Pin
  (collection-scoped pins.list loaded once per CollectionDocumentsOutline,
  not per row - avoids an N+1 call storm); left Unsubscribe disabled
  there since subscriptions.list is per-document, with an accurate
  comment pointing at the reader's menu instead.
- documentEmbeds confirmed not a real field - removed from
  UpdateDocumentRequest entirely rather than leave a menu item that
  silently no-ops.
- Subscribe, Viewer Insights, and Full Width are now real Toggle menu
  items (checkmark reflects actual state) instead of static
  action buttons. Viewer Insights state is inferred from whether
  documents.insights succeeds/fails, since insightsEnabled isn't
  readable back off Document - heuristic, flagged in code.
2026-08-14 15:43:14 +01:00
Puranjay Savar Mattas 9991302683 Merge pull request 'Add feature/question issue templates, ignore local checklist' (#2) from feature/macos-app into main
Reviewed-on: #2
2026-08-14 15:21:43 +01:00
Puranjay Savar Mattas d5183a1300 chore: ignore local working checklist
TODO.local.md is a scratch tracker for in-progress work, kept
deliberately out of version control - decisions worth keeping belong
in CLAUDE.md/docs/ARCHITECTURE.md instead.
2026-08-14 15:18:31 +01:00
Puranjay Savar Mattas b1e8eb958e docs: add feature request and question issue templates
Rounds out the issue template set (bug/docs/security already existed)
- feature requests point at CLAUDE.md's phased build order since most
useful requests here are "match what web Outline does", and questions
point at SETUP.md first since blank issues are disabled.
2026-08-14 15:12:18 +01:00
Puranjay Savar Mattas 8c35710521 docs: add community health files and Gitea issue/PR templates
CODEOWNERS, CONTRIBUTING.md, SECURITY.md, SETUP.md, and the .gitea
issue/PR templates, rewritten for Outpost (they started as copies from
an unrelated project's templates - stripped the cross-repo/ticket-ID
conventions and the entirely different tech stack in SETUP.md,
replaced with this repo's actual submodule/OutlineKit/Xcode workflow).
2026-08-14 04:06:46 +01:00
Puranjay Savar Mattas 3e7d8097ad fix(scripts): correctly detect an annotated tag at HEAD in changelog range
git rev-parse on an annotated tag (git tag -a, what the release process
uses) returns the tag object's hash, not the commit hash it points to
- so the HEAD == tag comparison never matched, and the script always
fell into the "not tagged yet" branch. Fixed by peeling the tag down
to its commit with ^{commit}. Verified against a real annotated tag:
now correctly shows "Changelog (<tag>)" with actual content instead
of an empty "since <tag>" section.

Also: untrack Outpost.xcodeproj/xcuserdata (already gitignored, but
was committed before that rule existed, so Xcode kept dirtying it and
blocking pulls - including the one that just happened) and ignore
scripts/package-dmg.sh's dist/ output.
2026-08-14 03:44:47 +01:00
93 changed files with 7262 additions and 369 deletions
+43
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+30
View File
@@ -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
+20
View File
@@ -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.
+38
View File
@@ -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)
+6
View File
@@ -60,3 +60,9 @@ fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
# scripts/package-dmg.sh output
/dist/
# Local working checklist - intentionally never version controlled
/TODO.local.md
+37
View File
@@ -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
+71
View File
@@ -0,0 +1,71 @@
# Contributing to Outpost
Thank you for contributing. Please read this guide before opening issues or PRs.
Outpost is early alpha (`0.0.x`) — expect the codebase and conventions here to shift as Phase 1 (see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)) settles. If something in this guide is stale, flag it.
---
## Branching
Branch from `main` using `type/short-description`:
```bash
git checkout -b feature/document-permissions
git checkout -b fix/sidebar-context-menu
```
---
## Commit Messages
Follow the Conventional Commits standard: `type(scope): message`.
| Type | Description |
| :--- | :--- |
| `feat` | New feature |
| `fix` | Bug fix |
| `docs` | Documentation |
| `style` | Formatting |
| `refactor` | Refactor |
| `perf` | Performance |
| `test` | Tests |
| `build` | Build system |
| `ci` | CI/CD config |
| `chore` | Maintenance |
**Examples:**
- `feat(collections): add document right-click context menu`
- `fix(reader): correct off-main AppKit calls in save action`
---
## Pull Requests
- Link the related issue in your PR description, if any
- Keep PRs focused — one feature or fix per PR
- Self-review before requesting review
- Run the tests that apply to what you touched (see below) and confirm the app still launches and behaves correctly in Xcode
### Testing
- **`OutlineKit`** (the REST client package) has real unit test coverage:
```bash
cd OutlineKit && swift test
```
- **The `Outpost` app target** has no meaningful CLI build path — `xcodebuild` from the command line is not a reliable way to verify it in this project's current setup. Build and run through Xcode, and manually verify the feature you changed (and anything obviously adjacent) before opening a PR.
---
## Labels
Issues and PRs use two label prefixes:
- `type:` — what kind of change/issue this is (`type: bug`, `type: docs`, `type: security`, ...)
- `priority:` — how urgent it is (used mainly for security reports)
---
## Questions
Open an issue — this is a single-repo project, there's no separate issue tracker to route to.
@@ -0,0 +1,18 @@
import Foundation
import SwiftData
/// Generic key/value row backing `OfflineCacheStore` one table for every
/// cached response shape instead of a `@Model` per Outline type, so adding a
/// new cached endpoint never needs a schema migration.
@Model
public final class CachedPayload {
@Attribute(.unique) public var key: String
public var payload: Data
public var cachedAt: Date
public init(key: String, payload: Data, cachedAt: Date) {
self.key = key
self.payload = payload
self.cachedAt = cachedAt
}
}
@@ -0,0 +1,721 @@
import Foundation
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
/// support at the existing protocol boundary, so no view model needs to know
/// the network exists:
///
/// - **Reads** (`documentInfo`, `listDocuments`, `documentsList`,
/// `listViewedDocuments`, `listCollections`, `collectionInfo`): read-through
/// cache. Always tries live first never pre-checks reachability, since
/// "did this specific request just fail" is a more honest signal than a
/// reachability monitor, and the two can disagree (captive portals,
/// flaky Wi-Fi). Falls back to the cache only on failure; a live success
/// always overwrites the cache, so staleness is bounded by "last time this
/// endpoint actually worked."
/// - **A small set of writes** (`updateDocument`, `updateCollection`,
/// pin/star/subscribe create+delete): applied optimistically against the
/// cache and queued in `OfflineCacheStore`'s `PendingOperation` table on
/// failure, then replayed in order by `flushPendingOperations()` once back
/// online. Deliberately doesn't include anything that creates new tree
/// structure (`createDocument`, `moveDocument`, `archiveDocument`,
/// `deleteDocument`, `deleteCollection`, `duplicateDocument`) those need
/// real server-assigned ids to stay consistent with the rest of the tree,
/// and reconciling a locally-invented id with the one the server hands
/// back on sync is a much bigger problem than this pass takes on. Sharing,
/// permissions, search, and export also stay live-only read the request,
/// they need someone else's server session, not just a network.
/// - **Manual offline mode** (`offlineModeDefaultsKey`): when set, skips
/// attempting `live` entirely same fallback/queue paths as a real
/// failure, just chosen on purpose instead of discovered.
public actor CachingOutlineAPIClient: OutlineAPIClient {
public static let offlineModeDefaultsKey = "outpost.offlineModeEnabled"
private let live: OutlineAPIClient
private let cache: OfflineCacheStore
private let defaults: UserDefaults
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let keyEncoder: JSONEncoder
public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) {
self.live = live
self.cache = cache
self.defaults = defaults
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
self.encoder = encoder
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
self.decoder = decoder
let keyEncoder = JSONEncoder()
keyEncoder.outputFormatting = .sortedKeys
self.keyEncoder = keyEncoder
}
private var isManualOfflineModeEnabled: Bool {
defaults.bool(forKey: Self.offlineModeDefaultsKey)
}
// MARK: - Cached reads
public func documentInfo(id: String) async throws -> OutlineDocument {
try await cachedFetch(key: "document:\(id)") { try await self.live.documentInfo(id: id) }
}
public func listDocuments(
collectionId: String?,
parentDocumentId: String?,
offset: Int,
limit: Int
) async throws -> [OutlineDocument] {
let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)"
return try await cachedFetch(key: key) {
try await self.live.listDocuments(
collectionId: collectionId,
parentDocumentId: parentDocumentId,
offset: offset,
limit: limit
)
}
}
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
try await cachedFetch(key: requestKey("documentsList", request)) { try await self.live.documentsList(request) }
}
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
try await cachedFetch(key: "documentsViewed:\(offset):\(limit)") {
try await self.live.listViewedDocuments(offset: offset, limit: limit)
}
}
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
try await cachedFetch(key: "collections:\(offset):\(limit)") {
try await self.live.listCollections(offset: offset, limit: limit)
}
}
public func collectionInfo(id: String) async throws -> OutlineCollection {
try await cachedFetch(key: "collection:\(id)") { try await self.live.collectionInfo(id: id) }
}
// MARK: - Queueable writes
/// The one exception to "no new tree structure while offline"
/// synthesizes a document under a `pending-<uuid>` id (same scheme as
/// pin/star/subscribe), caches it so it's immediately readable/editable,
/// and queues the real create. On sync, the server-assigned id replaces
/// the placeholder in the cache; anything still referencing the old id
/// directly (a currently-open reader, most likely) won't follow that
/// rename automatically a known, narrow limitation, not something this
/// pass tries to solve generally.
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
if !isManualOfflineModeEnabled {
do {
let result = try await live.createDocument(request)
await cacheDocument(result)
return result
} catch {
return await queueDocumentCreate(request)
}
}
return await queueDocumentCreate(request)
}
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
if !isManualOfflineModeEnabled {
do {
let result = try await live.updateDocument(request)
await cacheDocument(result)
return result
} catch {
return try await queueDocumentUpdate(request, dueTo: error)
}
}
return try await queueDocumentUpdate(request, dueTo: nil)
}
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
if !isManualOfflineModeEnabled {
do {
let result = try await live.updateCollection(request)
await cacheCollection(result)
return result
} catch {
return try await queueCollectionUpdate(request, dueTo: error)
}
}
return try await queueCollectionUpdate(request, dueTo: nil)
}
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
if !isManualOfflineModeEnabled {
do { return try await live.createPin(request) } catch { return await queuePinCreate(request) }
}
return await queuePinCreate(request)
}
public func deletePin(id: String) async throws {
if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled {
do {
try await live.deletePin(id: id)
return
} catch {
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
return
}
}
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
}
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
if !isManualOfflineModeEnabled {
do { return try await live.createSubscription(request) } catch { return await queueSubscriptionCreate(request) }
}
return await queueSubscriptionCreate(request)
}
public func deleteSubscription(id: String) async throws {
if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled {
do {
try await live.deleteSubscription(id: id)
return
} catch {
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
return
}
}
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
}
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
if !isManualOfflineModeEnabled {
do { return try await live.starDocument(request) } catch { return await queueStarDocumentCreate(request) }
}
return await queueStarDocumentCreate(request)
}
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
if !isManualOfflineModeEnabled {
do { return try await live.starCollection(request) } catch { return await queueStarCollectionCreate(request) }
}
return await queueStarCollectionCreate(request)
}
public func deleteStar(id: String) async throws {
if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled {
do {
try await live.deleteStar(id: id)
return
} catch {
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
return
}
}
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
}
// MARK: - Pass-through
public func authInfo() async throws -> OutlineAuthInfo {
try await live.authInfo()
}
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
try await live.searchDocuments(request)
}
public func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] {
try await live.searchDocumentTitles(request)
}
public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate {
try await live.templatizeDocument(request)
}
public func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] {
try await live.duplicateDocument(request)
}
public func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument {
try await live.unpublishDocument(request)
}
public func archiveDocument(id: String) async throws -> OutlineDocument {
try await live.archiveDocument(id: id)
}
public func moveDocument(_ request: MoveDocumentRequest) async throws {
try await live.moveDocument(request)
}
public func deleteDocument(_ request: DeleteDocumentRequest) async throws {
try await live.deleteDocument(request)
}
public func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] {
try await live.documentInsights(request)
}
public func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] {
try await live.listRevisions(request)
}
public func exportDocument(id: String) async throws -> String {
try await live.exportDocument(id: id)
}
public func createShare(_ request: CreateShareRequest) async throws -> OutlineShare {
try await live.createShare(request)
}
public func shareInfo(documentId: String) async throws -> OutlineShare? {
try await live.shareInfo(documentId: documentId)
}
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
try await live.updateShare(request)
}
public func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] {
try await live.listShares(request)
}
public func revokeShare(id: String) async throws {
try await live.revokeShare(id: id)
}
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
try await live.listPins(request)
}
public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] {
try await live.listSubscriptions(request)
}
public func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] {
try await live.listViews(request)
}
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
try await live.addDocumentUser(request)
}
public func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws {
try await live.removeDocumentUser(request)
}
public func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] {
try await live.documentUsers(request)
}
public func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] {
try await live.listUsers(request)
}
public func deleteCollection(id: String) async throws {
try await live.deleteCollection(id: id)
}
public func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation {
try await live.exportCollection(request)
}
public func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] {
try await live.listStars(request)
}
public func currentUser() async throws -> OutlineUser {
try await live.currentUser()
}
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
try await live.createAttachment(request)
}
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
try await live.uploadAttachmentFile(result, fileData: fileData)
}
public func deleteAttachment(id: String) async throws {
try await live.deleteAttachment(id: id)
}
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
try await live.updateUserAvatar(request)
}
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
try await live.updateUserName(request)
}
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
try await live.updateUserLanguage(request)
}
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
try await live.updateUserPreferences(request)
}
public func deleteAccount() async throws {
try await live.deleteAccount()
}
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
try await live.subscribeToNotifications(eventType: eventType)
}
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
try await live.unsubscribeFromNotifications(eventType: eventType)
}
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
try await live.listApiKeys(request)
}
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
try await live.createApiKey(request)
}
public func deleteApiKey(id: String) async throws {
try await live.deleteApiKey(id: id)
}
public func installationInfo() async throws -> OutlineInstallationInfo {
try await live.installationInfo()
}
// MARK: - Sync management (Settings surface)
public func pendingOperations() async -> [PendingOperationSummary] {
await cache.pendingOperations().map {
PendingOperationSummary(id: $0.id, kind: $0.kind, createdAt: $0.createdAt, attemptCount: $0.attemptCount, lastError: $0.lastError)
}
}
public func cacheStorageSummary() async -> CacheStorageSummary {
CacheStorageSummary(itemCount: await cache.itemCount(), totalBytes: await cache.totalBytes())
}
public func clearCache() async {
await cache.clearAll()
}
/// Replays every queued operation against `live`, in the order they were
/// queued. Each is independent one failing doesn't block the rest.
public func flushPendingOperations() async -> SyncFlushSummary {
let operations = await cache.pendingOperations()
var succeeded = 0
var failed = 0
for operation in operations {
do {
try await replay(operation)
await cache.removeOperation(id: operation.id)
succeeded += 1
} catch {
await cache.recordFailure(id: operation.id, error: errorDescription(error))
failed += 1
}
}
return SyncFlushSummary(succeeded: succeeded, failed: failed)
}
/// "Full Local Sync": eagerly walks every collection and caches every
/// document's full content (not just whatever's been opened), so
/// browsing offline works for the whole workspace, not only what was
/// already viewed. Goes through `self`, not `live`, directly the
/// existing cached-read methods already do the caching as a side effect,
/// this just has to drive the walk and separately cache each document by
/// id (`listDocuments`'s cache key is the list, not the individual doc).
public func performFullSync() async -> FullSyncSummary {
var documentsCount = 0
var errors: [String] = []
var collections: [OutlineCollection] = []
var collectionsOffset = 0
let collectionsLimit = 100
// Outline rejects any `limit` over 100 outright page in increments
// of that instead of guessing a total up front (the protocol doesn't
// expose `pagination`'s total count, only the page itself); a page
// shorter than the limit is what signals "that was the last one".
while true {
let page: [OutlineCollection]
do {
page = try await listCollections(offset: collectionsOffset, limit: collectionsLimit)
} catch {
errors.append(errorDescription(error))
break
}
collections.append(contentsOf: page)
guard page.count == collectionsLimit else { break }
collectionsOffset += collectionsLimit
}
for collection in collections {
var offset = 0
let limit = 100
while true {
let documents: [OutlineDocument]
do {
documents = try await listDocuments(collectionId: collection.id, parentDocumentId: nil, offset: offset, limit: limit)
} catch {
errors.append("\(collection.name): \(errorDescription(error))")
break
}
for document in documents {
await cacheDocument(document)
}
documentsCount += documents.count
guard documents.count == limit else { break }
offset += limit
}
}
return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date())
}
// MARK: - Helpers
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
// Manual offline mode means "skip the network entirely," not just
// "prefer it" without this check, a read would still hit `live`
// (and succeed, showing content beyond whatever's cached) any time
// the device actually had a connection, defeating the point of
// deliberately testing/working as if offline.
if isManualOfflineModeEnabled {
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
return cached
}
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
}
do {
let result = try await fetch()
if let data = try? encoder.encode(result) {
await cache.save(data, forKey: key)
}
return result
} catch {
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
return cached
}
throw error
}
}
private func requestKey(_ prefix: String, _ request: some Encodable) -> String {
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
return prefix
}
return "\(prefix):\(json)"
}
private func cacheDocument(_ document: OutlineDocument) async {
if let data = try? encoder.encode(document) {
await cache.save(data, forKey: "document:\(document.id)")
}
}
private func cacheCollection(_ collection: OutlineCollection) async {
if let data = try? encoder.encode(collection) {
await cache.save(data, forKey: "collection:\(collection.id)")
}
}
private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async {
guard let data = try? encoder.encode(payload) else { return }
await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data)
}
/// A delete targeting a `pending-*` id can only mean "cancel the create
/// still sitting in the queue" the server has never heard of that id,
/// so queuing the delete would just fail once synced. Returns whether it
/// found (and removed) a matching create, meaning the caller is done.
private func cancelIfNeverSynced(id: String) async -> Bool {
guard id.hasPrefix("pending-") else { return false }
return await cache.removeOperation(id: id)
}
private func queueDocumentCreate(_ request: CreateDocumentRequest) async -> OutlineDocument {
let pendingId = "pending-\(UUID().uuidString)"
let now = Date()
let synthesized = OutlineDocument(
id: pendingId,
title: request.title,
text: request.text,
emoji: nil,
collectionId: request.collectionId,
parentDocumentId: request.parentDocumentId,
url: "/doc/\(pendingId)",
revision: nil,
fullWidth: nil,
createdAt: now,
updatedAt: now,
publishedAt: request.publish ? now : nil,
archivedAt: nil,
deletedAt: nil
)
await cacheDocument(synthesized)
await enqueue(.createDocument, payload: request, id: pendingId)
return synthesized
}
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
guard let baseData = await cache.load(forKey: "document:\(request.id)"),
let base = try? decoder.decode(OutlineDocument.self, from: baseData) else {
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
}
let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text)
let merged = OutlineDocument(
id: base.id,
title: request.title ?? base.title,
text: mergedText,
emoji: base.emoji,
collectionId: base.collectionId,
parentDocumentId: base.parentDocumentId,
url: base.url,
revision: base.revision,
fullWidth: request.fullWidth ?? base.fullWidth,
createdAt: base.createdAt,
updatedAt: Date(),
publishedAt: base.publishedAt,
archivedAt: base.archivedAt,
deletedAt: base.deletedAt
)
await cacheDocument(merged)
// Still-unsynced create for this exact document fold the edit into
// the pending create's payload instead of queuing a separate update.
// A separate update would target `merged.id`, which is still the
// `pending-*` placeholder at replay time; the server has never heard
// of it and the update would just fail every retry.
if merged.id.hasPrefix("pending-"),
let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }),
let createRequest = try? decoder.decode(CreateDocumentRequest.self, from: createOp.payload) {
let resolvedCreate = CreateDocumentRequest(
title: merged.title,
text: merged.text,
collectionId: createRequest.collectionId,
parentDocumentId: createRequest.parentDocumentId,
publish: createRequest.publish
)
await enqueue(.createDocument, payload: resolvedCreate, id: merged.id)
return merged
}
// Fully resolved (not the original partial request) so replaying just
// this one queued operation reproduces `merged` exactly that's what
// makes coalescing a second edit onto the same queued id safe.
let resolved = UpdateDocumentRequest(id: merged.id, title: merged.title, text: merged.text, fullWidth: merged.fullWidth)
await enqueue(.updateDocument, payload: resolved, id: "update-document-\(merged.id)")
return merged
}
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
guard let baseData = await cache.load(forKey: "collection:\(request.id)"),
let base = try? decoder.decode(OutlineCollection.self, from: baseData) else {
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
}
let merged = OutlineCollection(
id: base.id,
name: request.name ?? base.name,
description: request.description ?? base.description,
color: base.color,
icon: base.icon,
createdAt: base.createdAt,
updatedAt: Date()
)
await cacheCollection(merged)
let resolved = UpdateCollectionRequest(id: merged.id, name: merged.name, description: merged.description)
await enqueue(.updateCollection, payload: resolved, id: "update-collection-\(merged.id)")
return merged
}
private func queuePinCreate(_ request: CreatePinRequest) async -> OutlinePin {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.createPin, payload: request, id: pendingId)
return OutlinePin(id: pendingId, documentId: request.documentId, collectionId: request.collectionId, index: nil)
}
private func queueSubscriptionCreate(_ request: CreateSubscriptionRequest) async -> OutlineSubscription {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.createSubscription, payload: request, id: pendingId)
return OutlineSubscription(id: pendingId, documentId: request.documentId, collectionId: nil, event: request.event)
}
private func queueStarDocumentCreate(_ request: StarDocumentRequest) async -> OutlineStar {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.starDocument, payload: request, id: pendingId)
return OutlineStar(id: pendingId, index: nil, documentId: request.documentId, collectionId: nil)
}
private func queueStarCollectionCreate(_ request: StarCollectionRequest) async -> OutlineStar {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.starCollection, payload: request, id: pendingId)
return OutlineStar(id: pendingId, index: nil, documentId: nil, collectionId: request.collectionId)
}
private func replay(_ operation: PendingOperation) async throws {
guard let kind = PendingOperationKind(rawValue: operation.kind) else {
throw OutlineAPIError.decoding(DecodingError.dataCorrupted(
DecodingError.Context(codingPath: [], debugDescription: "Unknown pending operation kind: \(operation.kind)")
))
}
switch kind {
case .createDocument:
let request = try decoder.decode(CreateDocumentRequest.self, from: operation.payload)
let result = try await live.createDocument(request)
await cacheDocument(result)
// The placeholder id (== operation.id) is now a dead orphan
// nothing server-side will ever answer to it again.
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
case .updateDocument:
let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload)
let result = try await live.updateDocument(request)
await cacheDocument(result)
case .updateCollection:
let request = try decoder.decode(UpdateCollectionRequest.self, from: operation.payload)
let result = try await live.updateCollection(request)
await cacheCollection(result)
case .createPin:
let request = try decoder.decode(CreatePinRequest.self, from: operation.payload)
_ = try await live.createPin(request)
case .deletePin:
let request = try decoder.decode(IDPayload.self, from: operation.payload)
try await live.deletePin(id: request.id)
case .createSubscription:
let request = try decoder.decode(CreateSubscriptionRequest.self, from: operation.payload)
_ = try await live.createSubscription(request)
case .deleteSubscription:
let request = try decoder.decode(IDPayload.self, from: operation.payload)
try await live.deleteSubscription(id: request.id)
case .starDocument:
let request = try decoder.decode(StarDocumentRequest.self, from: operation.payload)
_ = try await live.starDocument(request)
case .deleteStar:
let request = try decoder.decode(IDPayload.self, from: operation.payload)
try await live.deleteStar(id: request.id)
case .starCollection:
let request = try decoder.decode(StarCollectionRequest.self, from: operation.payload)
_ = try await live.starCollection(request)
}
}
private func errorDescription(_ error: Error) -> String {
if let apiError = error as? OutlineAPIError {
switch apiError {
case .unauthorized: return "Sign-in expired."
case .notFound: return "Not found on the server."
case .server(let status, let message): return message ?? "Server error (\(status))."
case .decoding: return "Unexpected response shape."
case .transport: return "Couldn't reach the server."
case .tokenUnavailable: return "Couldn't access the saved sign-in."
}
}
return String(describing: error)
}
}
@@ -0,0 +1,104 @@
import Foundation
import SwiftData
/// Read-through cache backing `CachingOutlineAPIClient` stores whatever it
/// last fetched successfully, keyed by request shape, so browsing keeps
/// working (stale) once the network stops. Also owns the offline write
/// queue (`PendingOperation`) same store, same actor, since both need
/// synchronized access to the same on-disk SwiftData container.
@ModelActor
public actor OfflineCacheStore {
public static func makeContainer(inMemory: Bool = false) throws -> ModelContainer {
let configuration = ModelConfiguration(isStoredInMemoryOnly: inMemory)
return try ModelContainer(for: CachedPayload.self, PendingOperation.self, configurations: configuration)
}
// MARK: - Read-through cache
public func save(_ data: Data, forKey key: String) {
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
if let existing = try? modelContext.fetch(descriptor).first {
existing.payload = data
existing.cachedAt = Date()
} else {
modelContext.insert(CachedPayload(key: key, payload: data, cachedAt: Date()))
}
try? modelContext.save()
}
public func load(forKey key: String) -> Data? {
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
return try? modelContext.fetch(descriptor).first?.payload
}
/// Used to drop a temporary `pending-*` document's cache entry once a
/// queued create syncs and the server hands back the real id the
/// placeholder key would otherwise sit around as a dead orphan forever.
public func removeCacheEntry(forKey key: String) {
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
guard let existing = try? modelContext.fetch(descriptor).first else { return }
modelContext.delete(existing)
try? modelContext.save()
}
public func itemCount() -> Int {
(try? modelContext.fetchCount(FetchDescriptor<CachedPayload>())) ?? 0
}
public func totalBytes() -> Int {
let descriptor = FetchDescriptor<CachedPayload>()
let rows = (try? modelContext.fetch(descriptor)) ?? []
return rows.reduce(0) { $0 + $1.payload.count }
}
public func clearAll() {
let descriptor = FetchDescriptor<CachedPayload>()
guard let rows = try? modelContext.fetch(descriptor) else { return }
rows.forEach { modelContext.delete($0) }
try? modelContext.save()
}
// MARK: - Offline write queue
/// Upserts by `id` a second call with the same id (an edit coalescing
/// onto a still-unsynced edit, or a create being cancelled by its own
/// synthesized id) replaces the row in place rather than piling up.
public func enqueueOperation(id: String, kind: String, payload: Data) {
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
if let existing = try? modelContext.fetch(descriptor).first {
existing.kind = kind
existing.payload = payload
existing.lastError = nil
existing.attemptCount = 0
} else {
modelContext.insert(PendingOperation(id: id, kind: kind, payload: payload, createdAt: Date()))
}
try? modelContext.save()
}
public func pendingOperations() -> [PendingOperation] {
let descriptor = FetchDescriptor<PendingOperation>(sortBy: [SortDescriptor(\.createdAt)])
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Returns whether a matching operation was actually found and removed
/// callers use this to detect "this targeted something that never made
/// it to the server in the first place" and skip queuing a delete.
@discardableResult
public func removeOperation(id: String) -> Bool {
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
guard let existing = try? modelContext.fetch(descriptor).first else { return false }
modelContext.delete(existing)
try? modelContext.save()
return true
}
public func recordFailure(id: String, error: String) {
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
guard let existing = try? modelContext.fetch(descriptor).first else { return }
existing.lastAttemptAt = Date()
existing.lastError = error
existing.attemptCount += 1
try? modelContext.save()
}
}
@@ -0,0 +1,40 @@
import Foundation
import SwiftData
/// A queued mutation made while offline, waiting to replay against the live
/// server. `id` is deliberately overloaded: for actions that create a new
/// server-side record (pin, star, subscription), it's also the synthesized
/// placeholder id handed back to the caller immediately so a matching
/// delete queued before that create ever syncs can cancel both out by id
/// instead of hitting a server that's never heard of the placeholder. For
/// actions that edit an existing record (document/collection updates), it's
/// deterministic per target id, so a second edit before the first syncs
/// coalesces into one queued operation instead of piling up.
@Model
public final class PendingOperation {
@Attribute(.unique) public var id: String
public var kind: String
public var payload: Data
public var createdAt: Date
public var lastAttemptAt: Date?
public var lastError: String?
public var attemptCount: Int
public init(
id: String,
kind: String,
payload: Data,
createdAt: Date,
lastAttemptAt: Date? = nil,
lastError: String? = nil,
attemptCount: Int = 0
) {
self.id = id
self.kind = kind
self.payload = payload
self.createdAt = createdAt
self.lastAttemptAt = lastAttemptAt
self.lastError = lastError
self.attemptCount = attemptCount
}
}
@@ -0,0 +1,50 @@
import Foundation
/// UI-facing snapshot of a queued offline mutation deliberately not the
/// `@Model` type itself, so Settings can display it without holding a
/// reference into the SwiftData store.
public struct PendingOperationSummary: Identifiable, Sendable {
public let id: String
public let kind: String
public let createdAt: Date
public let attemptCount: Int
public let lastError: String?
}
public struct CacheStorageSummary: Sendable {
public let itemCount: Int
public let totalBytes: Int
}
public struct SyncFlushSummary: Sendable {
public let succeeded: Int
public let failed: Int
}
public struct FullSyncSummary: Sendable {
public let collectionsCount: Int
public let documentsCount: Int
public let errors: [String]
public let finishedAt: Date
}
/// Every mutation `CachingOutlineAPIClient` knows how to queue offline and
/// replay later. Deliberately a small, explicit set see its doc comment
/// for what's excluded and why.
enum PendingOperationKind: String, Codable, Sendable {
case createDocument
case updateDocument
case updateCollection
case createPin
case deletePin
case createSubscription
case deleteSubscription
case starDocument
case deleteStar
case starCollection
}
/// Shared payload shape for the delete-by-id queueable operations.
struct IDPayload: Codable, Sendable {
let id: String
}
@@ -9,6 +9,10 @@ public protocol OutlineAPIClient: Sendable {
func documentInfo(id: String) async throws -> OutlineDocument
func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument]
/// Richer filtering (sort/direction/userId) for the Home page's tabs. See `DocumentsListRequest`.
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument]
/// Documents the current user has recently viewed. Backed by `documents.viewed`.
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument]
/// Full-text search with snippets/ranking. Backed by `documents.search`.
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
/// Title-only search faster, no snippets. Backed by `documents.search_titles`.
@@ -31,6 +35,8 @@ public protocol OutlineAPIClient: Sendable {
func createShare(_ request: CreateShareRequest) async throws -> OutlineShare
func shareInfo(documentId: String) async throws -> OutlineShare?
func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare
func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare]
func revokeShare(id: String) async throws
/// See `OutlinePin` best-effort, not in the vendored spec.
func createPin(_ request: CreatePinRequest) async throws -> OutlinePin
func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin]
@@ -42,6 +48,14 @@ public protocol OutlineAPIClient: Sendable {
/// Historical view records, not live presence. Backed by `views.list`.
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView]
/// See `OutlineMembership`/`OutlineDocumentMember` `add` is confirmed
/// from Outline's official docs, the rest are best-effort.
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember]
/// For searching workspace members to invite. Backed by `users.list`.
func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser]
func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection]
func collectionInfo(id: String) async throws -> OutlineCollection
func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection
@@ -55,4 +69,40 @@ public protocol OutlineAPIClient: Sendable {
func deleteStar(id: String) async throws
func currentUser() async throws -> OutlineUser
/// Two-step presigned upload: this requests where/how to upload,
/// `uploadAttachmentFile` performs the actual multipart POST to that
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws
/// Best-effort matches the shape every other simple `id`-only delete
/// in this API uses (`pins.delete`, `stars.delete`, ), not confirmed
/// against a live server specifically for attachments yet.
func deleteAttachment(id: String) async throws
/// `users.update`, avatar only. See `UpdateUserAvatarRequest`.
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser
/// `users.update`, name only. See `UpdateUserNameRequest`.
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser
/// `users.update`, language only. See `UpdateUserLanguageRequest`.
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser
/// `users.update`, preferences only. See `UpdateUserPreferencesRequest`.
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser
/// Backed by `users.delete` self-service account deletion, no
/// confirmation code param confirmed live, matches every other simple
/// no-body delete in this API.
func deleteAccount() async throws
/// `nil` targets every notification event. See `NotificationEventType`.
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
/// Settings API & Access.
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey]
/// The returned `OutlineAPIKey.value` is the only time the full
/// plaintext key is ever available the caller is responsible for
/// displaying it once and then discarding it.
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey
func deleteApiKey(id: String) async throws
/// Settings Installation. Self-hosted server version info.
func installationInfo() async throws -> OutlineInstallationInfo
}
@@ -51,6 +51,14 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
)
}
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
try await post("documents.list", body: request)
}
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
try await post("documents.viewed", body: PaginationParams(offset: offset, limit: limit))
}
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
try await post("documents.search", body: request)
}
@@ -113,23 +121,38 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
}
public func shareInfo(documentId: String) async throws -> OutlineShare? {
do {
return try await post("shares.info", body: ShareInfoRequest(documentId: documentId))
} catch OutlineAPIError.notFound {
return nil
}
// Real shape confirmed against a live server: `data` is
// `{ shares: [...] }`, not the bare share object the docs imply
// same pattern as `pins.list`. A document could in principle have
// more than one share record; the first is what the reader's
// share sheet cares about.
let payload: SharesInfoPayload? = try await postOptional("shares.info", body: ShareInfoRequest(documentId: documentId))
return payload?.shares.first
}
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
try await post("shares.update", body: request)
}
public func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] {
try await post("shares.list", body: request)
}
public func revokeShare(id: String) async throws {
try await postForSuccess("shares.revoke", body: StarIDParams(id: id))
}
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
try await post("pins.create", body: request)
}
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
try await post("pins.list", body: request)
// `data` here is `{ pins: [...], documents: [...] }`, not a bare
// array confirmed against a live server. Decoding straight to
// `[OutlinePin]` throws on every call, which `try?` at call sites
// swallows silently, so pins never showed up anywhere.
let payload: PinsListPayload = try await post("pins.list", body: request)
return payload.pins
}
public func deletePin(id: String) async throws {
@@ -152,6 +175,22 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("views.list", body: request)
}
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
try await post("documents.add_user", body: request)
}
public func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws {
try await postForSuccess("documents.remove_user", body: request)
}
public func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] {
try await post("documents.users", body: request)
}
public func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] {
try await post("users.list", body: request)
}
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit))
}
@@ -190,6 +229,103 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("users.info", body: EmptyParams())
}
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
try await post("attachments.create", body: request)
}
/// No Bearer/CSRF header attached here, deliberately the presigned
/// `form.sig` field (short-lived, scoped to this exact upload key) is
/// what authorizes this specific request, the same way an S3 presigned
/// POST works. Best-effort against a live server: if it turns out the
/// self-hosted local-storage backend also wants a bearer token here,
/// that's a one-line addition once confirmed, not a design change.
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
let (body, contentType) = MultipartFormDataBuilder.build(
fields: result.form,
fileFieldName: "file",
fileName: result.attachment.name ?? "avatar",
fileData: fileData,
fileContentType: result.form["Content-Type"] ?? "application/octet-stream"
)
// `uploadUrl` is a host-relative path (e.g. "/api/files.create") on
// a self-hosted local-storage backend, not an absolute S3 URL
// resolving against `baseURL` handles both: `URL(string:relativeTo:)`
// replaces the whole path for a leading-slash relative string per
// RFC 3986, same resolution already used for user/team avatar URLs.
guard let uploadURL = URL(string: result.uploadUrl, relativeTo: baseURL)?.absoluteURL else {
throw OutlineAPIError.transport(URLError(.badURL))
}
var request = URLRequest(url: uploadURL)
request.httpMethod = "POST"
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
request.httpBody = body
let data: Data
let response: HTTPURLResponse
do {
(data, response) = try await httpClient.send(request)
} catch let error as OutlineAPIError {
throw error
} catch {
throw OutlineAPIError.transport(error)
}
guard (200...299).contains(response.statusCode) else {
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
}
}
public func deleteAttachment(id: String) async throws {
try await postForSuccess("attachments.delete", body: StarIDParams(id: id))
}
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
try await post("users.update", body: request)
}
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
try await post("users.update", body: request)
}
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
try await post("users.update", body: request)
}
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
try await post("users.update", body: request)
}
public func deleteAccount() async throws {
try await postForSuccess("users.delete", body: EmptyParams())
}
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
try await post("users.notificationsSubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
}
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
try await post("users.notificationsUnsubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
}
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
try await post("apiKeys.list", body: request)
}
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
try await post("apiKeys.create", body: request)
}
public func deleteApiKey(id: String) async throws {
try await postForSuccess("apiKeys.delete", body: StarIDParams(id: id))
}
public func installationInfo() async throws -> OutlineInstallationInfo {
try await post("installation.info", body: EmptyParams())
}
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
guard let token = try? tokenStore.token() else {
throw OutlineAPIError.tokenUnavailable
@@ -230,6 +366,52 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
}
}
/// Like `post(_:body:)`, but for endpoints where "no result" comes back
/// as a 200 with a completely empty body instead of a real 404
/// confirmed against a live server for `shares.info` (no share yet for
/// a given document). A 404 is still treated as nil too.
private func postOptional<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response? {
guard let token = try? tokenStore.token() else {
throw OutlineAPIError.tokenUnavailable
}
var request = URLRequest(url: baseURL.appendingPathComponent("api/\(path)"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.httpBody = try encoder.encode(body)
let data: Data
let response: HTTPURLResponse
do {
(data, response) = try await httpClient.send(request)
} catch let error as OutlineAPIError {
throw error
} catch {
throw OutlineAPIError.transport(error)
}
guard (200...299).contains(response.statusCode) else {
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
switch response.statusCode {
case 401:
throw OutlineAPIError.unauthorized
case 404:
return nil
default:
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
}
}
guard !data.isEmpty else { return nil }
do {
return try decoder.decode(OutlineEnvelope<Response>.self, from: data).data
} catch {
throw OutlineAPIError.decoding(error)
}
}
/// For endpoints shaped `{ "success": true }` instead of `{ "data": ... }`
/// (e.g. `collections.delete`) `post(_:body:)`'s envelope decode doesn't fit.
private func postForSuccess<Body: Encodable>(_ path: String, body: Body) async throws {
@@ -286,6 +468,15 @@ private struct DuplicateDocumentResponse: Decodable {
let documents: [OutlineDocument]
}
private struct PinsListPayload: Decodable {
let pins: [OutlinePin]
let documents: [OutlineDocument]
}
private struct SharesInfoPayload: Decodable {
let shares: [OutlineShare]
}
private struct ListStarsResponse: Decodable {
let stars: [OutlineStar]
}
@@ -314,6 +505,11 @@ private struct DocumentListParams: Encodable {
let limit: Int
}
private struct PaginationParams: Encodable {
let offset: Int
let limit: Int
}
private struct CollectionListParams: Encodable {
let offset: Int
let limit: Int
@@ -0,0 +1,28 @@
import Foundation
/// The subset of Outline's notification event types this app exposes a
/// toggle for. Wire values confirmed live (captured `users.update`
/// responses showing the full `notificationSettings` dictionary after
/// toggling each one in Outline's own web app). The server tracks more
/// event types than this app has UI for (`revisions.create`,
/// `emails.onboarding`, `emails.features` were also seen live) those are
/// left alone since `users.notificationsSubscribe`/`Unsubscribe` are
/// per-event, not a whole-object replace like `preferences`, so there's no
/// clobbering risk in only covering a subset.
public enum NotificationEventType: String, CaseIterable, Sendable {
case documentPublish = "documents.publish"
case documentUpdate = "documents.update"
case commentCreate = "comments.create"
case commentMentioned = "comments.mentioned"
case documentMentioned = "documents.mentioned"
case commentGroupMentioned = "comments.group_mentioned"
case documentGroupMentioned = "documents.group_mentioned"
case commentResolve = "comments.resolve"
case reactionCreate = "reactions.create"
case collectionCreate = "collections.create"
case emailsInviteAccepted = "emails.invite_accepted"
case documentAddUser = "documents.add_user"
case collectionAddUser = "collections.add_user"
case emailsExportCompleted = "emails.export_completed"
case accessRequestCreate = "access_requests.create"
}
@@ -0,0 +1,40 @@
import Foundation
/// A personal API key (Settings API & Access). Only the last 4 characters
/// of the actual token are ever returned by the server there's no way to
/// see a full key again after creation, matching every other API-key UI
/// convention.
public struct OutlineAPIKey: Codable, Identifiable, Hashable, Sendable {
public let id: String
public let name: String
public let last4: String?
public let scope: [String]?
public let createdAt: Date
public let expiresAt: Date?
public let lastActiveAt: Date?
/// The full plaintext key present *only* in `apiKeys.create`'s
/// response, confirmed live: `apiKeys.list` never includes it, matching
/// "shown once at creation" being enforced server-side, not just a
/// client-side UI convention this app has to uphold on its own.
public let value: String?
public init(
id: String,
name: String,
last4: String? = nil,
scope: [String]? = nil,
createdAt: Date,
expiresAt: Date? = nil,
lastActiveAt: Date? = nil,
value: String? = nil
) {
self.id = id
self.name = name
self.last4 = last4
self.scope = scope
self.createdAt = createdAt
self.expiresAt = expiresAt
self.lastActiveAt = lastActiveAt
self.value = value
}
}
@@ -0,0 +1,26 @@
import Foundation
/// One uploaded file created via a two-step presigned upload
/// (`attachments.create` for the upload target, then a direct POST to
/// `uploadUrl`/`form`). Confirmed live against a self-hosted instance's
/// local-storage backend; `size` comes back as a string there, not a
/// number same "don't trust the vendored spec's implied types" lesson as
/// everywhere else this codebase has hit it.
public struct OutlineAttachment: Decodable, Identifiable, Sendable {
public let id: String
public let documentId: String?
public let contentType: String?
public let name: String?
public let url: String?
public let size: String?
}
/// `attachments.create`'s response everything needed to perform the
/// actual upload. `form` fields (Content-Type, key, acl, sig, `_csrf`, )
/// must all be included as their own multipart parts, in the same request
/// as the file itself, POSTed to `uploadUrl`.
public struct CreateAttachmentResult: Decodable, Sendable {
public let attachment: OutlineAttachment
public let uploadUrl: String
public let form: [String: String]
}
@@ -0,0 +1,11 @@
import Foundation
/// `installation.info` the self-hosted server's own version, confirmed
/// live. `policies` (a separate top-level array alongside `data` in the raw
/// response) isn't modeled here not used by this app's Installation
/// settings page.
public struct OutlineInstallationInfo: Decodable, Sendable {
public let version: String
public let latestVersion: String
public let versionsBehind: Int
}
@@ -0,0 +1,31 @@
import Foundation
/// A user's explicit permission grant on a document, backed by
/// `documents.add_user`/`documents.remove_user`. Not in the vendored spec
/// only `documents.add_user`'s shape is confirmed from Outline's official
/// docs; `remove` and the response shape here follow the create/delete and
/// `{data: ...}` conventions used throughout the rest of this API, but
/// aren't verified against a live server yet.
public struct OutlineMembership: Decodable, Identifiable, Sendable {
public let id: String
public let userId: String
public let documentId: String?
/// `"read"` or `"read_write"` per the documented enum.
public let permission: String
}
/// A workspace member as returned by `documents.users` in the context of a
/// specific document same identity fields as `OutlineUser`, plus (if the
/// server includes it) the permission they hold on that document. Modeled
/// separately from `OutlineUser` rather than adding an optional field there,
/// since `permission` only makes sense in this document-scoped context.
/// Speculative `documents.users` isn't in the vendored spec, its name is
/// inferred from Outline's usual `<resource>.<verb>` convention and hasn't
/// been confirmed against a live server.
public struct OutlineDocumentMember: Decodable, Identifiable, Sendable {
public let id: String
public let name: String
public let email: String?
public let avatarUrl: String?
public let permission: String?
}
@@ -1,11 +1,11 @@
import Foundation
/// A document pinned to the top of a collection (or the team home), backed by
/// `pins.*`. Not in the vendored OpenAPI spec (`docs/reference/outline-openapi`)
/// that spec has no `Pins` tag at all but the endpoint exists on Outline's
/// actual server (`server/routes/api/pins.ts` upstream). Shape reconstructed
/// from general knowledge of Outline's API, not verified against this spec;
/// treat field names as best-effort until confirmed against a live server.
/// A document pinned to the top of a collection, or to team Home when
/// `collectionId` is `nil`. Backed by `pins.*` not in the vendored OpenAPI
/// spec (`docs/reference/outline-openapi`, no `Pins` tag at all), but
/// confirmed real against a live server's network traffic. `collectionId: nil`
/// is what "Pin to Home" actually sends; a non-nil value is "Pin to
/// Collection", a distinct action.
public struct OutlinePin: Decodable, Identifiable, Sendable {
public let id: String
public let documentId: String
@@ -1,10 +1,41 @@
import Foundation
/// A public share link for a document or collection, backed by `shares.*`.
///
/// Only `id` and `published` are guaranteed present on every real share
/// object everything else is modeled as optional even where the official
/// API docs don't explicitly mark it nullable, since self-hosted servers
/// have repeatedly diverged from the hosted app's documented response shape
/// (see `OutlinePin`'s history) and a single unexpectedly-null field used to
/// crash this decode entirely ("Got an unexpected response from the
/// server").
public struct OutlineShare: Decodable, Identifiable, Sendable {
public let id: String
public let published: Bool
public let documentId: String?
public let collectionId: String?
public let url: String
public let published: Bool
public let documentTitle: String?
public let documentUrl: String?
public let sourceTitle: String?
public let sourcePath: String?
public let urlId: String?
public let url: String?
public let domain: String?
/// Overrides the source document/collection title on the publicly
/// shared page, if set.
public let title: String?
/// Overrides the workspace branding icon on the publicly shared page,
/// if set.
public let iconUrl: String?
public let includeChildDocuments: Bool?
public let allowSubscriptions: Bool?
public let allowIndexing: Bool?
public let showLastUpdated: Bool?
public let showTOC: Bool?
public let views: Int?
public let createdBy: OutlineUser?
public let createdAt: Date?
public let updatedAt: Date?
public let lastAccessedAt: Date?
}
@@ -6,18 +6,30 @@ public struct OutlineUser: Codable, Identifiable, Hashable, Sendable {
public let email: String?
public let avatarUrl: String?
public let role: String?
public let language: String?
public let preferences: OutlineUserPreferences?
/// Keyed by `NotificationEventType`'s raw values, plus event types this
/// app has no UI for kept as a flexible dictionary rather than a
/// fixed struct for that reason. `true` means subscribed.
public let notificationSettings: [String: Bool]?
public init(
id: String,
name: String,
email: String? = nil,
avatarUrl: String? = nil,
role: String? = nil
role: String? = nil,
language: String? = nil,
preferences: OutlineUserPreferences? = nil,
notificationSettings: [String: Bool]? = nil
) {
self.id = id
self.name = name
self.email = email
self.avatarUrl = avatarUrl
self.role = role
self.language = language
self.preferences = preferences
self.notificationSettings = notificationSettings
}
}
@@ -0,0 +1,109 @@
import Foundation
/// `User.preferences` a free-form JSON blob on Outline's own `User` row,
/// not a fixed-shape API resource. Wire key names below are confirmed
/// against a live server's own web app traffic (captured toggling every
/// Preferences setting one at a time), not guessed see the `CodingKeys`
/// mapping for the two that don't match this struct's own property names.
///
/// The server also validates `preferences` against a known key allowlist
/// and rejects the whole `users.update` call (not just the bad field) if
/// any key it doesn't recognize is present confirmed live via a
/// `"notificationBadge: Invalid Input"` error when an earlier, wrong value
/// was sent. That's also why `fullWidthDocuments` is kept here even though
/// this app has no UI for it yet: this app always sends the *whole*
/// preferences object back on every save (see
/// `UpdateUserPreferencesRequest`), so silently dropping an unknown key
/// during decode would permanently clear it the next time any other
/// preference here gets saved.
public struct OutlineUserPreferences: Codable, Hashable, Sendable {
public var rememberLastPath: Bool?
/// App-facing polarity: `true` means "separate editing mode is on"
/// the opposite of the wire's own `seamlessEdit` (seamless editing and
/// separate editing modes are each other's negation), inverted in
/// `init(from:)`/`encode(to:)` so nothing outside this file has to
/// remember that.
public var separateEditing: Bool?
public var useCursorPointer: Bool?
public var codeBlockLineNumbers: Bool?
public var showCommentMarker: Bool?
public var smartText: Bool?
/// One of `NotificationBadgeStyle`'s raw values.
public var notificationBadge: String?
/// No UI in this app yet preserved purely so saving any other
/// preference here doesn't clobber it. See the type doc comment.
public var fullWidthDocuments: Bool?
public init(
rememberLastPath: Bool? = nil,
separateEditing: Bool? = nil,
useCursorPointer: Bool? = nil,
codeBlockLineNumbers: Bool? = nil,
showCommentMarker: Bool? = nil,
smartText: Bool? = nil,
notificationBadge: String? = nil,
fullWidthDocuments: Bool? = nil
) {
self.rememberLastPath = rememberLastPath
self.separateEditing = separateEditing
self.useCursorPointer = useCursorPointer
self.codeBlockLineNumbers = codeBlockLineNumbers
self.showCommentMarker = showCommentMarker
self.smartText = smartText
self.notificationBadge = notificationBadge
self.fullWidthDocuments = fullWidthDocuments
}
private enum CodingKeys: String, CodingKey {
case rememberLastPath
case seamlessEdit
case useCursorPointer
case codeBlockLineNumbers
case commentsInGutter
case enableSmartText
case notificationBadge
case fullWidthDocuments
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
rememberLastPath = try container.decodeIfPresent(Bool.self, forKey: .rememberLastPath)
separateEditing = try container.decodeIfPresent(Bool.self, forKey: .seamlessEdit).map { !$0 }
useCursorPointer = try container.decodeIfPresent(Bool.self, forKey: .useCursorPointer)
codeBlockLineNumbers = try container.decodeIfPresent(Bool.self, forKey: .codeBlockLineNumbers)
showCommentMarker = try container.decodeIfPresent(Bool.self, forKey: .commentsInGutter)
smartText = try container.decodeIfPresent(Bool.self, forKey: .enableSmartText)
notificationBadge = try container.decodeIfPresent(String.self, forKey: .notificationBadge)
fullWidthDocuments = try container.decodeIfPresent(Bool.self, forKey: .fullWidthDocuments)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(rememberLastPath, forKey: .rememberLastPath)
try container.encodeIfPresent(separateEditing.map { !$0 }, forKey: .seamlessEdit)
try container.encodeIfPresent(useCursorPointer, forKey: .useCursorPointer)
try container.encodeIfPresent(codeBlockLineNumbers, forKey: .codeBlockLineNumbers)
try container.encodeIfPresent(showCommentMarker, forKey: .commentsInGutter)
try container.encodeIfPresent(smartText, forKey: .enableSmartText)
try container.encodeIfPresent(notificationBadge, forKey: .notificationBadge)
try container.encodeIfPresent(fullWidthDocuments, forKey: .fullWidthDocuments)
}
}
/// App-icon unread indicator style. Wire values confirmed live (captured
/// setting all three from Outline's own web app).
public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable {
case none = "disabled"
case unreadIndicator = "indicator"
case unreadCount = "count"
public var id: String { rawValue }
public var label: String {
switch self {
case .none: return "None"
case .unreadIndicator: return "Unread Indicator"
case .unreadCount: return "Unread Count"
}
}
}
@@ -0,0 +1,40 @@
import Foundation
/// Builds the multipart/form-data body for Outline's presigned-upload
/// targets (`attachments.create`'s `uploadUrl`/`form`) an S3-style
/// presigned POST: every `form` field has to ride along as its own part in
/// the same request as the file, not as query params or headers.
enum MultipartFormDataBuilder {
static func build(
fields: [String: String],
fileFieldName: String,
fileName: String,
fileData: Data,
fileContentType: String,
boundary: String = "Boundary-\(UUID().uuidString)"
) -> (body: Data, contentType: String) {
var body = Data()
for (key, value) in fields {
body.append("--\(boundary)\r\n".utf8Data)
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".utf8Data)
body.append(value.utf8Data)
body.append("\r\n".utf8Data)
}
body.append("--\(boundary)\r\n".utf8Data)
body.append(
"Content-Disposition: form-data; name=\"\(fileFieldName)\"; filename=\"\(fileName)\"\r\n".utf8Data
)
body.append("Content-Type: \(fileContentType)\r\n\r\n".utf8Data)
body.append(fileData)
body.append("\r\n".utf8Data)
body.append("--\(boundary)--\r\n".utf8Data)
return (body, "multipart/form-data; boundary=\(boundary)")
}
}
private extension String {
var utf8Data: Data { Data(utf8) }
}
@@ -0,0 +1,16 @@
import Foundation
/// See `OutlineMembership`. Confirmed shape from Outline's official
/// `documents.add_user` docs.
public struct AddDocumentUserRequest: Encodable, Sendable {
public let id: String
public let userId: String
/// `"read"` or `"read_write"`.
public let permission: String
public init(id: String, userId: String, permission: String) {
self.id = id
self.userId = userId
self.permission = permission
}
}
@@ -0,0 +1,21 @@
import Foundation
/// `apiKeys.create`. `expiresAt: nil` (the key omitted entirely, not sent as
/// literal `null`) confirmed live to mean no expiration Swift's
/// synthesized `Encodable` already omits `nil` optionals via
/// `encodeIfPresent`, so no custom `encode(to:)` is needed here the way
/// `UpdateUserAvatarRequest` needed one for the opposite case.
public struct CreateApiKeyRequest: Encodable, Sendable {
public let name: String
public let expiresAt: Date?
/// `nil`/omitted grants full access confirmed live (every key created
/// without a scope came back with unrestricted access). A specific
/// scope is a list of allowed API paths, e.g. `["/api/documents.info"]`.
public let scope: [String]?
public init(name: String, expiresAt: Date? = nil, scope: [String]? = nil) {
self.name = name
self.expiresAt = expiresAt
self.scope = scope
}
}
@@ -0,0 +1,19 @@
import Foundation
/// Requests an upload target for a new file not the upload itself, see
/// `OutlineAPIClient.uploadAttachmentFile`. `documentId: nil` is what a
/// user-avatar upload sends (confirmed live); a real value scopes the
/// attachment to a document instead (e.g. an inline image embed).
public struct CreateAttachmentRequest: Encodable, Sendable {
public let name: String
public let contentType: String
public let size: Int
public let documentId: String?
public init(name: String, contentType: String, size: Int, documentId: String? = nil) {
self.name = name
self.contentType = contentType
self.size = size
self.documentId = documentId
}
}
@@ -1,6 +1,6 @@
import Foundation
public struct CreateDocumentRequest: Encodable, Sendable {
public struct CreateDocumentRequest: Codable, Sendable {
public let title: String
public let text: String
public let collectionId: String
@@ -1,7 +1,8 @@
import Foundation
/// See `OutlinePin` best-effort shape, not in the vendored spec.
public struct CreatePinRequest: Encodable, Sendable {
/// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to
/// Collection" these are distinct actions on the real server.
public struct CreatePinRequest: Codable, Sendable {
public let documentId: String
public let collectionId: String?
@@ -1,7 +1,7 @@
import Foundation
/// See `OutlineSubscription` best-effort shape, not in the vendored spec.
public struct CreateSubscriptionRequest: Encodable, Sendable {
public struct CreateSubscriptionRequest: Codable, Sendable {
public let documentId: String
public let event: String
@@ -0,0 +1,29 @@
import Foundation
/// Richer `documents.list` query than `OutlineAPIClient.listDocuments` covers
/// (that one's kept as-is for its existing simple callers) adds the
/// sort/direction/userId filters the Home page's tabs need.
public struct DocumentsListRequest: Encodable, Sendable {
public let collectionId: String?
public let userId: String?
public let sort: String?
public let direction: String?
public let offset: Int
public let limit: Int
public init(
collectionId: String? = nil,
userId: String? = nil,
sort: String? = nil,
direction: String? = nil,
offset: Int = 0,
limit: Int = 25
) {
self.collectionId = collectionId
self.userId = userId
self.sort = sort
self.direction = direction
self.offset = offset
self.limit = limit
}
}
@@ -0,0 +1,14 @@
import Foundation
/// `apiKeys.list` matches every other paginated `.list` endpoint's flat
/// offset/limit convention (e.g. `ListSharesRequest`), not the nested
/// `pagination` object that only appears in list *responses*.
public struct ListApiKeysRequest: Encodable, Sendable {
public let offset: Int
public let limit: Int
public init(offset: Int = 0, limit: Int = 25) {
self.offset = offset
self.limit = limit
}
}
@@ -0,0 +1,17 @@
import Foundation
/// See `OutlineDocumentMember`. Speculative `documents.users` isn't in
/// the vendored spec.
public struct ListDocumentUsersRequest: Encodable, Sendable {
public let id: String
public let query: String?
public let offset: Int
public let limit: Int
public init(id: String, query: String? = nil, offset: Int = 0, limit: Int = 25) {
self.id = id
self.query = query
self.offset = offset
self.limit = limit
}
}
@@ -1,6 +1,6 @@
import Foundation
/// See `OutlinePin` best-effort shape, not in the vendored spec.
/// See `OutlinePin`. `collectionId: nil` lists Home pins only.
public struct ListPinsRequest: Encodable, Sendable {
public let collectionId: String?
@@ -0,0 +1,18 @@
import Foundation
public struct ListSharesRequest: Encodable, Sendable {
public let offset: Int
public let limit: Int
public let sort: String?
public let direction: String?
/// Filter to shared documents matching a search query.
public let query: String?
public init(offset: Int = 0, limit: Int = 25, sort: String? = nil, direction: String? = nil, query: String? = nil) {
self.offset = offset
self.limit = limit
self.sort = sort
self.direction = direction
self.query = query
}
}
@@ -0,0 +1,15 @@
import Foundation
/// For searching workspace members to invite to a document. Backed by
/// `users.list`.
public struct ListUsersRequest: Encodable, Sendable {
public let query: String?
public let offset: Int
public let limit: Int
public init(query: String? = nil, offset: Int = 0, limit: Int = 25) {
self.query = query
self.offset = offset
self.limit = limit
}
}
@@ -0,0 +1,13 @@
import Foundation
/// `users.notificationsSubscribe` / `users.notificationsUnsubscribe`. A
/// `nil` `eventType` targets every notification event at once confirmed
/// live via Outline's own "All notifications" master toggle, which sends
/// no `eventType` at all.
public struct NotificationSubscriptionRequest: Encodable, Sendable {
public let eventType: String?
public init(eventType: String? = nil) {
self.eventType = eventType
}
}
@@ -0,0 +1,14 @@
import Foundation
/// See `OutlineMembership`. Speculative follows the create/delete pairing
/// convention used elsewhere in this API (`documents.add_user` /
/// `documents.remove_user`), not confirmed against a live server yet.
public struct RemoveDocumentUserRequest: Encodable, Sendable {
public let id: String
public let userId: String
public init(id: String, userId: String) {
self.id = id
self.userId = userId
}
}
@@ -1,6 +1,6 @@
import Foundation
public struct StarCollectionRequest: Encodable, Sendable {
public struct StarCollectionRequest: Codable, Sendable {
public let collectionId: String
public init(collectionId: String) {
@@ -1,6 +1,6 @@
import Foundation
public struct StarDocumentRequest: Encodable, Sendable {
public struct StarDocumentRequest: Codable, Sendable {
public let documentId: String
public init(documentId: String) {
@@ -1,6 +1,6 @@
import Foundation
public struct UpdateCollectionRequest: Encodable, Sendable {
public struct UpdateCollectionRequest: Codable, Sendable {
public let id: String
public let name: String?
public let description: String?
@@ -1,18 +1,12 @@
import Foundation
public struct UpdateDocumentRequest: Encodable, Sendable {
public struct UpdateDocumentRequest: Codable, Sendable {
public let id: String
public let title: String?
public let text: String?
public let append: Bool?
public let fullWidth: Bool?
public let insightsEnabled: Bool?
/// Not in the vendored spec's `Document`/`documents.update` shape at all
/// (only a workspace-level `documentEmbeds` flag exists there) included
/// speculatively since the field may exist on newer self-hosted servers.
/// Unrecognized fields are typically ignored server-side rather than
/// rejected, so this is low-risk even if unsupported.
public let documentEmbeds: Bool?
public init(
id: String,
@@ -20,8 +14,7 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
text: String? = nil,
append: Bool? = nil,
fullWidth: Bool? = nil,
insightsEnabled: Bool? = nil,
documentEmbeds: Bool? = nil
insightsEnabled: Bool? = nil
) {
self.id = id
self.title = title
@@ -29,6 +22,5 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
self.append = append
self.fullWidth = fullWidth
self.insightsEnabled = insightsEnabled
self.documentEmbeds = documentEmbeds
}
}
@@ -3,9 +3,16 @@ import Foundation
public struct UpdateShareRequest: Encodable, Sendable {
public let id: String
public let published: Bool
/// Overrides the title displayed on the publicly shared page. `nil`
/// leaves it unset in the payload (server keeps whatever it already
/// has) rather than clearing it send an empty string to clear.
public let title: String?
public let iconUrl: String?
public init(id: String, published: Bool) {
public init(id: String, published: Bool, title: String? = nil, iconUrl: String? = nil) {
self.id = id
self.published = published
self.title = title
self.iconUrl = iconUrl
}
}
@@ -0,0 +1,28 @@
import Foundation
/// `users.update`, scoped to just the avatar. Custom `encode(to:)` because
/// Swift's synthesized `Encodable` uses `encodeIfPresent` for `Optional`
/// properties, which *omits* the key entirely when the value is `nil`
/// removing the avatar needs a literal `"avatarUrl": null` in the request
/// body, not the key missing. `id` is required: confirmed live (the
/// captured request body's length only matches `{"id": "...", "avatarUrl":
/// ...}`, not a shorter shape without it).
public struct UpdateUserAvatarRequest: Encodable, Sendable {
public let id: String
public let avatarUrl: String?
public init(id: String, avatarUrl: String?) {
self.id = id
self.avatarUrl = avatarUrl
}
private enum CodingKeys: String, CodingKey {
case id, avatarUrl
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(avatarUrl, forKey: .avatarUrl)
}
}
@@ -0,0 +1,12 @@
import Foundation
/// `users.update`, language only.
public struct UpdateUserLanguageRequest: Encodable, Sendable {
public let id: String
public let language: String
public init(id: String, language: String) {
self.id = id
self.language = language
}
}
@@ -0,0 +1,12 @@
import Foundation
/// `users.update`, name only.
public struct UpdateUserNameRequest: Encodable, Sendable {
public let id: String
public let name: String
public init(id: String, name: String) {
self.id = id
self.name = name
}
}
@@ -0,0 +1,14 @@
import Foundation
/// `users.update`, preferences only. Sends the *whole* preferences object
/// back (not a single changed key) avoids needing to know whether the
/// server deep-merges a partial `preferences` body or replaces it outright.
public struct UpdateUserPreferencesRequest: Encodable, Sendable {
public let id: String
public let preferences: OutlineUserPreferences
public init(id: String, preferences: OutlineUserPreferences) {
self.id = id
self.preferences = preferences
}
}
@@ -0,0 +1,514 @@
import XCTest
@testable import OutlineKit
private struct NotStubbed: Error {}
/// Conforms to the full protocol (so it can stand in for `live`), but only
/// the two methods under test in this file have real behavior everything
/// else throws loudly if a test accidentally exercises it.
private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable {
var documentInfoHandler: (@Sendable (String) async throws -> OutlineDocument)?
var listCollectionsHandler: (@Sendable (Int, Int) async throws -> [OutlineCollection])?
var updateDocumentHandler: (@Sendable (UpdateDocumentRequest) async throws -> OutlineDocument)?
var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)?
var deletePinHandler: (@Sendable (String) async throws -> Void)?
var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])?
var createDocumentHandler: (@Sendable (CreateDocumentRequest) async throws -> OutlineDocument)?
func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() }
func documentInfo(id: String) async throws -> OutlineDocument {
guard let handler = documentInfoHandler else { throw NotStubbed() }
return try await handler(id)
}
func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument] {
guard let handler = listDocumentsHandler else { throw NotStubbed() }
return try await handler(collectionId, parentDocumentId, offset, limit)
}
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() }
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() }
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
guard let handler = createDocumentHandler else { throw NotStubbed() }
return try await handler(request)
}
func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
guard let handler = updateDocumentHandler else { throw NotStubbed() }
return try await handler(request)
}
func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { throw NotStubbed() }
func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { throw NotStubbed() }
func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() }
func archiveDocument(id: String) async throws -> OutlineDocument { throw NotStubbed() }
func moveDocument(_ request: MoveDocumentRequest) async throws { throw NotStubbed() }
func deleteDocument(_ request: DeleteDocumentRequest) async throws { throw NotStubbed() }
func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] { throw NotStubbed() }
func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] { throw NotStubbed() }
func exportDocument(id: String) async throws -> String { throw NotStubbed() }
func createShare(_ request: CreateShareRequest) async throws -> OutlineShare { throw NotStubbed() }
func shareInfo(documentId: String) async throws -> OutlineShare? { throw NotStubbed() }
func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { throw NotStubbed() }
func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] { throw NotStubbed() }
func revokeShare(id: String) async throws { throw NotStubbed() }
func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
guard let handler = createPinHandler else { throw NotStubbed() }
return try await handler(request)
}
func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { throw NotStubbed() }
func deletePin(id: String) async throws {
guard let handler = deletePinHandler else { throw NotStubbed() }
try await handler(id)
}
func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { throw NotStubbed() }
func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() }
func deleteSubscription(id: String) async throws { throw NotStubbed() }
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { throw NotStubbed() }
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { throw NotStubbed() }
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() }
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { throw NotStubbed() }
func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] { throw NotStubbed() }
func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
guard let handler = listCollectionsHandler else { throw NotStubbed() }
return try await handler(offset, limit)
}
func collectionInfo(id: String) async throws -> OutlineCollection { throw NotStubbed() }
func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection { throw NotStubbed() }
func deleteCollection(id: String) async throws { throw NotStubbed() }
func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation { throw NotStubbed() }
func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar { throw NotStubbed() }
func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { throw NotStubbed() }
func deleteStar(id: String) async throws { throw NotStubbed() }
func currentUser() async throws -> OutlineUser { throw NotStubbed() }
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { throw NotStubbed() }
func deleteAttachment(id: String) async throws { throw NotStubbed() }
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser { throw NotStubbed() }
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser { throw NotStubbed() }
func deleteAccount() async throws { throw NotStubbed() }
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] { throw NotStubbed() }
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey { throw NotStubbed() }
func deleteApiKey(id: String) async throws { throw NotStubbed() }
func installationInfo() async throws -> OutlineInstallationInfo { throw NotStubbed() }
}
private struct StubTransportError: Error {}
final class CachingOutlineAPIClientTests: XCTestCase {
private func makeCache() throws -> OfflineCacheStore {
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
}
private func makeDocument(id: String = "doc-1", title: String = "Hello") -> OutlineDocument {
OutlineDocument(
id: id,
title: title,
text: "body",
url: "/doc/\(id)",
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0)
)
}
private func makeCollection(id: String = "col-1", name: String = "Engineering") -> OutlineCollection {
OutlineCollection(
id: id,
name: name,
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0)
)
}
func testDocumentInfoWritesThroughToCacheOnSuccess() async throws {
let stub = StubOutlineAPIClient()
let document = makeDocument()
stub.documentInfoHandler = { _ in document }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let result = try await sut.documentInfo(id: "doc-1")
XCTAssertEqual(result, document)
}
func testDocumentInfoFallsBackToCacheWhenLiveFails() async throws {
let stub = StubOutlineAPIClient()
let document = makeDocument()
var callCount = 0
stub.documentInfoHandler = { _ in
callCount += 1
if callCount == 1 { return document }
throw StubTransportError()
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
// First call succeeds and populates the cache.
_ = try await sut.documentInfo(id: "doc-1")
// Second call fails live should transparently return the cached value.
let result = try await sut.documentInfo(id: "doc-1")
XCTAssertEqual(result, document)
XCTAssertEqual(callCount, 2)
}
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
do {
_ = try await sut.documentInfo(id: "doc-1")
XCTFail("Expected an error")
} catch is StubTransportError {
// expected
}
}
func testListCollectionsFallsBackToCacheWhenLiveFails() async throws {
let stub = StubOutlineAPIClient()
let collections = [makeCollection()]
var callCount = 0
stub.listCollectionsHandler = { _, _ in
callCount += 1
if callCount == 1 { return collections }
throw StubTransportError()
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.listCollections(offset: 0, limit: 25)
let result = try await sut.listCollections(offset: 0, limit: 25)
XCTAssertEqual(result, collections)
}
func testDifferentDocumentIdsAreCachedSeparately() async throws {
let stub = StubOutlineAPIClient()
let docOne = makeDocument(id: "doc-1", title: "One")
let docTwo = makeDocument(id: "doc-2", title: "Two")
stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.documentInfo(id: "doc-2")
stub.documentInfoHandler = { _ in throw StubTransportError() }
let cachedOne = try await sut.documentInfo(id: "doc-1")
let cachedTwo = try await sut.documentInfo(id: "doc-2")
XCTAssertEqual(cachedOne, docOne)
XCTAssertEqual(cachedTwo, docTwo)
}
// MARK: - Offline writes
func testUpdateDocumentQueuesAndAppliesOptimisticallyWhenLiveFails() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
// Populate the cache with the base document first (as a real open would).
_ = try await sut.documentInfo(id: "doc-1")
let updated = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
XCTAssertEqual(updated.title, "Edited Offline")
// Re-opening the document (still offline) should reflect the optimistic edit.
stub.documentInfoHandler = { _ in throw StubTransportError() }
let reopened = try await sut.documentInfo(id: "doc-1")
XCTAssertEqual(reopened.title, "Edited Offline")
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
XCTAssertEqual(pending.first?.kind, "updateDocument")
}
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
let stub = StubOutlineAPIClient()
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
do {
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x"))
XCTFail("Expected an error")
} catch is StubTransportError {
// expected nothing to optimistically merge onto
}
}
func testSecondOfflineEditCoalescesIntoOneQueuedOperation() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit"))
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Second Edit"))
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
}
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
let stub = StubOutlineAPIClient()
stub.createPinHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
XCTAssertTrue(pin.id.hasPrefix("pending-"))
let pendingCount1 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount1, 1)
try await sut.deletePin(id: pin.id)
let pendingCount0 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount0, 0)
}
func testFlushPendingOperationsReplaysAndClearsOnSuccess() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
let pendingCount1 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount1, 1)
// Network's back replay should now succeed.
stub.updateDocumentHandler = { request in
self.makeDocument(id: request.id, title: request.title ?? "")
}
let summary = await sut.flushPendingOperations()
XCTAssertEqual(summary.succeeded, 1)
XCTAssertEqual(summary.failed, 0)
let pendingCount0 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount0, 0)
}
func testFlushPendingOperationsRecordsErrorAndKeepsFailedOperation() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
// Still offline replay should fail and keep the operation queued.
let summary = await sut.flushPendingOperations()
XCTAssertEqual(summary.succeeded, 0)
XCTAssertEqual(summary.failed, 1)
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
XCTAssertEqual(pending.first?.attemptCount, 1)
XCTAssertNotNil(pending.first?.lastError)
}
func testManualOfflineModeSkipsLiveEntirely() async throws {
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOffline")!
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOffline")
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
let stub = StubOutlineAPIClient()
var liveCallCount = 0
stub.createPinHandler = { _ in
liveCallCount += 1
return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil)
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults)
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
XCTAssertEqual(liveCallCount, 0, "Manual offline mode should never attempt the live call")
XCTAssertTrue(pin.id.hasPrefix("pending-"))
}
/// Regression test: manual offline mode used to only gate writes
/// reads (listCollections, documentInfo, etc.) still hit `live` first
/// any time the device actually had a connection, silently defeating
/// "skip the network entirely" for the one thing that mattered most:
/// the sidebar showing more than what was actually cached.
func testManualOfflineModeSkipsLiveForReadsToo() async throws {
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOfflineReads")!
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOfflineReads")
let stub = StubOutlineAPIClient()
let cachedCollection = makeCollection(id: "col-1")
var liveCallCount = 0
stub.listCollectionsHandler = { _, _ in
liveCallCount += 1
return [cachedCollection]
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults)
// Online first populates the cache normally.
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
XCTAssertEqual(firstResult, [cachedCollection])
XCTAssertEqual(liveCallCount, 1)
// Flip manual offline mode on, still "connected" (stub would happily
// answer) the live call must not be attempted at all.
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
let secondResult = try await sut.listCollections(offset: 0, limit: 25)
XCTAssertEqual(secondResult, [cachedCollection])
XCTAssertEqual(liveCallCount, 1, "listCollections should have served entirely from cache")
}
func testCacheStorageSummaryReflectsCachedItems() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
let summary = await sut.cacheStorageSummary()
XCTAssertEqual(summary.itemCount, 1)
XCTAssertGreaterThan(summary.totalBytes, 0)
await sut.clearCache()
let clearedSummary = await sut.cacheStorageSummary()
XCTAssertEqual(clearedSummary.itemCount, 0)
}
// MARK: - Full sync
func testPerformFullSyncNeverRequestsMoreThan100CollectionsPerPage() async throws {
let stub = StubOutlineAPIClient()
var requestedLimits: [Int] = []
// 105 collections across two pages (100 + 5) regression test for a
// real bug: this used to ask for `limit: 250` in one shot, which
// Outline's server rejects outright ("Pagination limit is too large
// (max 100)"), turning the whole sync into a single silent failure.
stub.listCollectionsHandler = { offset, limit in
requestedLimits.append(limit)
let remaining = max(0, 105 - offset)
let count = min(limit, remaining)
return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
}
stub.listDocumentsHandler = { _, _, _, _ in [] }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let summary = await sut.performFullSync()
XCTAssertEqual(summary.collectionsCount, 105)
XCTAssertTrue(summary.errors.isEmpty)
XCTAssertTrue(requestedLimits.allSatisfy { $0 <= 100 }, "requested limits: \(requestedLimits)")
}
func testPerformFullSyncCachesEachDocumentIndividually() async throws {
let stub = StubOutlineAPIClient()
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
stub.listDocumentsHandler = { _, _, offset, _ in
offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
let summary = await sut.performFullSync()
XCTAssertEqual(summary.documentsCount, 2)
// A plain documentInfo read (no live call available) should now hit
// the cache full sync populated, not just the list-shaped cache key.
stub.documentInfoHandler = { _ in throw StubTransportError() }
let cachedDoc = try await sut.documentInfo(id: "doc-2")
XCTAssertEqual(cachedDoc.id, "doc-2")
}
// MARK: - Offline document creation
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
)
XCTAssertTrue(created.id.hasPrefix("pending-"))
XCTAssertEqual(created.title, "New Doc")
XCTAssertEqual(created.text, "hello")
// It's immediately readable, same as any other cached document.
stub.documentInfoHandler = { _ in throw StubTransportError() }
let reopened = try await sut.documentInfo(id: created.id)
XCTAssertEqual(reopened.title, "New Doc")
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
XCTAssertEqual(pending.first?.kind, "createDocument")
}
func testEditingAnUnsyncedCreatedDocumentFoldsIntoTheCreateInsteadOfQueuingAnUpdate() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
)
_ = try await sut.updateDocument(UpdateDocumentRequest(id: created.id, title: "Real Title", text: "Real body"))
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1, "the edit should have folded into the pending create, not added a second operation")
XCTAssertEqual(pending.first?.kind, "createDocument")
stub.documentInfoHandler = { _ in throw StubTransportError() }
let reopened = try await sut.documentInfo(id: created.id)
XCTAssertEqual(reopened.title, "Real Title")
XCTAssertEqual(reopened.text, "Real body")
}
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
)
let realDocument = makeDocument(id: "real-server-id", title: "New Doc")
stub.createDocumentHandler = { _ in realDocument }
let summary = await sut.flushPendingOperations()
XCTAssertEqual(summary.succeeded, 1)
XCTAssertEqual(summary.failed, 0)
// The real document is now readable under its real id...
stub.documentInfoHandler = { _ in throw StubTransportError() }
let byRealId = try await sut.documentInfo(id: "real-server-id")
XCTAssertEqual(byRealId.id, "real-server-id")
// ...and the placeholder is gone rather than left as a dead orphan.
do {
_ = try await sut.documentInfo(id: created.id)
XCTFail("Expected the placeholder cache entry to have been removed")
} catch {
// expected nothing left under the old id
}
}
}
@@ -280,6 +280,65 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(decodedBody.parentDocumentId, "doc-1")
}
func testDocumentsListSendsSortDirectionAndUserId() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "data": [] }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
_ = try await client.documentsList(
DocumentsListRequest(userId: "user-1", sort: "updatedAt", direction: "DESC")
)
struct SentBody: Decodable {
let userId: String?
let sort: String?
let direction: String?
}
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let decodedBody = try JSONDecoder().decode(SentBody.self, from: sentBody)
XCTAssertEqual(decodedBody.userId, "user-1")
XCTAssertEqual(decodedBody.sort, "updatedAt")
XCTAssertEqual(decodedBody.direction, "DESC")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.list")
}
func testListViewedDocumentsDecodesDocuments() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{
"id": "doc-1",
"title": "Hello",
"text": "World",
"url": "/doc/hello-doc-1",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-02T00:00:00.000Z"
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let documents = try await client.listViewedDocuments(offset: 0, limit: 25)
XCTAssertEqual(documents.first?.id, "doc-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.viewed")
}
func testDocumentInfoDecodesEnvelopeAndSetsAuthHeader() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
@@ -610,6 +669,117 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.create")
}
func testShareInfoDecodesRealShareArrayShape() async throws {
// Real shape confirmed against a live server `data` is
// `{ shares: [...] }`, not the bare share object the official docs
// imply (same pattern as `pins.list`). This is the exact payload
// captured for an existing, unpublished share.
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"shares": [
{
"id": "861559ac-906b-4dec-9c1f-3a2d2000745d",
"sourceTitle": "Test Document 3",
"sourcePath": "/doc/test-document-3-VHxABl5RaD",
"collectionId": null,
"documentId": "b1196971-4239-4e35-970f-7fbbc60af044",
"documentTitle": "Test Document 3",
"documentUrl": "/doc/test-document-3-VHxABl5RaD",
"published": false,
"url": "https://docs.psmattas.com/s/861559ac-906b-4dec-9c1f-3a2d2000745d",
"urlId": null,
"createdBy": {
"id": "1e2ef39c-aa82-475b-b5af-d76bb4f023ed",
"name": "Puranjay Savar Mattas",
"avatarUrl": "/api/files.get?key=public/avatar.png",
"color": "#1c9152",
"role": "admin",
"isSuspended": false,
"createdAt": "2025-07-30T17:36:58.560Z",
"updatedAt": "2026-08-14T16:47:45.037Z",
"deletedAt": null,
"lastActiveAt": "2026-08-14T16:47:45.037Z",
"timezone": "Europe/Dublin"
},
"includeChildDocuments": false,
"allowIndexing": false,
"allowSubscriptions": true,
"showLastUpdated": false,
"showTOC": false,
"title": null,
"iconUrl": null,
"views": 0,
"domain": null,
"createdAt": "2026-08-14T16:49:40.146Z",
"updatedAt": "2026-08-14T16:49:40.146Z"
}
]
},
"policies": [
{ "id": "861559ac-906b-4dec-9c1f-3a2d2000745d", "abilities": { "read": true, "update": false, "revoke": true } }
],
"status": 200,
"ok": true
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let share = try await client.shareInfo(documentId: "doc-1")
XCTAssertEqual(share?.id, "861559ac-906b-4dec-9c1f-3a2d2000745d")
XCTAssertEqual(share?.published, false)
XCTAssertEqual(share?.views, 0)
XCTAssertEqual(share?.createdBy?.name, "Puranjay Savar Mattas")
XCTAssertEqual(share?.documentId, "b1196971-4239-4e35-970f-7fbbc60af044")
}
func testListSharesDecodesSharesArray() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{ "id": "share-1", "documentId": "doc-1", "collectionId": null, "url": "https://outline.example.com/s/share-1", "published": true }
],
"pagination": { "offset": 0, "limit": 25 }
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let shares = try await client.listShares(ListSharesRequest())
XCTAssertEqual(shares.first?.id, "share-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.list")
}
func testRevokeShareSendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.revokeShare(id: "share-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.revoke")
}
func testShareInfoReturnsNilOnNotFound() async throws {
let httpClient = MockHTTPClient()
httpClient.statusCode = 404
@@ -628,6 +798,24 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertNil(share)
}
func testShareInfoReturnsNilOnEmptyBody() async throws {
// Confirmed against a live server: shares.info returns a 200 with a
// completely empty body (not a 404) when no share exists yet for a
// given document.
let httpClient = MockHTTPClient()
httpClient.responseData = Data()
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let share = try await client.shareInfo(documentId: "doc-1")
XCTAssertNil(share)
}
func testListViewsDecodesViewsAndFiltersNilLastViewedAt() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
@@ -676,6 +864,34 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create")
}
func testListPinsDecodesNestedPinsArray() async throws {
// Real shape confirmed against a live server: `data` is
// `{ pins: [...], documents: [...] }`, not a bare array.
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"pagination": { "limit": 25, "offset": 0 },
"data": {
"pins": [
{ "id": "pin-1", "documentId": "doc-1", "collectionId": null, "index": "h" }
],
"documents": []
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let pins = try await client.listPins(ListPinsRequest(collectionId: nil))
XCTAssertEqual(pins.first?.id, "pin-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.list")
}
func testCreateSubscriptionDecodesSubscription() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
@@ -694,6 +910,573 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/subscriptions.create")
}
func testAddDocumentUserDecodesMembership() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "data": { "id": "mem-1", "userId": "user-1", "documentId": "doc-1", "permission": "read" } }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let membership = try await client.addDocumentUser(
AddDocumentUserRequest(id: "doc-1", userId: "user-1", permission: "read")
)
XCTAssertEqual(membership.id, "mem-1")
XCTAssertEqual(membership.permission, "read")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.add_user")
}
func testRemoveDocumentUserSendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.removeDocumentUser(RemoveDocumentUserRequest(id: "doc-1", userId: "user-1"))
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.remove_user")
}
func testDocumentUsersDecodesMembersArray() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{ "id": "user-1", "name": "Jane Doe", "email": "jane@example.com", "avatarUrl": null, "permission": "read_write" }
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let members = try await client.documentUsers(ListDocumentUsersRequest(id: "doc-1"))
XCTAssertEqual(members.first?.name, "Jane Doe")
XCTAssertEqual(members.first?.permission, "read_write")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.users")
}
func testListUsersDecodesUsersArray() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{ "id": "user-1", "name": "Jane Doe", "email": "jane@example.com", "role": "member" }
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let users = try await client.listUsers(ListUsersRequest(query: "Jane"))
XCTAssertEqual(users.first?.name, "Jane Doe")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.list")
}
func testUpdateUserAvatarSendsExplicitNullWhenRemoving() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"avatarUrl": null
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserAvatar(UpdateUserAvatarRequest(id: "user-1", avatarUrl: nil))
XCTAssertNil(user.avatarUrl)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
// The whole point of UpdateUserAvatarRequest's custom encode(to:)
// Swift's synthesized Encodable would have omitted the key entirely
// for a nil Optional instead of sending a literal null.
XCTAssertTrue(sentJSON.contains("\"avatarUrl\":null"), "expected an explicit null, got: \(sentJSON)")
}
func testUpdateUserAvatarSendsTheNewURLWhenSet() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"avatarUrl": "/api/attachments.redirect?id=abc"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserAvatar(
UpdateUserAvatarRequest(id: "user-1", avatarUrl: "/api/attachments.redirect?id=abc")
)
XCTAssertEqual(user.avatarUrl, "/api/attachments.redirect?id=abc")
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
XCTAssertTrue(sentJSON.contains("\"id\":\"user-1\""))
}
func testUpdateUserNameSendsRequestAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "New Name"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserName(UpdateUserNameRequest(id: "user-1", name: "New Name"))
XCTAssertEqual(user.name, "New Name")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
}
func testUpdateUserLanguageSendsRequestAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"language": "fr_FR"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserLanguage(UpdateUserLanguageRequest(id: "user-1", language: "fr_FR"))
XCTAssertEqual(user.language, "fr_FR")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
}
func testUpdateUserPreferencesSendsWholeObjectAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"preferences": { "rememberLastPath": true, "useCursorPointer": true }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let preferences = OutlineUserPreferences(rememberLastPath: true, useCursorPointer: true)
let user = try await client.updateUserPreferences(UpdateUserPreferencesRequest(id: "user-1", preferences: preferences))
XCTAssertEqual(user.preferences?.rememberLastPath, true)
XCTAssertEqual(user.preferences?.useCursorPointer, true)
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
let sentPreferences = sentBody?["preferences"] as? [String: Any]
XCTAssertEqual(sentPreferences?["rememberLastPath"] as? Bool, true)
XCTAssertEqual(sentPreferences?["useCursorPointer"] as? Bool, true)
}
/// Locks in the wire mapping confirmed against a live server's own web
/// app traffic: `seamlessEdit`/`commentsInGutter`/`enableSmartText` are
/// the real keys (not `separateEditing`/`showCommentMarker`/`smartText`
/// this struct exposes), and `seamlessEdit` is the *negation* of this
/// app's `separateEditing`.
func testOutlineUserPreferencesDecodesRealWireKeys() throws {
let json = """
{
"seamlessEdit": false,
"commentsInGutter": true,
"enableSmartText": true,
"rememberLastPath": true,
"useCursorPointer": true,
"codeBlockLineNumbers": false,
"notificationBadge": "indicator",
"fullWidthDocuments": true
}
""".data(using: .utf8)!
let preferences = try JSONDecoder().decode(OutlineUserPreferences.self, from: json)
XCTAssertEqual(preferences.separateEditing, true, "seamlessEdit: false means separate editing is ON")
XCTAssertEqual(preferences.showCommentMarker, true)
XCTAssertEqual(preferences.smartText, true)
XCTAssertEqual(preferences.rememberLastPath, true)
XCTAssertEqual(preferences.useCursorPointer, true)
XCTAssertEqual(preferences.codeBlockLineNumbers, false)
XCTAssertEqual(preferences.notificationBadge, "indicator")
XCTAssertEqual(preferences.fullWidthDocuments, true)
}
func testOutlineUserPreferencesEncodesRealWireKeysAndInvertsSeparateEditing() throws {
var preferences = OutlineUserPreferences()
preferences.separateEditing = true
preferences.showCommentMarker = false
preferences.smartText = true
preferences.fullWidthDocuments = true
let data = try JSONEncoder().encode(preferences)
let object = try JSONSerialization.jsonObject(with: data) as? [String: Any]
XCTAssertEqual(object?["seamlessEdit"] as? Bool, false, "separateEditing: true must encode as seamlessEdit: false")
XCTAssertEqual(object?["commentsInGutter"] as? Bool, false)
XCTAssertEqual(object?["enableSmartText"] as? Bool, true)
XCTAssertEqual(object?["fullWidthDocuments"] as? Bool, true)
XCTAssertNil(object?["separateEditing"], "must not leak this app's own field name onto the wire")
XCTAssertNil(object?["showCommentMarker"])
XCTAssertNil(object?["smartText"])
}
func testSubscribeToNotificationsSendsEventTypeAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"notificationSettings": { "documents.publish": true }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.subscribeToNotifications(eventType: .documentPublish)
XCTAssertEqual(user.notificationSettings?["documents.publish"], true)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsSubscribe")
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertEqual(sentBody?["eventType"] as? String, "documents.publish")
}
func testUnsubscribeFromNotificationsWithNilEventTypeTargetsAll() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"notificationSettings": { "documents.publish": false }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
_ = try await client.unsubscribeFromNotifications(eventType: nil)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsUnsubscribe")
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertNil(sentBody?["eventType"])
}
func testListApiKeysDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"pagination": { "limit": 25, "offset": 0 },
"data": [
{
"id": "c3eec545-6d38-4065-90dc-b6c96a551445",
"name": "Outpost",
"scope": null,
"last4": "dl1h",
"createdAt": "2026-08-12T18:44:32.467Z",
"updatedAt": "2026-08-12T18:44:32.467Z",
"expiresAt": null,
"lastActiveAt": "2026-08-18T01:01:36.553Z"
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let keys = try await client.listApiKeys(ListApiKeysRequest())
XCTAssertEqual(keys.count, 1)
XCTAssertEqual(keys.first?.name, "Outpost")
XCTAssertEqual(keys.first?.last4, "dl1h")
XCTAssertNil(keys.first?.expiresAt)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.list")
}
func testInstallationInfoDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": { "version": "1.9.2", "latestVersion": "1.9.2", "versionsBehind": 0 },
"policies": []
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let info = try await client.installationInfo()
XCTAssertEqual(info.version, "1.9.2")
XCTAssertEqual(info.versionsBehind, 0)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/installation.info")
}
func testCreateApiKeyOmitsExpiresAtWhenNilAndDecodesValue() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683",
"name": "test",
"scope": null,
"value": "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv",
"last4": "0DGv",
"createdAt": "2026-08-18T15:39:29.872Z",
"expiresAt": null,
"lastActiveAt": null
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let key = try await client.createApiKey(CreateApiKeyRequest(name: "test"))
XCTAssertEqual(key.value, "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv")
XCTAssertNil(key.expiresAt)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.create")
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertEqual(sentBody?["name"] as? String, "test")
XCTAssertNil(sentBody?["expiresAt"], "omitting expiresAt (not sending null) is what produces a non-expiring key")
XCTAssertNil(sentBody?["scope"])
}
func testCreateApiKeySendsExpiresAtWhenProvided() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "04e204fb-51a4-4b54-aed1-2056dfd576d7",
"name": "Test",
"scope": null,
"value": "ol_api_aeYcOts7I2sJXw3zaztjydRM3W3W89TBAtcLts",
"last4": "cLts",
"createdAt": "2026-08-18T15:38:22.928Z",
"expiresAt": "2026-11-16T23:59:59.999Z",
"lastActiveAt": null
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let expiresAt = Date(timeIntervalSince1970: 1_795_000_000)
_ = try await client.createApiKey(CreateApiKeyRequest(name: "Test", expiresAt: expiresAt))
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertNotNil(sentBody?["expiresAt"])
}
func testDeleteApiKeySendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteApiKey(id: "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.delete")
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertEqual(sentBody?["id"] as? String, "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
}
func testDeleteAccountSendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteAccount()
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.delete")
}
func testDeleteAttachmentSendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteAttachment(id: "attach-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.delete")
}
func testCreateAttachmentDecodesUploadTargetAndFormFields() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"attachment": {
"id": "attach-1",
"documentId": null,
"contentType": "image/jpeg",
"name": "avatar.jpg",
"url": "/api/attachments.redirect?id=attach-1",
"size": "59304"
},
"uploadUrl": "/api/files.create",
"form": {
"Content-Type": "image/jpeg",
"key": "uploads/user-1/attach-1/avatar.jpg",
"acl": "public-read"
}
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let result = try await client.createAttachment(
CreateAttachmentRequest(name: "avatar.jpg", contentType: "image/jpeg", size: 59304)
)
XCTAssertEqual(result.attachment.id, "attach-1")
XCTAssertEqual(result.attachment.size, "59304")
XCTAssertEqual(result.uploadUrl, "/api/files.create")
XCTAssertEqual(result.form["acl"], "public-read")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.create")
}
func testUploadAttachmentFilePostsToTheResolvedRelativeURL() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let uploadTarget = CreateAttachmentResult(
attachment: OutlineAttachment(
id: "attach-1",
documentId: nil,
contentType: "image/jpeg",
name: "avatar.jpg",
url: "/api/attachments.redirect?id=attach-1",
size: "3"
),
uploadUrl: "/api/files.create",
form: ["Content-Type": "image/jpeg", "key": "uploads/attach-1"]
)
try await client.uploadAttachmentFile(uploadTarget, fileData: Data("abc".utf8))
// Resolved against baseURL's host, not appended onto "/api/<baseURL-relative-path>".
XCTAssertEqual(httpClient.lastRequest?.url?.absoluteString, "https://outline.example.com/api/files.create")
XCTAssertNil(httpClient.lastRequest?.value(forHTTPHeaderField: "Authorization"))
let contentType = httpClient.lastRequest?.value(forHTTPHeaderField: "Content-Type")
XCTAssertTrue(contentType?.hasPrefix("multipart/form-data; boundary=") ?? false)
}
func testMissingTokenThrowsTokenUnavailable() async throws {
let httpClient = MockHTTPClient()
let client = LiveOutlineAPIClient(
@@ -0,0 +1,48 @@
import XCTest
@testable import OutlineKit
final class MultipartFormDataBuilderTests: XCTestCase {
func testBuildIncludesEveryFieldAndTheFile() throws {
let fileData = Data("fake-jpeg-bytes".utf8)
let (body, contentType) = MultipartFormDataBuilder.build(
fields: ["key": "uploads/abc", "acl": "public-read", "Content-Type": "image/jpeg"],
fileFieldName: "file",
fileName: "avatar.jpg",
fileData: fileData,
fileContentType: "image/jpeg",
boundary: "TestBoundary"
)
let bodyString = String(decoding: body, as: UTF8.self)
XCTAssertEqual(contentType, "multipart/form-data; boundary=TestBoundary")
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"key\""))
XCTAssertTrue(bodyString.contains("uploads/abc"))
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"acl\""))
XCTAssertTrue(bodyString.contains("public-read"))
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"file\"; filename=\"avatar.jpg\""))
XCTAssertTrue(bodyString.contains("fake-jpeg-bytes"))
XCTAssertTrue(bodyString.hasPrefix("--TestBoundary\r\n"))
XCTAssertTrue(bodyString.hasSuffix("--TestBoundary--\r\n"))
}
func testFileFieldComesAfterAllFormFields() throws {
let (body, _) = MultipartFormDataBuilder.build(
fields: ["a": "1", "b": "2"],
fileFieldName: "file",
fileName: "x.jpg",
fileData: Data("bytes".utf8),
fileContentType: "image/jpeg",
boundary: "B"
)
let bodyString = String(decoding: body, as: UTF8.self)
let fieldsRange = bodyString.range(of: "name=\"a\"")
let fileRange = bodyString.range(of: "name=\"file\"")
XCTAssertNotNil(fieldsRange)
XCTAssertNotNil(fileRange)
if let fieldsRange, let fileRange {
XCTAssertTrue(fieldsRange.lowerBound < fileRange.lowerBound)
}
}
}
+24 -12
View File
@@ -299,7 +299,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -361,7 +361,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -385,15 +385,20 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -408,9 +413,10 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 0.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
MARKETING_VERSION = 0.0.4;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -430,15 +436,20 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -453,9 +464,10 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 0.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
MARKETING_VERSION = 0.0.4;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -476,7 +488,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
@@ -502,7 +514,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
@@ -527,7 +539,7 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
@@ -552,7 +564,7 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>Outpost.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
</dict>
</dict>
</plist>
+21
View File
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "outpost-ios-1024.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

+9 -31
View File
@@ -2,25 +2,17 @@
import AppKit
import SwiftUI
struct AboutView: View {
/// Bare content (icon, name, version, links) with no window chrome the
/// macOS "About Outpost" app-menu command now opens Settings' own About
/// section directly (no separate popup window), so this is its only caller.
struct AboutInfoView: View {
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
private let releasesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/releases")!
private var appName: String {
var appName: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
}
/// Bumped alongside `MARKETING_VERSION` in the Xcode project kept out
/// of the bundle version itself since `CFBundleShortVersionString` is
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
private let releaseStage = "ALPHA"
private var versionString: String {
let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
return "Version \(shortVersion)\(stageSuffix) (\(buildNumber))"
}
var versionString: String { OutpostVersion.fullVersionString }
private var copyrightYear: String {
String(Calendar.current.component(.year, from: Date()))
@@ -51,29 +43,15 @@ struct AboutView: View {
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity)
VStack(spacing: 10) {
Link(destination: repositoryURL) {
Label("View Source on Git", systemImage: "link")
}
.font(.callout)
Button("Check for Updates…") {
checkForUpdates()
}
Link(destination: repositoryURL) {
Label("View Source on Git", systemImage: "link")
}
.font(.callout)
Text("© \(copyrightYear) Puranjay Savar Mattas")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.padding(32)
.frame(width: 320)
}
// No Sparkle-style in-app updater yet this just opens the releases page
// on the self-hosted Gitea instance so the user can check/download manually.
private func checkForUpdates() {
NSWorkspace.shared.open(releasesURL)
}
}
#endif
+13 -2
View File
@@ -1,5 +1,6 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
/// contains the avatar image reliably broke its own sizing on click (even after
@@ -7,10 +8,11 @@ import SwiftUI
/// label rendering, which a `Button` doesn't go through.
struct AccountFooter: View {
@Environment(SessionStore.self) private var session
@Environment(AppNavigation.self) private var navigation
@Environment(\.openURL) private var openURL
@Environment(\.openWindow) private var openWindow
@Environment(\.openSettings) private var openSettings
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@State private var isMenuPresented = false
@State private var isShowingLogoutConfirmation = false
@State private var isShowingProfile = false
@@ -93,8 +95,17 @@ struct AccountFooter: View {
.padding(.horizontal, 6)
.padding(.vertical, 4)
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
.padding(.horizontal, 6)
.padding(.vertical, 4)
menuItem("Profile…") { isShowingProfile = true }
menuItem("Preferences…") { openSettings() }
// Deferred a tick: setting this synchronously in the same call
// that dismisses this popover collides two AppKit window/layer
// transactions in the same runloop turn (visible in the console
// as "Invalid attempt to open a new transaction during CA
// commit") letting the popover's dismissal finish first avoids it.
menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } }
Divider()
@@ -0,0 +1,114 @@
#if os(macOS)
import AppKit
import SwiftUI
/// Crop/rotate/zoom editor shown after picking a photo, before it's
/// uploaded pan (drag), zoom (pinch or the slider), and 90°-increment
/// rotate, all inside a circular mask matching how the avatar actually
/// renders everywhere else in the app.
///
/// The on-screen preview and the final exported image are built from the
/// exact same view composition (`avatarContent`), just instantiated once
/// for display and once inside an `ImageRenderer` that's deliberate:
/// hand-deriving a separate set of crop-math for a higher-resolution
/// render would risk it silently disagreeing with what the user actually
/// saw and confirmed, and there's no way to visually verify that
/// agreement without running the app. Reusing the identical view tree
/// makes the export WYSIWYG by construction instead of by careful math.
struct AvatarCropperView: View {
let sourceImage: NSImage
let onConfirm: (Data) -> Void
let onCancel: () -> Void
@State private var scale: CGFloat = 1
@State private var offset: CGSize = .zero
@State private var rotationDegrees: Double = 0
@GestureState private var dragTranslation: CGSize = .zero
/// Used for both the live preview and the exported image see the
/// type-level doc comment for why that's the same size, not two.
private let diameter: CGFloat = 320
var body: some View {
VStack(spacing: 20) {
Text("Edit Photo")
.font(.headline)
ZStack {
avatarContent
.clipShape(Circle())
Circle()
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
}
.frame(width: diameter, height: diameter)
.contentShape(Circle())
.gesture(
DragGesture()
.updating($dragTranslation) { value, state, _ in state = value.translation }
.onEnded { value in
offset.width += value.translation.width
offset.height += value.translation.height
}
)
HStack(spacing: 16) {
Button {
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees -= 90 }
} label: {
Image(systemName: "rotate.left")
}
.help("Rotate left")
Slider(value: $scale, in: 1...4)
.frame(width: 140)
Button {
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees += 90 }
} label: {
Image(systemName: "rotate.right")
}
.help("Rotate right")
}
HStack {
Button("Cancel", role: .cancel, action: onCancel)
Spacer()
Button("Use Photo") {
if let data = renderFinalImage() {
onConfirm(data)
}
}
.buttonStyle(.borderedProminent)
}
}
.padding(24)
.frame(width: 360)
}
/// Aspect-fills `sourceImage` into a `diameter`×`diameter` square, then
/// applies the user's pan/zoom/rotation on top identical between the
/// live preview and the final render (see the type-level doc comment).
private var avatarContent: some View {
Image(nsImage: sourceImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: diameter, height: diameter)
.scaleEffect(scale)
.rotationEffect(.degrees(rotationDegrees))
.offset(x: offset.width + dragTranslation.width, y: offset.height + dragTranslation.height)
.frame(width: diameter, height: diameter)
.clipped()
}
@MainActor
private func renderFinalImage() -> Data? {
let content = avatarContent
.clipShape(Circle())
.frame(width: diameter, height: diameter)
let renderer = ImageRenderer(content: content)
renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays
guard let nsImage = renderer.nsImage else { return nil }
return nsImage.jpegData(compressionQuality: 0.9)
}
}
#endif
@@ -1,40 +0,0 @@
#if os(macOS)
import SwiftUI
struct PreferencesView: View {
@Environment(SessionStore.self) private var session
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@State private var isShowingLogoutConfirmation = false
var body: some View {
Form {
Section("Appearance") {
Picker("Appearance", selection: $appearance) {
ForEach(AppAppearance.allCases) { option in
Text(option.label).tag(option)
}
}
.pickerStyle(.segmented)
.labelsHidden()
}
Section("Account") {
LabeledContent("Signed in as", value: session.userName ?? "")
if let email = session.userEmail {
LabeledContent("Email", value: email)
}
if let teamName = session.teamName {
LabeledContent("Workspace", value: teamName)
}
Button("Log Out…", role: .destructive) {
isShowingLogoutConfirmation = true
}
}
}
.formStyle(.grouped)
.frame(width: 380, height: 300)
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
}
}
#endif
@@ -0,0 +1,115 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// Swapped into the real sidebar's content slot (search field, collections
/// tree, account footer) while Settings is open same sidebar, different
/// content, rather than a separate mini sidebar nested inside a page. "Done"
/// clears `AppNavigation.isShowingSettings`, which puts the collections tree
/// back.
///
/// Grouped by `SettingsCategory` `general` (ours) sits under an "Outpost"
/// header at the top, then Outline's own Account/Workspace groups, matching
/// the settings page structure of the Outline web app. Outline's own server
/// version has no dedicated section (there used to be an Integrations &
/// Installation category for just that) it's cheap enough to show
/// unconditionally in the footer here instead, alongside Outpost's own
/// version.
struct SettingsSidebarList: View {
@Binding var selection: SettingsSection?
let onDone: () -> Void
@Environment(SessionStore.self) private var session
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@State private var outlineVersion: String?
/// Mirrors `SettingsView`'s own check a real dropped connection or
/// the manual Offline Mode toggle both mean there's no server to ask.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Settings")
.font(.headline)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
Divider()
List(selection: $selection) {
ForEach(SettingsCategory.allCases) { category in
let sections = SettingsSection.allCases.filter { $0.category == category }
Section {
ForEach(sections) { section in
Label(section.title, systemImage: section.icon)
.tag(section)
}
} header: {
if let title = category.title {
HStack(spacing: 6) {
Text(title)
// Not hardcoded to `.workspace` specifically
// stays correct on its own as sections get
// built, only shows while every section in
// the category is still `!isImplemented`.
if sections.allSatisfy({ !$0.isImplemented }) {
Text("Coming Soon")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.secondary.opacity(0.15), in: Capsule())
}
}
}
}
}
}
.listStyle(.sidebar)
Divider()
versionFooter
Divider()
Button("Done", action: onDone)
.keyboardShortcut(.cancelAction)
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
.padding(12)
}
// Keyed to connectivity, not a one-shot `.task {}` reconnecting
// (or turning the manual Offline Mode toggle back off) re-fires
// this automatically instead of leaving the footer stuck on
// whatever it last knew, or blank, until Settings is reopened.
.task(id: isEffectivelyOnline) { await refreshOutlineVersion() }
}
private var versionFooter: some View {
VStack(alignment: .leading, spacing: 2) {
Text("Outpost \(OutpostVersion.displayString)")
if let outlineVersion {
Text("Outline \(outlineVersion)")
} else if !isEffectivelyOnline {
Text("Outline — offline")
}
}
.font(.caption2)
.foregroundStyle(.tertiary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
private func refreshOutlineVersion() async {
guard isEffectivelyOnline, let apiClient = session.apiClient else { return }
outlineVersion = try? await apiClient.installationInfo().version
}
}
#endif
File diff suppressed because it is too large Load Diff
+11 -15
View File
@@ -3,21 +3,17 @@ import SwiftUI
struct AuthHeaderView: View {
var body: some View {
VStack(spacing: 12) {
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.accentColor, Color.accentColor.opacity(0.6)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 64, height: 64)
Image(systemName: "text.book.closed.fill")
.font(.system(size: 26, weight: .semibold))
.foregroundStyle(.white)
}
.shadow(color: Color.accentColor.opacity(0.35), radius: 12, y: 6)
// A plain Image Set, not the AppIcon *app icon* asset App Icon
// sets aren't reliably resolvable through Image(_:)/UIImage
// (named:) at runtime (confirmed live: showed nothing). This is
// the same source artwork (outpost-ios-1024.png) duplicated
// into a normal image set so SwiftUI can actually load it.
Image("AppLogo")
.resizable()
.scaledToFit()
.frame(width: 72, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 72 * 0.2237, style: .continuous))
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
VStack(spacing: 4) {
Text("Welcome to Outpost")
@@ -9,14 +9,27 @@ import OutlineKit
/// client-side rather than `collections.documents`.
struct CollectionDocumentsOutline: View {
let apiClient: OutlineAPIClient
let collection: OutlineCollection
@State private var viewModel: DocumentsViewModel
let sortOption: SidebarSortOption
let refreshToken: Int
/// Bumped from `ContentView_macOS` whenever a document is created from
/// somewhere that has no direct handle on this row the reader's
/// toolbar "New Document" button and Home's, specifically. Those can't
/// call `onDocumentsChanged()` the way a same-row sheet does, since they
/// don't know which (if any) sidebar row corresponds to where the new
/// document landed, so every expanded row just reloads itself.
let externalRefreshToken: Int
let selectedDocumentID: String?
/// Full chain from root to the clicked document (inclusive) lets the
/// toolbar render the real hierarchy instead of just the leaf title.
let onSelectDocument: ([OutlineDocument]) -> Void
/// Loaded once per collection (`pins.list` is collection-scoped) rather
/// than per-row a per-row `pins.list`/lookup would be an N+1 call for
/// every document in the tree.
@State private var pinsByDocumentID: [String: OutlinePin] = [:]
private var tree: [DocumentNode] {
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
}
@@ -26,13 +39,16 @@ struct CollectionDocumentsOutline: View {
collection: OutlineCollection,
sortOption: SidebarSortOption,
refreshToken: Int,
externalRefreshToken: Int,
selectedDocumentID: String?,
onSelectDocument: @escaping ([OutlineDocument]) -> Void
) {
self.apiClient = apiClient
self.collection = collection
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
self.sortOption = sortOption
self.refreshToken = refreshToken
self.externalRefreshToken = externalRefreshToken
self.selectedDocumentID = selectedDocumentID
self.onSelectDocument = onSelectDocument
}
@@ -56,13 +72,26 @@ struct CollectionDocumentsOutline: View {
depth: 0,
ancestors: [],
selectedDocumentID: selectedDocumentID,
pinsByDocumentID: pinsByDocumentID,
onSelectDocument: onSelectDocument,
onDocumentsChanged: { await viewModel.load() }
onDocumentsChanged: { await viewModel.load() },
onPinsChanged: { await loadPins() }
)
}
}
}
.task(id: refreshToken) { await viewModel.load() }
// Combined into one identity rather than two separate `.task(id:)`
// modifiers each of those fires once unconditionally on first
// appear, so two of them would double the initial load.
.task(id: "\(refreshToken)-\(externalRefreshToken)") {
await viewModel.load()
await loadPins()
}
}
private func loadPins() async {
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) else { return }
pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) })
}
}
@@ -81,8 +110,10 @@ private struct DocumentNodeRow: View {
/// Chain from root down to (not including) this node.
let ancestors: [OutlineDocument]
let selectedDocumentID: String?
let pinsByDocumentID: [String: OutlinePin]
let onSelectDocument: ([OutlineDocument]) -> Void
let onDocumentsChanged: () async -> Void
let onPinsChanged: () async -> Void
@State private var isExpanded = false
@@ -96,6 +127,7 @@ private struct DocumentNodeRow: View {
@State private var isShowingInsightsSheet = false
@State private var isShowingPresentSheet = false
@State private var isShowingSearchSheet = false
@State private var isShowingNewDocumentSheet = false
@State private var actionErrorMessage: String?
private var isSelected: Bool {
@@ -172,8 +204,10 @@ private struct DocumentNodeRow: View {
depth: depth + 1,
ancestors: ancestors + [node.document],
selectedDocumentID: selectedDocumentID,
pinsByDocumentID: pinsByDocumentID,
onSelectDocument: onSelectDocument,
onDocumentsChanged: onDocumentsChanged
onDocumentsChanged: onDocumentsChanged,
onPinsChanged: onPinsChanged
)
}
}
@@ -252,6 +286,11 @@ private struct DocumentNodeRow: View {
.sheet(isPresented: $isShowingSearchSheet) {
DocumentSearchSheet(apiClient: apiClient, document: node.document)
}
.sheet(isPresented: $isShowingNewDocumentSheet) {
NewDocumentSheet(apiClient: apiClient, initialParentDocument: node.document) { _ in
Task { await onDocumentsChanged() }
}
}
}
@ViewBuilder
@@ -259,7 +298,11 @@ private struct DocumentNodeRow: View {
Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "Star") {
Task { await star() }
}
// No `subscriptions.*` endpoint in the API nothing to back this with.
// subscriptions.* does exist and works (confirmed against a live
// server via the reader's menu) not shown here because
// subscriptions.list is per-document, so reflecting accurate
// per-row state for every document in the tree would mean an N+1
// call storm. Use the reader's menu instead.
Button("Unsubscribe") {}
.disabled(true)
@@ -273,8 +316,9 @@ private struct DocumentNodeRow: View {
renameText = node.document.title
isShowingRenameAlert = true
}
// Sharing/membership management is its own subsystem, not a one-off
// action deferred rather than half-built here.
// DocumentShareSheet now has a real "People with access" section
// this row just doesn't have a sheet wired up to present it yet
// (only the reader toolbar does). Use the reader's menu instead.
Button("Permissions…") {}
.disabled(true)
@@ -303,11 +347,13 @@ private struct DocumentNodeRow: View {
Button("Import Document…") {}
.disabled(true)
Button("New Document") {
Task { await createChildDocument() }
isShowingNewDocumentSheet = true
}
// Scoped to this collection (`collection.id`) "Pin to Collection",
// distinct from the reader toolbar's "Pin to Home" (collectionId: nil).
Button(pinsByDocumentID[node.document.id] != nil ? "Unpin from Collection" : "Pin to Collection") {
Task { await togglePin() }
}
// No `pins.*` endpoint in the API nothing to back this with.
Button("Pin") {}
.disabled(true)
Divider()
@@ -351,6 +397,19 @@ private struct DocumentNodeRow: View {
}
}
private func togglePin() async {
do {
if let pin = pinsByDocumentID[node.document.id] {
try await apiClient.deletePin(id: pin.id)
} else {
_ = try await apiClient.createPin(CreatePinRequest(documentId: node.document.id, collectionId: node.document.collectionId))
}
await onPinsChanged()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update pin state.")
}
}
private func rename() async {
do {
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: node.document.id, title: renameText))
@@ -395,26 +454,6 @@ private struct DocumentNodeRow: View {
}
}
private func createChildDocument() async {
guard let collectionId = node.document.collectionId else {
actionErrorMessage = "This document isn't in a collection."
return
}
do {
_ = try await apiClient.createDocument(
CreateDocumentRequest(
title: "Untitled",
text: "",
collectionId: collectionId,
parentDocumentId: node.document.id
)
)
await onDocumentsChanged()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.")
}
}
private func delete() async {
do {
try await apiClient.deleteDocument(DeleteDocumentRequest(id: node.document.id))
@@ -10,7 +10,7 @@ struct CollectionRowView: View {
} icon: {
if let emoji = collection.emojiIcon {
Text(emoji)
} else if let symbolName = collection.icon.flatMap(OutlineIconMapping.sfSymbolName) {
} else if let symbolName = collection.icon.flatMap({ OutlineIconMapping.sfSymbolName(for: $0) }) {
Image(systemName: symbolName)
.foregroundStyle(tintColor)
} else {
@@ -11,6 +11,8 @@ struct CollectionTreeRow: View {
let isExpanded: Bool
let isSelected: Bool
let selectedDocumentID: String?
/// See the identical parameter on `CollectionDocumentsOutline`.
let externalRefreshToken: Int
let onToggle: () -> Void
let onSelectDocument: ([OutlineDocument]) -> Void
let onSearchInCollection: (OutlineCollection) -> Void
@@ -22,6 +24,7 @@ struct CollectionTreeRow: View {
@State private var isShowingRenameAlert = false
@State private var renameText = ""
@State private var isShowingDeleteConfirmation = false
@State private var isShowingNewDocumentSheet = false
@State private var actionErrorMessage: String?
var body: some View {
@@ -62,6 +65,7 @@ struct CollectionTreeRow: View {
collection: collection,
sortOption: sortOption,
refreshToken: documentsRefreshToken,
externalRefreshToken: externalRefreshToken,
selectedDocumentID: selectedDocumentID,
onSelectDocument: onSelectDocument
)
@@ -92,6 +96,12 @@ struct CollectionTreeRow: View {
} message: {
Text(actionErrorMessage ?? "")
}
.sheet(isPresented: $isShowingNewDocumentSheet) {
NewDocumentSheet(apiClient: apiClient, initialCollectionID: collection.id) { _ in
documentsRefreshToken += 1
Task { await onCollectionsChanged() }
}
}
}
@ViewBuilder
@@ -103,7 +113,7 @@ struct CollectionTreeRow: View {
Divider()
Button("New Document") {
Task { await createDocument() }
isShowingNewDocumentSheet = true
}
Divider()
@@ -148,18 +158,6 @@ struct CollectionTreeRow: View {
}
}
private func createDocument() async {
do {
_ = try await apiClient.createDocument(
CreateDocumentRequest(title: "Untitled", text: "", collectionId: collection.id)
)
documentsRefreshToken += 1
await onCollectionsChanged()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.")
}
}
private func rename() async {
do {
_ = try await apiClient.updateCollection(UpdateCollectionRequest(id: collection.id, name: renameText))
@@ -7,6 +7,12 @@ struct CollectionsTreeView: View {
@Binding private var selectedCollection: OutlineCollection?
let selectedDocumentID: String?
@State private var expandedCollectionIDs: Set<String> = []
/// Bumped from `ContentView_macOS` whenever a document is created
/// somewhere with no direct handle on the sidebar row it belongs
/// under see the identical parameter on `CollectionDocumentsOutline`.
let externalRefreshToken: Int
let isShowingHome: Bool
let onSelectHome: () -> Void
let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void
let onSearchInCollection: (OutlineCollection) -> Void
@@ -14,12 +20,18 @@ struct CollectionsTreeView: View {
apiClient: OutlineAPIClient,
selectedCollection: Binding<OutlineCollection?>,
selectedDocumentID: String?,
externalRefreshToken: Int,
isShowingHome: Bool,
onSelectHome: @escaping () -> Void,
onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void,
onSearchInCollection: @escaping (OutlineCollection) -> Void
) {
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
_selectedCollection = selectedCollection
self.selectedDocumentID = selectedDocumentID
self.externalRefreshToken = externalRefreshToken
self.isShowingHome = isShowingHome
self.onSelectHome = onSelectHome
self.onSelectDocument = onSelectDocument
self.onSearchInCollection = onSearchInCollection
}
@@ -31,6 +43,7 @@ struct CollectionsTreeView: View {
Task { await viewModel.load() }
}
}
homeRow
content
}
.task {
@@ -42,6 +55,32 @@ struct CollectionsTreeView: View {
}
}
/// Pinned above the collections list, not inside the scroll region
/// Home isn't a collection, so it doesn't belong in `viewModel.collections`
/// or compete with them for scroll space.
private var homeRow: some View {
Button(action: onSelectHome) {
HStack(spacing: 6) {
Image(systemName: "house.fill")
.font(.callout)
.foregroundStyle(.secondary)
Text("Home")
.font(.body)
Spacer(minLength: 0)
}
.padding(.vertical, 4)
.padding(.horizontal, 6)
.contentShape(Rectangle())
.background(
isShowingHome ? Color.accentColor.opacity(0.15) : Color.clear,
in: RoundedRectangle(cornerRadius: 6)
)
}
.buttonStyle(.plain)
.padding(.horizontal, 8)
.padding(.top, 4)
}
@ViewBuilder
private var content: some View {
Group {
@@ -81,6 +120,7 @@ struct CollectionsTreeView: View {
isExpanded: expandedCollectionIDs.contains(collection.id),
isSelected: selectedCollection?.id == collection.id,
selectedDocumentID: selectedDocumentID,
externalRefreshToken: externalRefreshToken,
onToggle: { toggle(collection) },
onSelectDocument: { chain in onSelectDocument(collection, chain) },
onSearchInCollection: onSearchInCollection,
@@ -95,13 +135,6 @@ struct CollectionsTreeView: View {
}
.task {
await viewModel.load()
// Stand-in for Outline's own configured "Start view" we don't have
// a confirmed schema for `team.preferences` to read the actual
// setting, so this defaults to the first collection instead of
// landing on an empty "No Collection Selected" placeholder.
if selectedCollection == nil, let first = viewModel.collections.first {
selectedCollection = first
}
}
// A document opened from outside the sidebar (detail pane's list,
// global search) wouldn't otherwise expand its collection here, so
@@ -4,6 +4,12 @@ import OutlineKit
struct ContentView_macOS: View {
@Environment(SessionStore.self) private var session
@Environment(AppNavigation.self) private var navigation
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
/// The landing state no collection selected yet is what Home actually
/// means, so this starts `true` rather than auto-selecting the first
/// collection the way this used to work.
@State private var isShowingHome = true
@State private var selectedCollection: OutlineCollection?
/// The real navigation stack, root to leaf also the source of truth for
/// the toolbar breadcrumb, so the two can't drift out of sync.
@@ -12,16 +18,83 @@ struct ContentView_macOS: View {
@State private var contextualSearchQuery = ""
@State private var isContextualSearchExpanded = false
@FocusState private var isContextualSearchFocused: Bool
/// Bumped whenever a document is created from somewhere with no direct
/// handle on the sidebar row it belongs under (the reader toolbar's and
/// Home's "New Document" buttons) every expanded sidebar row reloads
/// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`.
@State private var documentsChangedToken = 0
private var trimmedGlobalQuery: String {
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// `@Environment(AppNavigation.self)` doesn't hand out `$`-bindings on its
/// own (that's `@Bindable`'s job, and introducing one in `body` would
/// mean restructuring it away from a single implicit-return expression)
/// a manually-built `Binding` over the same reference is simpler here.
private var selectedSettingsSectionBinding: Binding<SettingsSection?> {
Binding(
get: { navigation.selectedSettingsSection },
set: { navigation.selectedSettingsSection = $0 }
)
}
var body: some View {
// A totally separate top-level branch, not content swapped inside
// one persistent `NavigationSplitView` that was tried first (an
// `if/else` inside a single split view, then an explicit `.id()` on
// just the detail pane) and neither reliably replaced the detail
// pane's content on macOS: `NavigationSplitView` bridges to
// `NSSplitViewController`, and swapping a `NavigationStack` with
// real push history for a plain view inside one persisting instance
// doesn't propagate the way plain SwiftUI identity rules would
// suggest. A different `if` branch is a genuinely different view
// hierarchy, so there's no existing split view instance for AppKit
// to get confused about reusing.
if navigation.isShowingSettings {
settingsContent
} else {
mainContent
}
}
private var settingsContent: some View {
NavigationSplitView {
SettingsSidebarList(
selection: selectedSettingsSectionBinding,
onDone: { navigation.isShowingSettings = false }
)
.navigationSplitViewColumnWidth(min: 220, ideal: 260)
} detail: {
SettingsView(section: navigation.selectedSettingsSection ?? .appearance)
}
.navigationTitle("")
.toolbar {
ToolbarItem(placement: .navigation) {
HStack(spacing: 6) {
Image(systemName: "gearshape.fill")
Text("Settings")
}
.font(.headline)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
}
}
private var mainContent: some View {
NavigationSplitView {
VStack(spacing: 0) {
SidebarSearchField(text: $globalSearchQuery)
Divider()
if isOfflineModeEnabled {
OfflineBanner(isManual: true)
Divider()
} else if !session.networkMonitor.isOnline {
OfflineConnectionPromptBanner(onEnableOfflineMode: { isOfflineModeEnabled = true })
Divider()
}
sidebar
AccountFooter()
}
@@ -43,17 +116,48 @@ struct ContentView_macOS: View {
// mutually exclusive: whenever a document's pushed (back button
// visible), this shows the document hierarchy instead of falling
// back to the workspace badge.
ToolbarItem(placement: .navigation) {
Button {
goHome()
} label: {
Image(systemName: "house")
}
.help("Home")
}
ToolbarItem(placement: .navigation) {
leadingToolbarContent
}
// Custom instead of `.searchable`: that modifier always renders a
// full-width field, but this is meant to sit alongside the other
// per-document toolbar buttons as a plain icon that only expands
// into a field once clicked.
ToolbarItem(placement: .primaryAction) {
contextualSearchField
// into a field once clicked. Hidden on the Home landing page
// itself `contextualSearchQuery` is only ever read by
// `CollectionOverviewView`, so on Home it was a dead end: a
// user could click it, type, and nothing would happen. The
// sidebar's global search already covers "search everything."
if !(isShowingHome && documentPath.isEmpty) {
ToolbarItem(placement: .primaryAction) {
contextualSearchField
}
}
}
// Any explicit collection pick sidebar click, "Search in
// Collection" means the user has navigated away from Home.
.onChange(of: selectedCollection) { _, newValue in
if newValue != nil {
isShowingHome = false
}
}
}
private func goHome() {
globalSearchQuery = ""
contextualSearchQuery = ""
isContextualSearchExpanded = false
selectedCollection = nil
isShowingHome = true
navigation.isShowingSettings = false
replaceDocumentPath(with: [])
}
@ViewBuilder
@@ -106,12 +210,19 @@ struct ContentView_macOS: View {
Image(systemName: "magnifyingglass")
Text("Search")
}
} else if !documentPath.isEmpty, let selectedCollection {
// Collection every ancestor (icon only) current document
// (icon + full title) ancestors stay icon-only so a deep
// chain doesn't blow out the toolbar width.
} else if !documentPath.isEmpty {
// Origin (collection, or Home if opened from there) every
// ancestor (icon only) current document (icon + full
// title) ancestors stay icon-only so a deep chain doesn't
// blow out the toolbar width. Checked before `isShowingHome`
// since opening a document from Home still leaves that flag
// set the pushed document should win either way.
HStack(spacing: 6) {
CollectionRowView(collection: selectedCollection)
if let selectedCollection {
CollectionRowView(collection: selectedCollection)
} else {
Image(systemName: "house.fill")
}
ForEach(Array(documentPath.enumerated()), id: \.element.id) { index, document in
Image(systemName: "chevron.right")
@@ -130,6 +241,11 @@ struct ContentView_macOS: View {
}
}
}
} else if isShowingHome {
HStack(spacing: 6) {
Image(systemName: "house.fill")
Text("Home")
}
} else if let selectedCollection {
CollectionRowView(collection: selectedCollection)
} else {
@@ -153,6 +269,9 @@ struct ContentView_macOS: View {
apiClient: apiClient,
selectedCollection: $selectedCollection,
selectedDocumentID: documentPath.last?.id,
externalRefreshToken: documentsChangedToken,
isShowingHome: isShowingHome,
onSelectHome: goHome,
onSelectDocument: selectDocumentChain,
onSearchInCollection: searchInCollection
)
@@ -205,6 +324,8 @@ struct ContentView_macOS: View {
Group {
if !trimmedGlobalQuery.isEmpty {
GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery, onOpenDocument: openDocument)
} else if isShowingHome {
HomeView(apiClient: apiClient, onOpenDocument: openDocument, onDocumentCreated: { documentsChangedToken += 1 })
} else if let selectedCollection {
CollectionOverviewView(
apiClient: apiClient,
@@ -229,11 +350,12 @@ struct ContentView_macOS: View {
if !documentPath.isEmpty {
documentPath.removeLast()
}
}
},
onDocumentCreated: { documentsChangedToken += 1 }
)
}
}
.id(trimmedGlobalQuery.isEmpty ? (selectedCollection?.id ?? "none") : "search")
.id(trimmedGlobalQuery.isEmpty ? (isShowingHome ? "home" : (selectedCollection?.id ?? "none")) : "search")
} else {
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
}
@@ -12,6 +12,7 @@ import OutlineKit
struct DocumentReaderView: View {
@Environment(SessionStore.self) private var session
@Environment(StarStore.self) private var starStore
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@State private var viewModel: DocumentReaderViewModel
let apiClient: OutlineAPIClient
@@ -23,6 +24,11 @@ struct DocumentReaderView: View {
/// longer visible in the collection it was opened from, so the reader
/// pops itself off the navigation stack.
let onDeleted: () -> Void
/// The reader's own "New Document" toolbar button has no direct handle
/// on the sidebar row it belongs under this tells the sidebar a
/// document exists now so it can pick it up. See
/// `CollectionDocumentsOutline.externalRefreshToken`.
let onDocumentCreated: () -> Void
@State private var isShowingUnpublishConfirmation = false
@State private var isShowingArchiveConfirmation = false
@@ -33,19 +39,31 @@ struct DocumentReaderView: View {
@State private var isShowingPresentSheet = false
@State private var isShowingSearchSheet = false
@State private var isShowingShareSheet = false
@State private var isShowingNewDocumentSheet = false
@State private var actionErrorMessage: String?
init(
apiClient: OutlineAPIClient,
document: OutlineDocument,
onOpenChild: @escaping (OutlineDocument) -> Void,
onDeleted: @escaping () -> Void
onDeleted: @escaping () -> Void,
onDocumentCreated: @escaping () -> Void
) {
self.apiClient = apiClient
self.document = document
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
self.onOpenChild = onOpenChild
self.onDeleted = onDeleted
self.onDocumentCreated = onDocumentCreated
}
/// New Document, editing, pin/star/subscribe, and Full Width all queue
/// and sync later (see `CachingOutlineAPIClient`) everything else here
/// (sharing, permissions, move/archive/delete/duplicate/templatize,
/// history, insights, export) hits the server directly with no offline
/// path, so it's disabled rather than left to fail confusingly on tap.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
var body: some View {
@@ -74,6 +92,7 @@ struct DocumentReaderView: View {
NativeTextViewWrapper(
text: $viewModel.text,
configuration: .init(heightBehavior: .fitsContent),
documentId: viewModel.documentId,
isEditable: viewModel.isEditing
)
@@ -102,7 +121,11 @@ struct DocumentReaderView: View {
} label: {
Image(systemName: "square.and.arrow.up")
}
.help("Share")
.help(isEffectivelyOnline ? "Share" : "Sharing needs an internet connection")
.disabled(!isEffectivelyOnline)
.popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) {
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
}
Button {
Task { await viewModel.toggleEditing() }
@@ -116,7 +139,7 @@ struct DocumentReaderView: View {
.disabled(viewModel.isSaving)
Button {
Task { await createChildDocument() }
isShowingNewDocumentSheet = true
} label: {
Image(systemName: "doc.badge.plus")
}
@@ -127,12 +150,22 @@ struct DocumentReaderView: View {
} label: {
Image(systemName: "ellipsis.circle")
}
// SwiftUI's macOS `Menu` doesn't reliably re-evaluate a
// `Toggle`'s checkmark against updated @Observable state on
// its own without a fresh `.id()` per state combination,
// toggling Subscribed/Viewer Insights/Full Width kept
// showing the pre-toggle checkmark until the whole view was
// torn down and rebuilt (e.g. navigating away and back).
.id(menuIdentity)
}
}
.task { await viewModel.loadFullContent() }
.task {
await viewModel.loadPinAndSubscriptionState()
}
.task {
await viewModel.loadInsightsEnabledState()
}
.task {
while !Task.isCancelled {
await viewModel.loadViewers()
@@ -197,14 +230,34 @@ struct DocumentReaderView: View {
.sheet(isPresented: $isShowingSearchSheet) {
DocumentSearchSheet(apiClient: apiClient, document: document)
}
.sheet(isPresented: $isShowingShareSheet) {
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
.sheet(isPresented: $isShowingNewDocumentSheet) {
NewDocumentSheet(apiClient: apiClient, initialParentDocument: document) { child in
onDocumentCreated()
onOpenChild(child)
}
}
}
/// Every toggle-backed piece of state shown as a checkmark inside
/// `menuContent` see the `.id()` comment on the `Menu` above.
private var menuIdentity: String {
[
starStore.isStarred(documentId: viewModel.documentId),
viewModel.isSubscribed,
viewModel.isPinned,
viewModel.isInsightsEnabled ?? false,
viewModel.isFullWidth,
viewModel.isEditing,
isEffectivelyOnline
].map(String.init).joined(separator: "-")
}
@ViewBuilder
private var viewerAvatars: some View {
if !viewModel.viewers.isEmpty {
// Tied to the Viewer Insights toggle that's the feature this data
// belongs to, so turning it off should hide the avatars immediately
// rather than leaving them showing until the view reloads.
if viewModel.isInsightsEnabled == true, !viewModel.viewers.isEmpty {
HStack(spacing: -6) {
ForEach(viewModel.viewers.prefix(5)) { viewer in
AvatarBadge(
@@ -249,48 +302,57 @@ struct DocumentReaderView: View {
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
Task { await star() }
}
Button(viewModel.isSubscribed ? "Unsubscribe" : "Subscribe") {
Task { await toggleSubscription() }
}
Toggle("Subscribed", isOn: Binding(
get: { viewModel.isSubscribed },
set: { _ in Task { await toggleSubscription() } }
))
Divider()
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
Task { await viewModel.toggleEditing() }
}
// Sharing/membership management is its own subsystem, not a one-off
// action deferred rather than half-built here.
Button("Permissions…") {}
.disabled(true)
// Membership management now lives in DocumentShareSheet's "People
// with access" section, alongside the share link same sheet,
// same isShowingShareSheet state.
Button("Permissions…") {
isShowingShareSheet = true
}
.disabled(!isEffectivelyOnline)
Divider()
Button("Templatize") {
Task { await templatize() }
}
.disabled(!isEffectivelyOnline)
Button("Duplicate") {
Task { await duplicate() }
}
.disabled(!isEffectivelyOnline)
Button("Unpublish") {
isShowingUnpublishConfirmation = true
}
.disabled(!isEffectivelyOnline)
Button("Archive…") {
isShowingArchiveConfirmation = true
}
.disabled(!isEffectivelyOnline)
Divider()
Button("Move") {
isShowingMoveSheet = true
}
.disabled(!isEffectivelyOnline)
// Multipart file upload is its own subsystem deferred rather than
// half-built here.
Button("Import Document…") {}
.disabled(true)
Button("New Document") {
Task { await createChildDocument() }
isShowingNewDocumentSheet = true
}
Button(viewModel.isPinned ? "Unpin" : "Pin") {
Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") {
Task { await togglePin() }
}
@@ -299,9 +361,13 @@ struct DocumentReaderView: View {
Button("History") {
isShowingHistorySheet = true
}
.disabled(!isEffectivelyOnline)
Button("Insights") {
isShowingInsightsSheet = true
}
.disabled(!isEffectivelyOnline)
// Present/Search in Document both read `documents.info`, which is
// read-through cached they work offline on whatever's cached.
Button("Present") {
isShowingPresentSheet = true
}
@@ -311,6 +377,7 @@ struct DocumentReaderView: View {
Button("Download") {
Task { await download() }
}
.disabled(!isEffectivelyOnline)
Button("Copy") {
Task { await copyMarkdown() }
}
@@ -323,21 +390,28 @@ struct DocumentReaderView: View {
Divider()
Button("Enable Viewer Insights") {
Task { await enableInsights() }
}
Button("Enable Embeds") {
Task { await enableEmbeds() }
}
Button(viewModel.isFullWidth ? "Default Width" : "Full Width") {
Task { await toggleFullWidth() }
}
Toggle("Viewer Insights", isOn: Binding(
get: { viewModel.isInsightsEnabled ?? false },
set: { _ in Task { await toggleInsights() } }
))
.disabled(!isEffectivelyOnline)
// Confirmed against a live server: there's no per-document embeds
// field. Only a workspace-level setting exists, and that's not
// reachable via the API either (no `team.update` endpoint in the
// vendored spec) disabled rather than kept as a broken action.
Button("Enable Embeds") {}
.disabled(true)
Toggle("Full Width", isOn: Binding(
get: { viewModel.isFullWidth },
set: { _ in Task { await toggleFullWidth() } }
))
Divider()
Button("Delete…", role: .destructive) {
isShowingDeleteConfirmation = true
}
.disabled(!isEffectivelyOnline)
}
private func star() async {
@@ -372,19 +446,11 @@ struct DocumentReaderView: View {
}
}
private func enableInsights() async {
private func toggleInsights() async {
do {
try await viewModel.enableViewerInsights()
try await viewModel.toggleViewerInsights()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable viewer insights.")
}
}
private func enableEmbeds() async {
do {
try await viewModel.enableEmbeds()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable embeds.")
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update viewer insights.")
}
}
@@ -431,21 +497,6 @@ struct DocumentReaderView: View {
}
}
private func createChildDocument() async {
guard let collectionId = viewModel.collectionId else {
actionErrorMessage = "This document isn't in a collection."
return
}
do {
let child = try await apiClient.createDocument(
CreateDocumentRequest(title: "Untitled", text: "", collectionId: collectionId, parentDocumentId: viewModel.documentId)
)
onOpenChild(child)
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.")
}
}
private func download() async {
do {
let markdown = try await apiClient.exportDocument(id: viewModel.documentId)
@@ -28,7 +28,12 @@ final class DocumentReaderViewModel {
private var pinId: String?
private(set) var isSubscribed = false
private var subscriptionId: String?
private(set) var share: OutlineShare?
/// `nil` until checked. Inferred from whether `documents.insights`
/// succeeds or fails `insightsEnabled` isn't readable back off
/// `Document` in the vendored spec, so there's no direct field to read.
/// This is a heuristic, not confirmed server behavior.
private(set) var isInsightsEnabled: Bool?
let documentId: String
private let apiClient: OutlineAPIClient
@@ -75,7 +80,9 @@ final class DocumentReaderViewModel {
}
func loadPinAndSubscriptionState() async {
if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collectionId)),
// `collectionId: nil` = Home pins. This menu's Pin action is "Pin to
// Home", not "Pin to Collection" those are distinct on the server.
if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)),
let match = pins.first(where: { $0.documentId == documentId }) {
isPinned = true
pinId = match.id
@@ -94,8 +101,13 @@ final class DocumentReaderViewModel {
}
}
func loadShare() async {
share = try? await apiClient.shareInfo(documentId: documentId)
func loadInsightsEnabledState() async {
do {
_ = try await apiClient.documentInsights(DocumentInsightsRequest(id: documentId))
isInsightsEnabled = true
} catch {
isInsightsEnabled = false
}
}
func togglePin() async throws {
@@ -110,7 +122,7 @@ final class DocumentReaderViewModel {
throw error
}
} else {
let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: collectionId))
let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: nil))
pinId = pin.id
isPinned = true
}
@@ -134,10 +146,6 @@ final class DocumentReaderViewModel {
}
}
func createOrLoadShare() async throws {
share = try await apiClient.createShare(CreateShareRequest(documentId: documentId))
}
/// Turning editing off saves; turning it on is just a mode switch.
func toggleEditing() async {
guard isEditing else {
@@ -168,14 +176,14 @@ final class DocumentReaderViewModel {
}
}
/// Fire-and-forget: `insightsEnabled` isn't readable back off `Document`
/// in the vendored spec, so there's no state to reflect as a checkmark.
func enableViewerInsights() async throws {
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: true))
}
/// Fire-and-forget, speculative field see `UpdateDocumentRequest.documentEmbeds`.
func enableEmbeds() async throws {
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, documentEmbeds: true))
func toggleViewerInsights() async throws {
let newValue = !(isInsightsEnabled ?? false)
isInsightsEnabled = newValue
do {
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: newValue))
} catch {
isInsightsEnabled = !newValue
throw error
}
}
}
@@ -3,68 +3,306 @@ import AppKit
import SwiftUI
import OutlineKit
/// Content of the Share popover anchored to the reader toolbar's Share
/// button (see `DocumentReaderView`'s `.popover(isPresented:)`). Was a
/// modal `.sheet` originally moved to a popover so it reads as "options
/// for this button" instead of interrupting the whole window.
@MainActor
struct DocumentShareSheet: View {
@Environment(\.dismiss) private var dismiss
let apiClient: OutlineAPIClient
let documentId: String
@State private var share: OutlineShare?
@State private var isLoading = false
@State private var isUpdating = false
@State private var errorMessage: String?
@State private var titleOverride = ""
@State private var isLoadingShare = false
@State private var isUpdatingShare = false
@State private var isRevoking = false
@State private var isShowingRevokeConfirmation = false
@State private var isShowingTitleField = false
@State private var shareErrorMessage: String?
@State private var didCopy = false
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Share")
.font(.headline)
Spacer()
Button("Done") { dismiss() }
}
@State private var members: [OutlineDocumentMember] = []
@State private var isLoadingMembers = false
@State private var isShowingAddPerson = false
@State private var userSearchQuery = ""
@State private var userSearchResults: [OutlineUser] = []
@State private var isSearchingUsers = false
@State private var selectedPermission = "read"
@State private var isAddingUser = false
@State private var actionErrorMessage: String?
if isLoading {
ProgressView().frame(maxWidth: .infinity)
} else if let errorMessage {
Text(errorMessage)
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Share")
.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)
.foregroundStyle(.red)
} else if let share {
HStack {
Text(share.url)
.font(.callout)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Button {
copyLink(share.url)
} label: {
Image(systemName: didCopy ? "checkmark" : "doc.on.doc")
VStack(alignment: .leading, spacing: 6) {
if let url = share.url {
HStack(spacing: 8) {
Image(systemName: "globe")
.foregroundStyle(.secondary)
.font(.callout)
Text(url)
.font(.callout)
.lineLimit(1)
.truncationMode(.middle)
Spacer(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)
}
.padding(8)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6))
.font(.caption)
.foregroundStyle(.secondary)
Toggle(
"Published — accessible without sign-in",
isOn: Binding(
get: { share.published },
set: { newValue in Task { await setPublished(newValue) } }
)
)
.disabled(isUpdating)
Spacer()
Button("Revoke", role: .destructive) {
isShowingRevokeConfirmation = true
}
.buttonStyle(.plain)
.font(.caption)
.foregroundStyle(.red)
.disabled(isRevoking)
}
} else {
Button("Create Share Link") {
Button {
Task { await create() }
} label: {
Label("Create Share Link", systemImage: "link.badge.plus")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.regular)
}
}
}
// MARK: - People section
@ViewBuilder
private var peopleSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionHeader(icon: "person.2", title: "People with Access")
Spacer()
Button {
isShowingAddPerson.toggle()
} label: {
Image(systemName: isShowingAddPerson ? "xmark.circle.fill" : "person.badge.plus")
.font(.callout)
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.help("Add a person")
}
if isShowingAddPerson {
addPersonSection
}
if isLoadingMembers {
ProgressView()
.controlSize(.small)
.frame(maxWidth: .infinity, alignment: .center)
} else if members.isEmpty {
Text("No one else has explicit access yet.")
.font(.callout)
.foregroundStyle(.secondary)
} else {
VStack(spacing: 2) {
ForEach(members) { member in
memberRow(member)
}
}
}
}
.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) {
@@ -78,34 +316,96 @@ struct DocumentShareSheet: View {
}
}
private func load() async {
isLoading = true
defer { isLoading = false }
private func loadShare() async {
isLoadingShare = true
defer { isLoadingShare = false }
do {
share = try await apiClient.shareInfo(documentId: documentId)
titleOverride = share?.title ?? ""
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.")
shareErrorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.")
}
}
private func create() async {
isLoading = true
defer { isLoading = false }
isLoadingShare = true
defer { isLoadingShare = false }
do {
share = try await apiClient.createShare(CreateShareRequest(documentId: documentId))
titleOverride = share?.title ?? ""
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.")
shareErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.")
}
}
private func setPublished(_ published: Bool) async {
private func setTitle(_ title: String) async {
guard let share else { return }
isUpdating = true
defer { isUpdating = false }
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed != (share.title ?? "") else { return }
isUpdatingShare = true
defer { isUpdatingShare = false }
do {
self.share = try await apiClient.updateShare(UpdateShareRequest(id: share.id, published: published))
self.share = try await apiClient.updateShare(
UpdateShareRequest(id: share.id, published: share.published, title: trimmed)
)
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.")
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.")
}
}
private func revoke() async {
guard let share else { return }
isRevoking = true
defer { isRevoking = false }
do {
try await apiClient.revokeShare(id: share.id)
self.share = nil
titleOverride = ""
isShowingTitleField = false
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't revoke this share link.")
}
}
private func loadMembers() async {
isLoadingMembers = true
defer { isLoadingMembers = false }
members = (try? await apiClient.documentUsers(ListDocumentUsersRequest(id: documentId))) ?? []
}
private func searchUsers(_ query: String) async {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
userSearchResults = []
return
}
isSearchingUsers = true
defer { isSearchingUsers = false }
userSearchResults = (try? await apiClient.listUsers(ListUsersRequest(query: trimmed))) ?? []
}
private func addUser(_ user: OutlineUser) async {
isAddingUser = true
defer { isAddingUser = false }
do {
_ = try await apiClient.addDocumentUser(
AddDocumentUserRequest(id: documentId, userId: user.id, permission: selectedPermission)
)
userSearchQuery = ""
userSearchResults = []
isShowingAddPerson = false
await loadMembers()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't add this person.")
}
}
private func removeUser(_ member: OutlineDocumentMember) async {
do {
try await apiClient.removeDocumentUser(RemoveDocumentUserRequest(id: documentId, userId: member.id))
await loadMembers()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this person.")
}
}
}
@@ -0,0 +1,169 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// The one shared "create a document" dialog every "New Document" entry
/// point (sidebar collection, sidebar document, reader toolbar/menu, Home)
/// opens this instead of silently creating an "Untitled" document. Mirrors
/// `MoveDocumentSheet`'s collection+parent picker pattern.
@MainActor
struct NewDocumentSheet: View {
@Environment(\.dismiss) private var dismiss
let apiClient: OutlineAPIClient
/// Pre-selected collection e.g. opened from a specific collection's
/// "New Document". `nil` when opened from Home, where nothing is
/// pre-selected and the user must choose.
let initialCollectionID: String?
/// Pre-selected parent e.g. opened from a document's "New Document",
/// which creates a child of that document.
let initialParentDocument: OutlineDocument?
let onCreated: (OutlineDocument) -> Void
@State private var title = ""
@State private var collections: [OutlineCollection] = []
@State private var selectedCollectionID: String?
@State private var rootDocuments: [OutlineDocument] = []
@State private var selectedParentID: String?
@State private var isLoadingCollections = false
@State private var isLoadingDestinationDocuments = false
@State private var isCreating = false
@State private var errorMessage: String?
@FocusState private var isTitleFocused: Bool
init(
apiClient: OutlineAPIClient,
initialCollectionID: String? = nil,
initialParentDocument: OutlineDocument? = nil,
onCreated: @escaping (OutlineDocument) -> Void
) {
self.apiClient = apiClient
self.initialCollectionID = initialCollectionID
self.initialParentDocument = initialParentDocument
self.onCreated = onCreated
}
/// `rootDocuments` is only root-level (mirroring `MoveDocumentSheet`'s
/// intentionally shallow picker) if the initial parent is nested
/// deeper than that, it wouldn't otherwise appear as a selectable option
/// even though it's already the selection.
private var parentOptions: [OutlineDocument] {
var options = rootDocuments
if let initialParentDocument,
initialParentDocument.collectionId == selectedCollectionID,
!options.contains(where: { $0.id == initialParentDocument.id }) {
options.insert(initialParentDocument, at: 0)
}
return options
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("New Document")
.font(.headline)
TextField("Title", text: $title)
.textFieldStyle(.roundedBorder)
.focused($isTitleFocused)
.onSubmit { Task { await create() } }
if isLoadingCollections {
ProgressView().frame(maxWidth: .infinity)
} else {
Picker("Collection", selection: $selectedCollectionID) {
Text("Choose a collection").tag(String?.none)
ForEach(collections) { collection in
Text(collection.name).tag(Optional(collection.id))
}
}
.labelsHidden()
Picker("Location", selection: $selectedParentID) {
Text("Collection root").tag(String?.none)
ForEach(parentOptions) { candidate in
Text(candidate.title.isEmpty ? "Untitled" : candidate.title).tag(Optional(candidate.id))
}
}
.labelsHidden()
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments)
}
if let errorMessage {
Text(errorMessage)
.font(.callout)
.foregroundStyle(.red)
}
HStack {
Spacer()
Button("Cancel", role: .cancel) { dismiss() }
Button("Create") {
Task { await create() }
}
.keyboardShortcut(.defaultAction)
.disabled(selectedCollectionID == nil || isCreating)
}
}
.padding(20)
.frame(width: 380)
.task {
await loadCollections()
selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId ?? collections.first?.id
selectedParentID = initialParentDocument?.id
isTitleFocused = true
}
.task(id: selectedCollectionID) {
await loadRootDocuments()
}
}
private func loadCollections() async {
isLoadingCollections = true
defer { isLoadingCollections = false }
do {
collections = try await apiClient.listCollections(offset: 0, limit: 100)
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load collections.")
}
}
private func loadRootDocuments() async {
guard let selectedCollectionID else {
rootDocuments = []
return
}
isLoadingDestinationDocuments = true
defer { isLoadingDestinationDocuments = false }
do {
rootDocuments = try await apiClient.listDocuments(
collectionId: selectedCollectionID,
parentDocumentId: nil,
offset: 0,
limit: 100
)
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load destination documents.")
}
}
private func create() async {
guard let selectedCollectionID else { return }
isCreating = true
defer { isCreating = false }
do {
let document = try await apiClient.createDocument(
CreateDocumentRequest(
title: title.isEmpty ? "Untitled" : title,
text: "",
collectionId: selectedCollectionID,
parentDocumentId: selectedParentID
)
)
onCreated(document)
dismiss()
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't create this document.")
}
}
}
#endif
@@ -0,0 +1,27 @@
#if os(macOS)
import SwiftUI
/// Shown at the top of the sidebar whenever the app is offline either for
/// real (`NetworkMonitor` reports no connection) or because the user turned
/// on the manual "Offline Mode" toggle. Cached collections/documents keep
/// browsing working either way, but the user should know they might be
/// looking at stale data.
struct OfflineBanner: View {
/// Whether this is the user's own "Offline Mode" toggle rather than a
/// real dropped connection same banner slot, different explanation.
var isManual: Bool = false
var body: some View {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
.foregroundStyle(.orange)
Text(isManual ? "Offline Mode — showing cached content" : "Offline — showing cached content")
.font(.callout)
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.12))
}
}
#endif
@@ -0,0 +1,30 @@
#if os(macOS)
import SwiftUI
/// Shown in the sidebar when the network is actually down and the user
/// hasn't turned on Offline Mode themselves yet. Deliberately doesn't change
/// any fetch behavior on its own reads still try live and fall back to
/// cache the normal way (see `CachingOutlineAPIClient`) until the user
/// actually taps the button here, same as `RemoteChangesBanner` never
/// auto-refreshes on their behalf either.
struct OfflineConnectionPromptBanner: View {
let onEnableOfflineMode: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
.foregroundStyle(.orange)
Text("No connection")
.font(.callout)
Spacer(minLength: 8)
Button("Turn On Offline Mode", action: onEnableOfflineMode)
.buttonStyle(.borderedProminent)
.tint(.orange)
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.12))
}
}
#endif
@@ -0,0 +1,43 @@
#if os(macOS)
import SwiftUI
import OutlineKit
struct DocumentCardView: View {
@Environment(StarStore.self) private var starStore
let document: OutlineDocument
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
if let emoji = document.emoji {
Text(emoji)
.font(.title2)
} else {
Image(systemName: "doc.text")
.font(.title3)
.foregroundStyle(.secondary)
}
Spacer()
if starStore.isStarred(documentId: document.id) {
Image(systemName: "star.fill")
.font(.caption)
.foregroundStyle(.yellow)
}
}
Text(document.title.isEmpty ? "Untitled" : document.title)
.font(.headline)
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
Text(document.updatedAt, format: .relative(presentation: .named))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(14)
.frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 10))
}
}
#endif
+10
View File
@@ -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 }
}
+190
View File
@@ -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
+143
View File
@@ -0,0 +1,143 @@
import Foundation
import Observation
import OutlineKit
@MainActor
@Observable
final class HomeViewModel {
private(set) var pinnedDocuments: [OutlineDocument] = []
private(set) var recentlyViewed: [OutlineDocument] = []
private(set) var popular: [OutlineDocument] = []
private(set) var recentlyUpdated: [OutlineDocument] = []
private(set) var createdByMe: [OutlineDocument] = []
var isLoadingPinned = false
var isLoadingTab = false
var errorMessage: String?
/// Drives a "Refresh" banner rather than silently swapping content out
/// from under whoever's looking at it see `CollectionsViewModel`'s
/// identical pattern.
var hasRemoteChanges = false
private let apiClient: OutlineAPIClient
private var currentUserID: String?
init(apiClient: OutlineAPIClient) {
self.apiClient = apiClient
}
func documents(for tab: HomeTab) -> [OutlineDocument] {
switch tab {
case .recentlyViewed: recentlyViewed
case .popular: popular
case .recentlyUpdated: recentlyUpdated
case .createdByMe: createdByMe
}
}
func loadPinned() async {
isLoadingPinned = true
defer { isLoadingPinned = false }
pinnedDocuments = await fetchPinned()
}
func load(tab: HomeTab) async {
isLoadingTab = true
errorMessage = nil
defer { isLoadingTab = false }
do {
let documents = try await fetch(tab: tab)
set(documents, for: tab)
hasRemoteChanges = false
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load documents.")
}
}
/// Fetches fresh pinned docs and the current tab's documents to compare
/// against what's displayed, without replacing either. Bails silently
/// on a fetch failure rather than treating it as "changed" a
/// transient network hiccup (or, offline, `listPins` failing outright
/// it isn't one of the cached endpoints) shouldn't pop the refresh
/// banner. This needs the *throwing* pinned-fetch specifically: the
/// plain `fetchPinned()` used elsewhere collapses any failure to `[]`,
/// which used to read here as "pins changed" against whatever was
/// already displayed and falsely popped the banner on every offline
/// poll.
func checkForRemoteChanges(tab: HomeTab) async {
async let freshPinnedTask = fetchPinnedThrowing()
guard let freshTab = try? await fetch(tab: tab) else { return }
guard let pinned = try? await freshPinnedTask else { return }
if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments)
|| Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) {
hasRemoteChanges = true
}
}
private func fetchPinned() async -> [OutlineDocument] {
(try? await fetchPinnedThrowing()) ?? []
}
/// `pins.list` only returns pin records, not the documents themselves
/// fetches each pinned document individually. Pins are a small curated
/// set (unlike a full collection tree), so the N+1 here is acceptable
/// where it wouldn't be in the sidebar.
private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil))
var documents: [OutlineDocument] = []
for pin in pins {
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
documents.append(document)
}
}
return documents
}
private func fetch(tab: HomeTab) async throws -> [OutlineDocument] {
switch tab {
case .recentlyViewed:
return try await apiClient.listViewedDocuments(offset: 0, limit: 25)
case .popular:
// `sort: "viewCount"` was a guess and the server rejected it
// outright ("sort: Invalid input") sort is validated
// server-side against a fixed set, not free-form like the
// vendored spec's typing implies. Same conclusion as
// `CollectionTab.popular`: there's no real popularity
// ranking exposed via the REST API, so this falls back to
// the default list order rather than guessing again.
return try await apiClient.documentsList(DocumentsListRequest(limit: 25))
case .recentlyUpdated:
return try await apiClient.documentsList(
DocumentsListRequest(sort: "updatedAt", direction: "DESC", limit: 25)
)
case .createdByMe:
let userId = try await resolveCurrentUserID()
return try await apiClient.documentsList(
DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25)
)
}
}
private func set(_ documents: [OutlineDocument], for tab: HomeTab) {
switch tab {
case .recentlyViewed: recentlyViewed = documents
case .popular: popular = documents
case .recentlyUpdated: recentlyUpdated = documents
case .createdByMe: createdByMe = documents
}
}
private func resolveCurrentUserID() async throws -> String {
if let currentUserID { return currentUserID }
let user = try await apiClient.currentUser()
currentUserID = user.id
return user.id
}
private static func fingerprint(_ documents: [OutlineDocument]) -> String {
documents
.map { "\($0.id):\($0.updatedAt.timeIntervalSince1970)" }
.sorted()
.joined(separator: "|")
}
}
@@ -0,0 +1,46 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// Deliberately distinct from `DocumentCardView` the pinned section is for
/// a quick scan of a small curated set, not browsing, so this is a dense
/// single-line row rather than a tall card, with an explicit pin glyph so
/// it doesn't read the same as the tab grids below it.
struct PinnedDocumentCard: View {
@Environment(StarStore.self) private var starStore
let document: OutlineDocument
var body: some View {
HStack(spacing: 10) {
if let emoji = document.emoji {
Text(emoji)
.font(.title3)
} else {
Image(systemName: "doc.text")
.font(.body)
.foregroundStyle(.secondary)
}
Text(document.title.isEmpty ? "Untitled" : document.title)
.font(.callout.weight(.medium))
.lineLimit(1)
Spacer(minLength: 0)
if starStore.isStarred(documentId: document.id) {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
Image(systemName: "pin.fill")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
}
}
#endif
+14 -13
View File
@@ -14,10 +14,10 @@ import AppKit
@main
struct OutpostApp: App {
@State private var session = SessionStore()
@State private var navigation = AppNavigation()
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
#if os(macOS)
@Environment(\.openWindow) private var openWindow
@State private var isShowingLogoutConfirmation = false
#endif
@@ -25,6 +25,7 @@ struct OutpostApp: App {
WindowGroup {
RootView()
.environment(session)
.environment(navigation)
#if os(iOS)
.preferredColorScheme(appearance.colorScheme)
#endif
@@ -38,9 +39,20 @@ struct OutpostApp: App {
.commands {
CommandGroup(replacing: .appInfo) {
Button("About Outpost") {
openWindow(id: "about")
navigation.selectedSettingsSection = .about
navigation.isShowingSettings = true
}
}
// No `Settings {}` scene anymore Settings renders inside the
// root window (see `AppNavigation`), not a separate popup, so
// , has to be wired up by hand instead of coming for free.
CommandGroup(replacing: .appSettings) {
Button("Settings…") {
navigation.isShowingSettings = true
}
.keyboardShortcut(",")
.disabled(!session.isSignedIn)
}
CommandGroup(after: .appSettings) {
Divider()
Button("Log Out…") {
@@ -52,22 +64,11 @@ struct OutpostApp: App {
#endif
#if os(macOS)
Window("About Outpost", id: "about") {
AboutView()
.disablesFullScreen()
}
.windowResizability(.contentSize)
Window("Keyboard Shortcuts", id: "keyboard-shortcuts") {
KeyboardShortcutsView()
.disablesFullScreen()
}
.windowResizability(.contentSize)
Settings {
PreferencesView()
.environment(session)
}
#endif
}
+137
View File
@@ -0,0 +1,137 @@
import Observation
/// Top-level groupings shown as section headers in `SettingsSidebarList`.
/// `general` holds everything that's ours, not Outline's own settings
/// categories labeled "Outpost" so it reads as clearly distinct from the
/// Outline-sourced groups below it.
enum SettingsCategory: String, CaseIterable, Identifiable {
case general
case account
case workspace
var id: String { rawValue }
var title: String? {
switch self {
case .general: return "Outpost"
case .account: return "Account"
case .workspace: return "Workspace"
}
}
}
/// One entry in the Settings sidebar. Mirrors Outline's own settings
/// categories (Account/Workspace) so this app's settings read as a native
/// counterpart to the web app's, plus a `general` group for things that are
/// ours and don't map onto Outline's structure (offline/sync, advanced,
/// about, appearance). Outline's own version info moved to the sidebar
/// footer (`SettingsSidebarList`) instead of a standalone
/// Integrations & Installation section.
///
/// Most of the Account/Workspace cases are navigation-only for now
/// `SettingsView` renders a "Coming Soon" placeholder for anything not
/// explicitly built yet. Content lands section by section.
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
// General (ours)
case appearance, offlineSync, advanced, about
// Account
case profile, preferences, notifications, passkeys, apiAccess
// Workspace
case details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData
var id: String { rawValue }
var category: SettingsCategory {
switch self {
case .appearance, .offlineSync, .advanced, .about:
return .general
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
return .account
case .details, .authentication, .security, .ai, .members, .groups, .templates, .emojis, .applications, .shared, .links, .webhooks, .importData, .exportData:
return .workspace
}
}
var title: String {
switch self {
case .appearance: return "Appearance"
case .offlineSync: return "Offline & Sync"
case .advanced: return "Advanced"
case .about: return "About"
case .profile: return "Profile"
case .preferences: return "Preferences"
case .notifications: return "Notifications"
case .passkeys: return "Passkeys"
case .apiAccess: return "API & Access"
case .details: return "Details"
case .authentication: return "Authentication"
case .security: return "Security"
case .ai: return "AI"
case .members: return "Members"
case .groups: return "Groups"
case .templates: return "Templates"
case .emojis: return "Emojis"
case .applications: return "Applications"
case .shared: return "Shared"
case .links: return "Links"
case .webhooks: return "Webhooks"
case .importData: return "Import"
case .exportData: return "Export"
}
}
var icon: String {
switch self {
case .appearance: return "paintbrush"
case .offlineSync: return "arrow.triangle.2.circlepath"
case .advanced: return "wrench.and.screwdriver"
case .about: return "info.circle"
case .profile: return "person.crop.circle"
case .preferences: return "gearshape"
case .notifications: return "bell"
case .passkeys: return "key"
case .apiAccess: return "chevron.left.forwardslash.chevron.right"
case .details: return "building.2"
case .authentication: return "lock"
case .security: return "shield"
case .ai: return "sparkles"
case .members: return "person.2"
case .groups: return "person.3"
case .templates: return "doc.on.doc"
case .emojis: return "face.smiling"
case .applications: return "app.badge"
case .shared: return "square.and.arrow.up.on.square"
case .links: return "link"
case .webhooks: return "bolt.horizontal"
case .importData: return "square.and.arrow.down"
case .exportData: return "square.and.arrow.up"
}
}
/// Everything actually built so far everything else in Account/
/// Workspace renders a "Coming Soon" placeholder until its content is
/// specified and built.
var isImplemented: Bool {
switch self {
case .appearance, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
return true
default:
return false
}
}
}
/// Cross-cutting UI state that doesn't belong to any one screen. Lives at
/// `OutpostApp` (so both `RootView`'s content and its `,` command can reach
/// it) and is read wherever something needs to open Settings (the profile
/// menu) or render it as a swap of the *existing* sidebar/detail panes in
/// `ContentView_macOS`, not a separate popup window or an overlay that hides
/// the sidebar.
@Observable
@MainActor
final class AppNavigation {
var isShowingSettings = false
var selectedSettingsSection: SettingsSection? = .appearance
}
+55
View File
@@ -5,6 +5,19 @@ struct RootView: View {
@Environment(SessionStore.self) private var session
@State private var welcomeName: String?
@State private var starStore = StarStore()
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
/// Real connectivity is only half of "can talk to the server" the
/// manual Offline Mode toggle is the other half. Syncing needs to
/// resume on either one clearing, not just a real reconnect: turning
/// the toggle off while the network had been up the whole time never
/// changes `networkMonitor.isOnline`, so keying the flush task off that
/// alone left pending operations stuck until the next real network
/// blip.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
var body: some View {
ZStack {
@@ -32,6 +45,48 @@ struct RootView: View {
starStore.reset()
}
}
// Replay whatever queued up while offline the moment either signal
// clears a real reconnect, or the user turning Offline Mode back
// off no need to wait for the user to open Settings and hit Retry.
// Also catches Full Local Sync back up immediately, rather than
// leaving it to wait out the rest of the periodic loop below.
//
// The flush itself gets a retry loop with a cooloff, not just one
// attempt: a single transient failure (one bad operation, a blip
// mid-flush) used to leave everything else stuck until the user
// manually hit Retry or connectivity changed again. Keeps retrying
// every 5 minutes for as long as *anything* is still pending and
// this signal stays on stops on its own once the queue is empty,
// and `.task(id:)` cancels/restarts it automatically if
// isEffectivelyOnline flips again in the meantime.
.task(id: isEffectivelyOnline) {
guard isEffectivelyOnline, let cachingClient = session.cachingClient else { return }
if isFullLocalSyncEnabled {
_ = await cachingClient.performFullSync()
}
while !Task.isCancelled {
_ = await cachingClient.flushPendingOperations()
let remaining = await cachingClient.pendingOperations()
guard !remaining.isEmpty else { break }
try? await Task.sleep(for: .seconds(300))
}
}
// Fully automatic this is the only place Full Local Sync actually
// runs from (Settings' "Sync Now" is just an on-demand nudge at the
// same call). Syncs immediately whenever this task (re)starts, which
// covers both "just switched on" and "was already on at launch"
// `.task(id:)` restarts on either, an `@AppStorage`-backed toggle
// changing anywhere updates every view reading that key then every
// 20 minutes after, for as long as it stays enabled.
.task(id: isFullLocalSyncEnabled) {
guard isFullLocalSyncEnabled else { return }
while !Task.isCancelled {
if isEffectivelyOnline, let cachingClient = session.cachingClient {
_ = await cachingClient.performFullSync()
}
try? await Task.sleep(for: .seconds(1200))
}
}
}
private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
+70 -9
View File
@@ -11,12 +11,26 @@ final class SessionStore {
private let defaults: UserDefaults
var isSignedIn: Bool
private(set) var userId: String?
var userName: String?
var userEmail: String?
var userAvatarURL: URL?
var userLanguage: String?
var userPreferences: OutlineUserPreferences?
var userNotificationSettings: [String: Bool]?
var teamName: String?
var teamAvatarURL: URL?
private(set) var apiClient: OutlineAPIClient?
/// Same object as `apiClient` when the offline cache is available kept
/// as a separately-typed reference so Settings can reach cache/sync-queue
/// specific methods (`flushPendingOperations`, `performFullSync`, etc.)
/// without downcasting the protocol-typed `apiClient` everywhere.
private(set) var cachingClient: CachingOutlineAPIClient?
let networkMonitor = NetworkMonitor()
/// `nil` only if the on-disk SwiftData store failed to open (e.g. disk
/// full) in that case `apiClient` falls back to talking to the server
/// directly with no offline cache, rather than failing sign-in outright.
private let cacheStore: OfflineCacheStore?
var serverURL: URL? {
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
@@ -25,36 +39,66 @@ final class SessionStore {
init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) {
self.tokenStore = tokenStore
self.defaults = defaults
self.isSignedIn = (try? tokenStore.token()) != nil
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
if isSignedIn, let serverURL {
apiClient = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: serverURL),
tokenStore: tokenStore
)
let hasToken = (try? tokenStore.token()) != nil
let storedServerURL = defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
if hasToken, let storedServerURL {
isSignedIn = true
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore)
} else {
// Keychain and the sandboxed UserDefaults container don't
// always survive together a Keychain item written by an
// older-signed build can outlive a reinstall that wipes the
// container (or vice versa), leaving a token with no server or
// a server with no token. Clear whichever half survived rather
// than showing a broken "signed in" UI with no working
// apiClient a fresh sign-in rewrites both consistently.
if hasToken {
try? tokenStore.clear()
}
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
isSignedIn = false
}
}
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
apiClient = LiveOutlineAPIClient(
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
apply(user: user, team: team, serverURL: serverURL)
isSignedIn = true
}
private static func makeAPIClient(
serverURL: URL,
tokenStore: TokenStoring,
cache: OfflineCacheStore?
) -> (OutlineAPIClient, CachingOutlineAPIClient?) {
let live = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: serverURL),
tokenStore: tokenStore
)
apply(user: user, team: team, serverURL: serverURL)
isSignedIn = true
guard let cache else { return (live, nil) }
let caching = CachingOutlineAPIClient(live: live, cache: cache)
return (caching, caching)
}
func signOut() {
try? tokenStore.clear()
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
isSignedIn = false
userId = nil
userName = nil
userEmail = nil
userAvatarURL = nil
userLanguage = nil
userPreferences = nil
userNotificationSettings = nil
teamName = nil
teamAvatarURL = nil
apiClient = nil
cachingClient = nil
}
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
@@ -65,13 +109,30 @@ final class SessionStore {
apply(user: auth.user, team: auth.team, serverURL: serverURL)
}
/// Settings calls this after a successful name/avatar change so the
/// sidebar's account footer and everywhere else reading these reflect
/// it immediately, without waiting for the next `auth.info` refresh.
func applyUpdatedProfile(_ user: OutlineUser) {
guard let serverURL else { return }
userId = user.id
userName = user.name
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
userLanguage = user.language
userPreferences = user.preferences
userNotificationSettings = user.notificationSettings
}
private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
userId = user.id
userName = user.name
userEmail = user.email
// Outline can return either an absolute URL or a server-relative path
// (e.g. `/api/files.get?key=...`) for avatarUrl resolve against the
// configured server so relative paths don't fail as "unsupported URL".
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
userLanguage = user.language
userPreferences = user.preferences
userNotificationSettings = user.notificationSettings
teamName = team.name
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
}
+38 -2
View File
@@ -1,4 +1,5 @@
import SwiftUI
import OutlineKit
#if os(macOS)
import AppKit
@@ -41,11 +42,46 @@ struct AvatarBadge: View {
.task(id: avatarURL) {
loadedImage = nil
guard let avatarURL else { return }
guard let (data, _) = try? await URLSession.shared.data(from: avatarURL) else { return }
loadedImage = PlatformImage(data: data)
loadedImage = await Self.loadImage(from: avatarURL)
}
}
/// The real fix, confirmed against a live network capture: every other
/// request this app makes attaches `Authorization: Bearer <token>`
/// this one never did, sending a bare unauthenticated GET. Outline's
/// browser session authenticates `attachments.redirect` via cookies
/// instead, which a native app doesn't have; the API-token equivalent
/// is the same Bearer header every RPC call already uses. Almost
/// certainly means no avatar image (not just a freshly-uploaded one)
/// has ever actually loaded in this app a 401 and a "no avatar set"
/// look identical here, both just fall back to the placeholder icon
/// with nothing on screen to flag it as an error.
///
/// The retry loop is a secondary, independent hardening cheap
/// insurance against a self-hosted reverse-proxied storage backend not
/// being instantly consistent right after an upload kept alongside
/// the auth fix rather than instead of it.
private static func loadImage(from url: URL) async -> PlatformImage? {
var request = URLRequest(url: url)
request.cachePolicy = .reloadIgnoringLocalCacheData
if let token = try? KeychainTokenStore().token() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
for attempt in 0..<3 {
if attempt > 0 {
try? await Task.sleep(for: .milliseconds(400))
}
if let (data, response) = try? await URLSession.shared.data(for: request),
let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode),
let image = PlatformImage(data: data) {
return image
}
}
return nil
}
private func platformImage(_ image: PlatformImage) -> Image {
#if os(macOS)
Image(nsImage: image)
+14
View File
@@ -0,0 +1,14 @@
#if os(macOS)
import AppKit
extension NSImage {
/// `NSImage` has no built-in JPEG encoder (unlike `UIImage`) routes
/// through a bitmap representation to get one.
func jpegData(compressionQuality: CGFloat) -> Data? {
guard let tiffData = tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData) else {
return nil
}
return bitmap.representation(using: .jpeg, properties: [.compressionFactor: compressionQuality])
}
}
#endif
+36
View File
@@ -0,0 +1,36 @@
import Foundation
import Network
import Observation
/// Backs the sidebar's offline badge purely a UI signal. Unrelated to
/// `CachingOutlineAPIClient`'s own fallback logic, which reacts to actual
/// request failures rather than pre-checking reachability.
@Observable
@MainActor
final class NetworkMonitor {
private(set) var isOnline = true
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "com.outpost.network-monitor")
init() {
// Weak on the outer closure (it's held by `monitor` for the object's
// whole lifetime, so a strong capture here would be a retain cycle),
// then unwrapped once into a plain strong local the inner `Task`
// closure capturing that local `self` is what a nested closure needs
// to be consistent about capture semantics with its enclosing one.
monitor.pathUpdateHandler = { [weak self] path in
guard let self else { return }
let online = path.status == .satisfied
Task { @MainActor in
self.isOnline = online
}
}
monitor.start(queue: queue)
}
deinit {
monitor.cancel()
}
}
+35
View File
@@ -0,0 +1,35 @@
import Foundation
/// Outline's interface-language options. Not exhaustive Outline accepts
/// community translations via its own translation portal, so the real list
/// on any given server can be longer than this; this covers the common
/// cases and falls back to showing whatever code the server already has
/// set even if it isn't in this list.
struct OutlineLocale: Identifiable, Hashable {
let code: String
let label: String
var id: String { code }
static let all: [OutlineLocale] = [
OutlineLocale(code: "en_US", label: "English (US)"),
OutlineLocale(code: "en_GB", label: "English (UK)"),
OutlineLocale(code: "de_DE", label: "Deutsch"),
OutlineLocale(code: "fr_FR", label: "Français"),
OutlineLocale(code: "es_ES", label: "Español"),
OutlineLocale(code: "pt_PT", label: "Português"),
OutlineLocale(code: "pt_BR", label: "Português (Brasil)"),
OutlineLocale(code: "it_IT", label: "Italiano"),
OutlineLocale(code: "nl_NL", label: "Nederlands"),
OutlineLocale(code: "pl_PL", label: "Polski"),
OutlineLocale(code: "ru_RU", label: "Русский"),
OutlineLocale(code: "ja_JP", label: "日本語"),
OutlineLocale(code: "ko_KR", label: "한국어"),
OutlineLocale(code: "zh_CN", label: "中文 (简体)"),
OutlineLocale(code: "zh_TW", label: "中文 (繁體)"),
]
static func label(for code: String) -> String {
all.first(where: { $0.code == code })?.label ?? code
}
}
+31
View File
@@ -0,0 +1,31 @@
import Foundation
/// Single source of truth for how Outpost's own version is formatted
/// used by both the About page and the Settings sidebar footer, so they
/// can't drift out of sync the way `AboutInfoView` was already written to
/// avoid for its own two call sites.
enum OutpostVersion {
/// Bumped alongside `MARKETING_VERSION` in the Xcode project kept out
/// of the bundle version itself since `CFBundleShortVersionString` is
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
static let releaseStage = "ALPHA"
static var shortVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
}
static var buildNumber: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
}
/// e.g. `"0.0.3-ALPHA"` for compact display (sidebar footer).
static var displayString: String {
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
return "\(shortVersion)\(stageSuffix)"
}
/// e.g. `"Version 0.0.3-ALPHA (1)"` for the About page.
static var fullVersionString: String {
"Version \(displayString) (\(buildNumber))"
}
}
+25 -13
View File
@@ -1,27 +1,29 @@
# Outpost
<p align="center">
<img src="Outpost/Assets.xcassets/AppLogo.imageset/outpost-ios-1024.png" width="120" alt="Outpost logo">
</p>
<h1 align="center">Outpost</h1>
<p align="center">
<a href="https://testflight.apple.com/join/y1mYcYAM">
<img src="https://img.shields.io/badge/Download-TestFlight-0D96F6?style=for-the-badge&logo=apple&logoColor=white" alt="Download on TestFlight">
</a>
</p>
A native Apple ecosystem client for [Outline](https://github.com/outline/outline) — built for iOS, iPadOS, and macOS from a single SwiftUI codebase, aiming for full editing parity with Outline's web app, including realtime collaborative editing.
> **Early alpha — macOS only for now.** Expect missing features and rough edges. iOS/iPadOS support is planned but not in the current build. See the [releases page](https://git.psmattas.com/psmattas/Outpost/releases) for changelogs, and [open an issue](https://git.psmattas.com/psmattas/Outpost/issues) if you hit anything.
## Why
Outline's web app is great, but there's no native Apple client with full editing parity. This project connects to a self-hosted Outline instance over its REST API and realtime collaboration socket to provide a proper native experience across the Apple ecosystem.
## Status
Early development. See `CLAUDE.md` for the current architecture and phased build plan.
- [ ] Phase 1 — Auth, browse, search, REST-only editing
- [ ] Phase 2 — Realtime collaborative editing (Yjs/Hocuspocus)
- [ ] Phase 3 — Offline cache, tables, embeds, comments, macOS polish
## Requirements
- Xcode 16+
- iOS 17+ / iPadOS 17+ / macOS 14+
- Xcode 27+ (currently developed against an Xcode 27 beta — this is a hard minimum, not a suggestion)
- macOS 27+. iOS/iPadOS support is planned but not in the current build (see the alpha note above) — same 27+ minimum will apply once it lands
- A self-hosted (or hosted) Outline instance with API access
> They will be updated. These are old requirements.
## Setup
1. Clone the repo and open the `.xcodeproj` in Xcode.
@@ -42,6 +44,16 @@ Parts of this codebase are AI-assisted (built with the help of AI coding tools).
This project is a client only — it does not include, vendor, or redistribute any of Outline's (BSL 1.1 licensed) server source. See [`LICENSE`](./LICENSE) for this repository's own license.
## Privacy
Outpost collects nothing about you — no analytics, no telemetry, no crash reporting of its own, no age or demographic data, nothing. The only thing stored locally is your Outline server URL and API token (in the device Keychain) and, optionally, a local offline cache of what you've viewed. Everything else goes straight from your device to whatever Outline server you configure — there's no backend in between, and the developer has no access to your data or your server.
Full policy, terms of service, and data-processing statement are on the [wiki](https://git.psmattas.com/psmattas/Outpost/wiki):
- [Privacy Policy](https://git.psmattas.com/psmattas/Outpost/wiki/Privacy-Policy.-)
- [Terms of Service](https://git.psmattas.com/psmattas/Outpost/wiki/Terms-of-Service.-)
- [Data Processing Statement](https://git.psmattas.com/psmattas/Outpost/wiki/Data-Processing-Statement.-)
## Not affiliated with Outline
This is an independent, unofficial client. Not affiliated with or endorsed by General Outline, Inc.
+38
View File
@@ -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.
+91
View File
@@ -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.
+6 -1
View File
@@ -98,7 +98,12 @@ if [ -z "$LATEST_TAG" ]; then
HEADING="Changelog"
else
HEAD_COMMIT="$(git -C "$REPO_ROOT" rev-parse HEAD)"
TAG_COMMIT="$(git -C "$REPO_ROOT" rev-parse "$LATEST_TAG")"
# `^{commit}` peels an annotated tag down to the commit it points at —
# without it, `rev-parse` on an annotated tag (e.g. `git tag -a`) returns
# the tag *object's* hash, which never equals a commit hash, so this
# comparison would always take the "not tagged yet" branch below even
# when HEAD genuinely is the tagged commit.
TAG_COMMIT="$(git -C "$REPO_ROOT" rev-parse "${LATEST_TAG}^{commit}")"
if [ "$HEAD_COMMIT" = "$TAG_COMMIT" ]; then
PREV_TAG="$(git -C "$REPO_ROOT" describe --tags --abbrev=0 "${LATEST_TAG}^" 2>/dev/null || true)"
RANGE="${PREV_TAG:+$PREV_TAG..}$LATEST_TAG"