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
+270 -2
View File
@@ -12,6 +12,8 @@ import sys
import argparse
import io
import zipfile
import urllib.request
import urllib.error
from pathlib import Path
from datetime import datetime, timedelta
from dataclasses import dataclass, asdict
@@ -901,6 +903,130 @@ def get_library_stats() -> Dict[str, Any]:
}
# ---- Outline integration ----
# Deliberately kept separate from DEFAULT_SETTINGS/load_settings/save_settings:
# those all flow through GET /api/settings, which theme.js fetches on every
# page load - not where an API token should ever ride along.
OUTLINE_CONFIG_FILE = os.path.join(DATA_DIR, 'outline_config.json')
def load_outline_config() -> Dict[str, str]:
"""Load the Outline base URL + API token. Never exposed via GET beyond 'configured'."""
try:
if os.path.exists(OUTLINE_CONFIG_FILE):
with open(OUTLINE_CONFIG_FILE, 'r') as f:
data = json.load(f)
if isinstance(data, dict):
return {
'base_url': data.get('base_url', ''),
'api_token': data.get('api_token', '')
}
except (json.JSONDecodeError, OSError) as e:
print(f"Could not load Outline config: {e}")
return {'base_url': '', 'api_token': ''}
def save_outline_config(base_url: str, api_token: str) -> None:
"""Save the Outline base URL + API token. A blank api_token keeps the
previously stored one, so the URL can be updated without re-pasting it."""
current = load_outline_config()
current['base_url'] = base_url.rstrip('/')
if api_token:
current['api_token'] = api_token
os.makedirs(DATA_DIR, exist_ok=True)
with open(OUTLINE_CONFIG_FILE, 'w') as f:
json.dump(current, f, indent=2)
def _outline_request(path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
"""
POST to the Outline API (every Outline endpoint is POST, even 'list'
ones) with the stored token. Raises RuntimeError with a readable message
on any failure - not configured, unreachable, or a non-2xx response.
"""
config = load_outline_config()
if not config['base_url'] or not config['api_token']:
raise RuntimeError('Outline is not configured')
url = f"{config['base_url']}/api/{path}"
body = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(url, data=body, method='POST', headers={
'Authorization': f"Bearer {config['api_token']}",
'Content-Type': 'application/json',
'Accept': 'application/json',
})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode('utf-8'))
except urllib.error.HTTPError as e:
detail = e.read().decode('utf-8', errors='replace')
raise RuntimeError(f'Outline returned {e.code}: {detail[:200]}')
except urllib.error.URLError as e:
raise RuntimeError(f'Could not reach Outline: {e.reason}')
def resolve_outline_topic(name: str) -> Dict[str, str]:
"""
Find an existing Outline collection with this exact name, or create one.
Matching by name first (rather than always creating) means naming a
"new" topic that happens to match one you already made doesn't spawn a
duplicate collection.
"""
existing = _outline_request('collections.list', {'limit': 100})
for c in existing.get('data', []):
if c['name'] == name:
return {'id': c['id'], 'name': c['name']}
created = _outline_request('collections.create', {'name': name, 'permission': 'read_write'})
return {'id': created['data']['id'], 'name': name}
def push_note_to_outline(course: Course, lesson_path: str, lesson_title: str,
topic_id: str, new_topic_name: str) -> Dict[str, Any]:
"""
Push a lesson's saved note to Outline as a document in the chosen topic
(collection). Creates the document on the first push and updates the
same one (by the id stashed in the progress file) on every push after
that.
new_topic_name is a fallback for a topic that somehow never got resolved
client-side (see resolve_outline_topic) - the normal path resolves a
brand-new topic name to a real collection id as soon as it's typed
(POST /api/outline/resolve-topic), specifically so this fire-and-forget,
can't-read-the-response push never has to decide "is this actually a
new topic" on its own. A page that fires pagehide more than once for
the same view (bfcache restore, for instance) would otherwise re-send
the same "new topic" intent every time and create a duplicate
collection per navigation.
"""
progress = ProgressTracker.load_progress(course)
entry = progress.get(lesson_path, {})
note = entry.get('note', '')
if not note:
return {'success': False, 'error': 'No note to push'}
if not topic_id:
if not new_topic_name:
return {'success': False, 'error': 'No topic selected'}
topic_id = resolve_outline_topic(new_topic_name)['id']
ProgressTracker.set_lesson_outline_topic(course, lesson_path, topic_id, '')
title = f"{lesson_title}{course.name}"
document_id = entry.get('outline_document_id')
if document_id:
_outline_request('documents.update', {'id': document_id, 'title': title, 'text': note})
else:
created = _outline_request('documents.create', {
'title': title,
'text': note,
'collectionId': topic_id,
'publish': True
})
document_id = created['data']['id']
ProgressTracker.set_lesson_outline_document_id(course, lesson_path, document_id)
return {'success': True, 'document_id': document_id, 'topic_id': topic_id}
RECENT_VIEWS_FILE = os.path.join(DATA_DIR, 'recent_views.json')
MAX_RECENT_VIEWS = 20
@@ -1057,6 +1183,35 @@ class ProgressTracker:
entry.pop('note', None)
ProgressTracker.save_progress(course, progress)
@staticmethod
def set_lesson_outline_topic(course: Course, lesson_path: str, topic_id: str, topic_name: str):
"""
Remember which Outline topic (collection) a lesson's note should push
to - an existing collection id, or a not-yet-created topic name (see
push_note_to_outline). Clearing the pick (both blank) removes it so
pagehide stops firing a push for this lesson.
"""
progress = ProgressTracker.load_progress(course)
entry = progress.setdefault(lesson_path, {})
if topic_id:
entry['outline_topic_id'] = topic_id
entry.pop('outline_topic_name', None)
elif topic_name:
entry['outline_topic_name'] = topic_name
entry.pop('outline_topic_id', None)
else:
entry.pop('outline_topic_id', None)
entry.pop('outline_topic_name', None)
ProgressTracker.save_progress(course, progress)
@staticmethod
def set_lesson_outline_document_id(course: Course, lesson_path: str, document_id: str):
"""Remember the Outline document a lesson's note was pushed to, so the next push updates it instead of creating a duplicate."""
progress = ProgressTracker.load_progress(course)
entry = progress.setdefault(lesson_path, {})
entry['outline_document_id'] = document_id
ProgressTracker.save_progress(course, progress)
@staticmethod
def mark_all_completed(course: Course):
"""Mark every lesson in the course as completed, in one save."""
@@ -1706,13 +1861,15 @@ def view_lesson(lesson_path: str):
# Read the note directly from the progress file rather than the Lesson
# object - apply_progress_to_tree (which populates Lesson fields) isn't
# called on this code path, only on the dashboard's tree render.
note = ProgressTracker.load_progress(current_course).get(lesson_path, {}).get('note', '')
lesson_progress = ProgressTracker.load_progress(current_course).get(lesson_path, {})
return render_template('lesson_view.html',
course=current_course,
lesson=lesson,
lesson_path=lesson_path,
lesson_note=note,
lesson_note=lesson_progress.get('note', ''),
outline_topic_id=lesson_progress.get('outline_topic_id', ''),
outline_topic_name=lesson_progress.get('outline_topic_name', ''),
prev_lesson=prev_lesson,
next_lesson=next_lesson)
@@ -1818,6 +1975,117 @@ def update_lesson_note_api():
return jsonify({'success': True})
@app.route('/api/lesson-note/topic', methods=['POST'])
def update_lesson_note_topic_api():
"""Remember which Outline topic (collection) a lesson's note should push to."""
global current_course
if not current_course:
return jsonify({'error': 'No course loaded'}), 400
data = request.json or {}
lesson_path = data.get('lesson_path')
if not lesson_path:
return jsonify({'error': 'lesson_path is required'}), 400
topic_id = (data.get('topic_id') or '').strip()
topic_name = (data.get('topic_name') or '').strip()
ProgressTracker.set_lesson_outline_topic(current_course, lesson_path, topic_id, topic_name)
return jsonify({'success': True})
@app.route('/api/outline/config', methods=['GET'])
def get_outline_config_api():
"""Whether Outline is configured, and the (non-secret) base URL - never the token."""
config = load_outline_config()
return jsonify({'configured': bool(config['base_url'] and config['api_token']), 'base_url': config['base_url']})
@app.route('/api/outline/config', methods=['POST'])
def set_outline_config_api():
"""Save the Outline base URL / API token."""
data = request.json or {}
base_url = (data.get('base_url') or '').strip()
api_token = (data.get('api_token') or '').strip()
if not base_url:
return jsonify({'error': 'Outline URL is required'}), 400
save_outline_config(base_url, api_token)
return jsonify({'success': True})
@app.route('/api/outline/test')
def test_outline_connection_api():
"""Ping Outline with the stored config, for the Settings page's 'Test Connection' button."""
try:
_outline_request('collections.list', {'limit': 1})
return jsonify({'success': True})
except RuntimeError as e:
return jsonify({'success': False, 'error': str(e)}), 502
@app.route('/api/outline/collections')
def list_outline_collections_api():
"""The topic chooser's option list - live from Outline, not locally cached."""
try:
result = _outline_request('collections.list', {'limit': 100})
except RuntimeError as e:
return jsonify({'collections': [], 'error': str(e)})
collections = [{'id': c['id'], 'name': c['name']} for c in result.get('data', [])]
return jsonify({'collections': collections})
@app.route('/api/outline/resolve-topic', methods=['POST'])
def resolve_outline_topic_api():
"""
Resolve a freshly-typed topic name to a real Outline collection id right
away (find-or-create), rather than deferring to the fire-and-forget
pagehide push - see push_note_to_outline's docstring for why that
matters (a page that fires pagehide more than once, e.g. via bfcache,
would otherwise send the same 'create a new topic' intent repeatedly).
"""
data = request.json or {}
name = (data.get('name') or '').strip()
if not name:
return jsonify({'error': 'name is required'}), 400
try:
topic = resolve_outline_topic(name)
except RuntimeError as e:
return jsonify({'error': str(e)}), 502
return jsonify({'success': True, 'id': topic['id'], 'name': topic['name']})
@app.route('/api/outline/push', methods=['POST'])
def push_outline_note_api():
"""
Push a lesson's note to Outline. Called via navigator.sendBeacon() on
pagehide, so this has no interactive caller to report errors back to in
the common case - failures are logged server-side and otherwise silent,
matching the fire-and-forget nature of the trigger.
"""
global current_course
if not current_course:
return jsonify({'error': 'No course loaded'}), 400
data = request.json or {}
lesson_path = data.get('lesson_path')
lesson_title = data.get('lesson_title', lesson_path)
topic_id = (data.get('topic_id') or '').strip()
new_topic_name = (data.get('new_topic_name') or '').strip()
if not lesson_path:
return jsonify({'error': 'lesson_path is required'}), 400
try:
result = push_note_to_outline(current_course, lesson_path, lesson_title, topic_id, new_topic_name)
except RuntimeError as e:
print(f"Outline push failed: {e}")
return jsonify({'success': False, 'error': str(e)}), 502
if not result['success']:
print(f"Outline push skipped: {result['error']}")
return jsonify(result)
@app.route('/api/course/mark-watched', methods=['POST'])
def mark_course_watched_api():
"""Mark every lesson in the currently loaded course as completed."""