Add Outline integration: push lesson notes to a topic collection

Pick a topic on a lesson's note and it pushes to that topic's collection
in a self-hosted Outline instance when you leave the page; notes without a
topic stay local-only. Settings gets a new Outline Integration card
(URL/token, Save, Test Connection).

Credential handling: the API token lives in its own outline_config.json
in DATA_DIR, deliberately kept out of the general settings flow (GET
/api/settings is fetched on every page load by theme.js - no place for a
secret to ride along). GET /api/outline/config only ever returns whether
it's configured, never the token itself.

The topic chooser reflects Outline's live collection list rather than a
locally cached copy, and resolves a newly-typed topic name to a real
collection immediately (find-by-name-or-create) rather than waiting until
the note is pushed. That's not just an optimization: the push itself fires
via navigator.sendBeacon() on pagehide, which can't read a response, so a
page that fires pagehide more than once for the same load (a back/forward-
cache restore, for instance) would otherwise re-send the same "create a
new topic" intent every time and spawn duplicate collections. Verified
live against a local mock Outline server that firing pagehide repeatedly
for the same lesson creates the collection/document once and updates
thereafter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 12:12:50 -04:00
co-authored by Claude Sonnet 5
parent a6c49d3762
commit 5fe3fafc7a
4 changed files with 546 additions and 2 deletions
+43
View File
@@ -205,6 +205,49 @@ you need real data for every result you return).
progress+notes file (stdlib `zipfile`, no new dependency) - not the
course files themselves.
## Outline integration (this session)
Push lesson notes to a self-hosted Outline instance
(`https://mikeline.michaelsitz.com`), organized by a "topic" the user picks
per note - each topic is its own Outline collection. Only notes with a
topic selected get pushed; everything else stays local-only, same as
before.
- **Credential handling**: the API token lives in its own
`OUTLINE_CONFIG_FILE` (`outline_config.json` in `DATA_DIR`), deliberately
*not* part of `DEFAULT_SETTINGS`/`load_settings` - those flow through
`GET /api/settings`, which `theme.js` fetches on every page load, and a
secret has no business riding along in that response. `GET
/api/outline/config` only ever returns `{configured, base_url}`, never
the token. Confirmed via a real (mocked-backend) test: token round-trips
through save but never comes back out on GET.
- **Topic chooser = live Outline collections**, not a locally-cached list -
`GET /api/outline/collections` proxies `collections.list` fresh every
time. Naming a new topic resolves it to a real collection **immediately**
(find-by-exact-name-or-create, `resolve_outline_topic()` /
`POST /api/outline/resolve-topic`) the moment you tab out of the "new
topic" field, rather than waiting until the note is pushed.
- **Why eager resolution, not deferred**: the first design deferred
collection creation to push-time (avoid an empty collection if the topic
was never actually used) - caught a real bug testing it: the push fires
via `navigator.sendBeacon()` on `pagehide`, which can't read a response,
so a page that fires `pagehide` more than once for the same load (a
browser back/forward-cache restore, for instance) would silently
re-create a *new* collection every time, since the client had no way to
learn the topic name had already been resolved. Fixed by resolving up
front and by making the deferred fallback path dedupe-by-name too
(`resolve_outline_topic`) - verified live (mocked Outline backend) that
firing multiple sequential `pagehide` events for the same lesson creates
the collection/document once and updates thereafter, never duplicates.
- **Push flow**: `POST /api/outline/push` - creates the Outline document on
first push, updates the same one (by the id stashed in the lesson's
progress-file entry, alongside the existing `note` field) on every push
after. No-ops if the note is empty.
- Verified end-to-end against a local mock Outline server (not the real
instance - no write-testing against live external services this
session), including Settings → Test Connection hitting the real HTTP
path.
## Known limitations still open
- App is unauthenticated by design (matches upstream) — settings and hidden-path
curation apply app-wide, not per-browser/per-user.