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:
+270
-2
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user