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:
@@ -205,6 +205,49 @@ you need real data for every result you return).
|
|||||||
progress+notes file (stdlib `zipfile`, no new dependency) - not the
|
progress+notes file (stdlib `zipfile`, no new dependency) - not the
|
||||||
course files themselves.
|
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
|
## Known limitations still open
|
||||||
- App is unauthenticated by design (matches upstream) — settings and hidden-path
|
- App is unauthenticated by design (matches upstream) — settings and hidden-path
|
||||||
curation apply app-wide, not per-browser/per-user.
|
curation apply app-wide, not per-browser/per-user.
|
||||||
|
|||||||
+270
-2
@@ -12,6 +12,8 @@ import sys
|
|||||||
import argparse
|
import argparse
|
||||||
import io
|
import io
|
||||||
import zipfile
|
import zipfile
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from dataclasses import dataclass, asdict
|
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')
|
RECENT_VIEWS_FILE = os.path.join(DATA_DIR, 'recent_views.json')
|
||||||
MAX_RECENT_VIEWS = 20
|
MAX_RECENT_VIEWS = 20
|
||||||
|
|
||||||
@@ -1057,6 +1183,35 @@ class ProgressTracker:
|
|||||||
entry.pop('note', None)
|
entry.pop('note', None)
|
||||||
ProgressTracker.save_progress(course, progress)
|
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
|
@staticmethod
|
||||||
def mark_all_completed(course: Course):
|
def mark_all_completed(course: Course):
|
||||||
"""Mark every lesson in the course as completed, in one save."""
|
"""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
|
# Read the note directly from the progress file rather than the Lesson
|
||||||
# object - apply_progress_to_tree (which populates Lesson fields) isn't
|
# object - apply_progress_to_tree (which populates Lesson fields) isn't
|
||||||
# called on this code path, only on the dashboard's tree render.
|
# 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',
|
return render_template('lesson_view.html',
|
||||||
course=current_course,
|
course=current_course,
|
||||||
lesson=lesson,
|
lesson=lesson,
|
||||||
lesson_path=lesson_path,
|
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,
|
prev_lesson=prev_lesson,
|
||||||
next_lesson=next_lesson)
|
next_lesson=next_lesson)
|
||||||
|
|
||||||
@@ -1818,6 +1975,117 @@ def update_lesson_note_api():
|
|||||||
return jsonify({'success': True})
|
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'])
|
@app.route('/api/course/mark-watched', methods=['POST'])
|
||||||
def mark_course_watched_api():
|
def mark_course_watched_api():
|
||||||
"""Mark every lesson in the currently loaded course as completed."""
|
"""Mark every lesson in the currently loaded course as completed."""
|
||||||
|
|||||||
@@ -205,6 +205,31 @@
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
min-height: 1.2em;
|
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 {
|
.navigation {
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -354,6 +379,17 @@
|
|||||||
<textarea id="lesson-note" class="lesson-note-textarea"
|
<textarea id="lesson-note" class="lesson-note-textarea"
|
||||||
placeholder="Jot something down about this lesson…">{{ lesson_note }}</textarea>
|
placeholder="Jot something down about this lesson…">{{ lesson_note }}</textarea>
|
||||||
<span id="note-status" class="note-status"></span>
|
<span id="note-status" class="note-status"></span>
|
||||||
|
|
||||||
|
<div class="outline-topic-row">
|
||||||
|
<label for="outline-topic-select">Push to Outline topic</label>
|
||||||
|
<select id="outline-topic-select" onchange="handleOutlineTopicChange()">
|
||||||
|
<option value="">— Don't push —</option>
|
||||||
|
<option value="__new__">+ New topic…</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="outline-new-topic-input" class="outline-new-topic-input"
|
||||||
|
placeholder="New topic name" style="display: none;">
|
||||||
|
</div>
|
||||||
|
<span id="outline-topic-status" class="note-status"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-buttons">
|
<div class="nav-buttons">
|
||||||
@@ -559,6 +595,121 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Outline topic chooser + push-on-leave ----
|
||||||
|
const LESSON_PATH = {{ lesson_path|tojson }};
|
||||||
|
const LESSON_TITLE = {{ lesson.title|tojson }};
|
||||||
|
const INITIAL_TOPIC_ID = {{ outline_topic_id|tojson }};
|
||||||
|
const INITIAL_TOPIC_NAME = {{ outline_topic_name|tojson }};
|
||||||
|
|
||||||
|
const topicSelect = document.getElementById('outline-topic-select');
|
||||||
|
const newTopicInput = document.getElementById('outline-new-topic-input');
|
||||||
|
const topicStatus = document.getElementById('outline-topic-status');
|
||||||
|
|
||||||
|
if (topicSelect) {
|
||||||
|
fetch('/api/outline/collections')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const newOption = topicSelect.querySelector('option[value="__new__"]');
|
||||||
|
(data.collections || []).forEach(function(c) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = c.id;
|
||||||
|
opt.textContent = c.name;
|
||||||
|
topicSelect.insertBefore(opt, newOption);
|
||||||
|
});
|
||||||
|
if (INITIAL_TOPIC_ID) {
|
||||||
|
topicSelect.value = INITIAL_TOPIC_ID;
|
||||||
|
} else if (INITIAL_TOPIC_NAME) {
|
||||||
|
topicSelect.value = '__new__';
|
||||||
|
newTopicInput.style.display = 'inline-block';
|
||||||
|
newTopicInput.value = INITIAL_TOPIC_NAME;
|
||||||
|
}
|
||||||
|
if (data.error && topicStatus) {
|
||||||
|
topicStatus.textContent = 'Outline not reachable - topic list may be incomplete';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Resolve a freshly-typed topic name to a real collection
|
||||||
|
// id immediately (find-or-create), rather than saving it as
|
||||||
|
// a pending name for the fire-and-forget pagehide push to
|
||||||
|
// deal with later - a page that fires pagehide more than
|
||||||
|
// once for the same view (e.g. restored from the browser's
|
||||||
|
// back/forward cache) would otherwise re-send the same "new
|
||||||
|
// topic" intent every time and create a duplicate
|
||||||
|
// collection per navigation.
|
||||||
|
newTopicInput.addEventListener('blur', function() {
|
||||||
|
const name = this.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
topicStatus.textContent = 'Creating topic…';
|
||||||
|
fetch('/api/outline/resolve-topic', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: name })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success) {
|
||||||
|
topicStatus.textContent = data.error || 'Could not create topic';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newOption = topicSelect.querySelector('option[value="__new__"]');
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = data.id;
|
||||||
|
opt.textContent = data.name;
|
||||||
|
topicSelect.insertBefore(opt, newOption);
|
||||||
|
topicSelect.value = data.id;
|
||||||
|
newTopicInput.style.display = 'none';
|
||||||
|
newTopicInput.value = '';
|
||||||
|
topicStatus.textContent = '';
|
||||||
|
saveOutlineTopicChoice(data.id, '');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
topicStatus.textContent = 'Could not reach the server';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOutlineTopicChange() {
|
||||||
|
if (topicSelect.value === '__new__') {
|
||||||
|
newTopicInput.style.display = 'inline-block';
|
||||||
|
newTopicInput.focus();
|
||||||
|
return; // wait for a name before resolving
|
||||||
|
}
|
||||||
|
newTopicInput.style.display = 'none';
|
||||||
|
newTopicInput.value = '';
|
||||||
|
saveOutlineTopicChoice(topicSelect.value, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveOutlineTopicChoice(topicId, topicName) {
|
||||||
|
fetch('/api/lesson-note/topic', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ lesson_path: LESSON_PATH, topic_id: topicId, topic_name: topicName })
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire the push via sendBeacon on pagehide - fires on any real
|
||||||
|
// navigation away (Previous/Next/Back-to-Course, browser back,
|
||||||
|
// closing the tab) and is far more likely to actually complete
|
||||||
|
// than a beforeunload-triggered fetch. No result to show the
|
||||||
|
// user either way, since the page is already gone by the time
|
||||||
|
// it lands.
|
||||||
|
window.addEventListener('pagehide', function() {
|
||||||
|
if (!topicSelect) return;
|
||||||
|
const isNew = topicSelect.value === '__new__';
|
||||||
|
const topicId = isNew ? '' : topicSelect.value;
|
||||||
|
const newTopicName = isNew ? newTopicInput.value.trim() : '';
|
||||||
|
if (!topicId && !newTopicName) return;
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
lesson_path: LESSON_PATH,
|
||||||
|
lesson_title: LESSON_TITLE,
|
||||||
|
topic_id: topicId,
|
||||||
|
new_topic_name: newTopicName
|
||||||
|
});
|
||||||
|
navigator.sendBeacon('/api/outline/push', new Blob([payload], { type: 'application/json' }));
|
||||||
|
});
|
||||||
|
|
||||||
if (activeMedia) {
|
if (activeMedia) {
|
||||||
let lastSaveTime = 0;
|
let lastSaveTime = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -521,6 +521,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Outline Integration</h2>
|
||||||
|
<p class="setting-desc" style="margin-bottom: 12px;">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div class="setting-row" style="flex-direction: column; align-items: stretch; gap: 8px;">
|
||||||
|
<label for="outline-url">Outline URL
|
||||||
|
<span class="setting-desc" id="outline-config-status">Not configured</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" id="outline-url" class="hex-input" style="width: 100%; font-family: var(--font-family);"
|
||||||
|
placeholder="https://outline.example.com">
|
||||||
|
<label for="outline-token" style="margin-top: 4px;">API token</label>
|
||||||
|
<input type="password" id="outline-token" class="hex-input" style="width: 100%; font-family: var(--font-family);"
|
||||||
|
placeholder="Leave blank to keep the current token">
|
||||||
|
<div style="display: flex; gap: 10px; margin-top: 4px;">
|
||||||
|
<button class="btn" onclick="saveOutlineConfig()">Save</button>
|
||||||
|
<button class="btn btn-secondary" onclick="testOutlineConnection()">Test Connection</button>
|
||||||
|
</div>
|
||||||
|
<span id="outline-test-status" style="font-size: 0.85em; min-height: 1.2em;"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-secondary" onclick="resetSettings()">Reset to Defaults</button>
|
<button class="btn btn-secondary" onclick="resetSettings()">Reset to Defaults</button>
|
||||||
<span id="save-status">✓ Saved</span>
|
<span id="save-status">✓ Saved</span>
|
||||||
@@ -971,6 +994,65 @@
|
|||||||
.catch(err => console.error('Failed to reset video size:', err));
|
.catch(err => console.error('Failed to reset video size:', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Outline integration ----
|
||||||
|
function loadOutlineConfig() {
|
||||||
|
fetch('/api/outline/config')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
document.getElementById('outline-url').value = data.base_url || '';
|
||||||
|
document.getElementById('outline-config-status').textContent =
|
||||||
|
data.configured ? 'Configured' : 'Not configured';
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveOutlineConfig() {
|
||||||
|
const status = document.getElementById('outline-test-status');
|
||||||
|
const url = document.getElementById('outline-url').value.trim();
|
||||||
|
const token = document.getElementById('outline-token').value.trim();
|
||||||
|
status.style.color = 'var(--text-muted)';
|
||||||
|
status.textContent = 'Saving...';
|
||||||
|
fetch('/api/outline/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ base_url: url, api_token: token })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
status.style.color = '#28a745';
|
||||||
|
status.textContent = 'Saved.';
|
||||||
|
document.getElementById('outline-token').value = '';
|
||||||
|
loadOutlineConfig();
|
||||||
|
} else {
|
||||||
|
status.style.color = '#ff6b6b';
|
||||||
|
status.textContent = data.error || 'Could not save.';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
status.style.color = '#ff6b6b';
|
||||||
|
status.textContent = 'Could not reach the server.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function testOutlineConnection() {
|
||||||
|
const status = document.getElementById('outline-test-status');
|
||||||
|
status.style.color = 'var(--text-muted)';
|
||||||
|
status.textContent = 'Testing...';
|
||||||
|
fetch('/api/outline/test')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
status.style.color = data.success ? '#28a745' : '#ff6b6b';
|
||||||
|
status.textContent = data.success ? 'Connected.' : (data.error || 'Connection failed.');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
status.style.color = '#ff6b6b';
|
||||||
|
status.textContent = 'Could not reach the server.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadOutlineConfig();
|
||||||
|
|
||||||
function resetSettings() {
|
function resetSettings() {
|
||||||
fetch('/api/settings/reset', { method: 'POST' })
|
fetch('/api/settings/reset', { method: 'POST' })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
|
|||||||
Reference in New Issue
Block a user