Fix note-field space bar hijacking video, rework Outline topics as documents

- Lesson page's global keyboard shortcuts (space/arrows for play-pause/
  seek/volume) fired regardless of focus, so typing a space in the notes
  textarea toggled the video instead of typing. Now skipped entirely
  whenever a text field/select has focus.
- Outline topics no longer spawn their own top-level collection each time.
  A topic is now a document inside one fixed, user-configured collection
  (Settings -> Outline Integration -> Default collection), and a lesson's
  note becomes a child document nested under its topic - matching how
  notes actually get organized in a real Outline instance instead of
  cluttering the collections list with one per topic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 12:27:00 -04:00
co-authored by Claude Sonnet 5
parent 5fe3fafc7a
commit 145f91a647
4 changed files with 168 additions and 36 deletions
+95 -29
View File
@@ -911,7 +911,7 @@ 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'."""
"""Load the Outline base URL + API token + default collection. Never exposed via GET beyond 'configured'."""
try:
if os.path.exists(OUTLINE_CONFIG_FILE):
with open(OUTLINE_CONFIG_FILE, 'r') as f:
@@ -919,20 +919,33 @@ def load_outline_config() -> Dict[str, str]:
if isinstance(data, dict):
return {
'base_url': data.get('base_url', ''),
'api_token': data.get('api_token', '')
'api_token': data.get('api_token', ''),
'default_collection_id': data.get('default_collection_id', ''),
'default_collection_name': data.get('default_collection_name', '')
}
except (json.JSONDecodeError, OSError) as e:
print(f"Could not load Outline config: {e}")
return {'base_url': '', 'api_token': ''}
return {'base_url': '', 'api_token': '', 'default_collection_id': '', 'default_collection_name': ''}
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."""
def save_outline_config(base_url: str, api_token: str,
default_collection_id: Optional[str] = None,
default_collection_name: Optional[str] = None) -> None:
"""
Save the Outline base URL / API token / default collection. A blank
api_token keeps the previously stored one, so the URL can be updated
without re-pasting it; default_collection_id/name are only touched when
explicitly passed (None means 'leave as-is'), so saving the URL/token
doesn't clear an already-chosen collection.
"""
current = load_outline_config()
current['base_url'] = base_url.rstrip('/')
if api_token:
current['api_token'] = api_token
if default_collection_id is not None:
current['default_collection_id'] = default_collection_id
if default_collection_name is not None:
current['default_collection_name'] = default_collection_name
os.makedirs(DATA_DIR, exist_ok=True)
with open(OUTLINE_CONFIG_FILE, 'w') as f:
json.dump(current, f, indent=2)
@@ -965,39 +978,72 @@ def _outline_request(path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
raise RuntimeError(f'Could not reach Outline: {e.reason}')
def list_outline_topics() -> List[Dict[str, str]]:
"""
Top-level documents in the configured default collection - each one is
a "topic" a lesson note can be filed under. Fetches every document in
the collection and filters to parentDocumentId is None locally, rather
than relying on documents.list's collectionId/parentDocumentId filter
params directly - both are marked deprecated in Outline's own API spec
and their exact recursive-vs-top-level behavior isn't documented, so
filtering the response ourselves is the version that can't be wrong.
"""
config = load_outline_config()
collection_id = config['default_collection_id']
if not collection_id:
raise RuntimeError('No default Outline collection set - pick one in Settings first')
result = _outline_request('documents.list', {'collectionId': collection_id, 'limit': 100})
return [
{'id': d['id'], 'name': d['title']}
for d in result.get('data', [])
if not d.get('parentDocumentId')
]
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.
Find an existing topic document (a top-level document in the configured
default collection) with this exact title, 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.
"""
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'})
for topic in list_outline_topics():
if topic['name'] == name:
return topic
config = load_outline_config()
created = _outline_request('documents.create', {
'title': name,
'text': '',
'collectionId': config['default_collection_id'],
'publish': True
})
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.
Push a lesson's saved note to Outline as a child document nested under
the chosen topic document (which itself lives in the configured default
collection - see list_outline_topics). Creates the note 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
brand-new topic name to a real document 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.
the same "new topic" intent every time and create a duplicate topic
document per navigation.
"""
config = load_outline_config()
if not config['default_collection_id']:
return {'success': False, 'error': 'No default Outline collection set - pick one in Settings first'}
progress = ProgressTracker.load_progress(course)
entry = progress.get(lesson_path, {})
note = entry.get('note', '')
@@ -1018,7 +1064,8 @@ def push_note_to_outline(course: Course, lesson_path: str, lesson_title: str,
created = _outline_request('documents.create', {
'title': title,
'text': note,
'collectionId': topic_id,
'collectionId': config['default_collection_id'],
'parentDocumentId': topic_id,
'publish': True
})
document_id = created['data']['id']
@@ -1996,20 +2043,29 @@ def update_lesson_note_topic_api():
@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."""
"""Whether Outline is configured, the (non-secret) base URL, and the default collection - never the token."""
config = load_outline_config()
return jsonify({'configured': bool(config['base_url'] and config['api_token']), 'base_url': config['base_url']})
return jsonify({
'configured': bool(config['base_url'] and config['api_token']),
'base_url': config['base_url'],
'default_collection_id': config['default_collection_id'],
'default_collection_name': config['default_collection_name']
})
@app.route('/api/outline/config', methods=['POST'])
def set_outline_config_api():
"""Save the Outline base URL / API token."""
"""Save the Outline base URL / API token / default collection."""
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)
save_outline_config(
base_url, api_token,
default_collection_id=data.get('default_collection_id'),
default_collection_name=data.get('default_collection_name')
)
return jsonify({'success': True})
@@ -2025,7 +2081,7 @@ def test_outline_connection_api():
@app.route('/api/outline/collections')
def list_outline_collections_api():
"""The topic chooser's option list - live from Outline, not locally cached."""
"""Every Outline collection - used by the Settings page's default-collection picker."""
try:
result = _outline_request('collections.list', {'limit': 100})
except RuntimeError as e:
@@ -2034,10 +2090,20 @@ def list_outline_collections_api():
return jsonify({'collections': collections})
@app.route('/api/outline/topics')
def list_outline_topics_api():
"""The lesson page's topic chooser option list - top-level documents in the configured default collection."""
try:
topics = list_outline_topics()
except RuntimeError as e:
return jsonify({'topics': [], 'error': str(e)})
return jsonify({'topics': topics})
@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
Resolve a freshly-typed topic name to a real Outline document 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,