diff --git a/OfflineU-project-summary.md b/OfflineU-project-summary.md index 84aa3c8..d6f7f7f 100644 --- a/OfflineU-project-summary.md +++ b/OfflineU-project-summary.md @@ -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. diff --git a/offlineu_core.py b/offlineu_core.py index 2af4737..5dfc12d 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -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.""" diff --git a/templates/lesson_view.html b/templates/lesson_view.html index 4e99ff0..e91936a 100644 --- a/templates/lesson_view.html +++ b/templates/lesson_view.html @@ -205,6 +205,31 @@ margin-top: 6px; min-height: 1.2em; } + .outline-topic-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; + } + .outline-topic-row label { + font-size: 0.9em; + color: var(--text-muted); + } + .outline-topic-row select, + .outline-new-topic-input { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 6px 10px; + font-size: 0.9em; + font-family: var(--font-family); + } + .outline-new-topic-input { + flex: 1; + min-width: 140px; + } .navigation { margin: 20px 0; display: flex; @@ -354,6 +379,17 @@ + +
+ Pick a topic on a lesson's note (on the lesson page) to push it to that topic's collection in + Outline when you leave the page. Notes without a topic stay local only. +
+