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
+23
View File
@@ -248,6 +248,29 @@ before.
session), including Settings → Test Connection hitting the real HTTP session), including Settings → Test Connection hitting the real HTTP
path. path.
## Outline integration follow-up fixes (this session)
- **Space bar bug**: the lesson page's global keyboard-shortcut handler
(space/arrows for play-pause/seek/volume) fired regardless of what had
focus, so typing a space in the notes textarea toggled the video instead.
Fixed with an `isTypingTarget()` guard that skips the shortcut handler
entirely when a textarea/input/select/contenteditable has focus.
- **Topic model redesign**: topics used to each spawn their own top-level
Outline *collection*. Changed so a topic is instead a *document* inside
one fixed, user-configured collection (Settings → Outline Integration →
Default collection, e.g. "Training Notes") - a lesson's note becomes a
child document nested under its topic document, matching how the user
actually organizes their real Outline instance. `list_outline_topics()`
fetches the collection's documents and filters to `parentDocumentId is
None` locally rather than trusting `documents.list`'s `collectionId`/
`parentDocumentId` request filters - both are marked deprecated in
Outline's own API spec with unclear recursive-vs-top-level semantics, so
filtering the response ourselves is the version that can't be wrong.
Verified end-to-end against a local mock Outline server: resolving a new
topic creates a top-level doc, pushing a note creates a child doc under
it, and the child never leaks back into the topic chooser as if it were
a topic itself.
## 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.
+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]: 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: try:
if os.path.exists(OUTLINE_CONFIG_FILE): if os.path.exists(OUTLINE_CONFIG_FILE):
with open(OUTLINE_CONFIG_FILE, 'r') as f: with open(OUTLINE_CONFIG_FILE, 'r') as f:
@@ -919,20 +919,33 @@ def load_outline_config() -> Dict[str, str]:
if isinstance(data, dict): if isinstance(data, dict):
return { return {
'base_url': data.get('base_url', ''), '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: except (json.JSONDecodeError, OSError) as e:
print(f"Could not load Outline config: {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: def save_outline_config(base_url: str, api_token: str,
"""Save the Outline base URL + API token. A blank api_token keeps the default_collection_id: Optional[str] = None,
previously stored one, so the URL can be updated without re-pasting it.""" 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 = load_outline_config()
current['base_url'] = base_url.rstrip('/') current['base_url'] = base_url.rstrip('/')
if api_token: if api_token:
current['api_token'] = 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) os.makedirs(DATA_DIR, exist_ok=True)
with open(OUTLINE_CONFIG_FILE, 'w') as f: with open(OUTLINE_CONFIG_FILE, 'w') as f:
json.dump(current, f, indent=2) 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}') 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]: def resolve_outline_topic(name: str) -> Dict[str, str]:
""" """
Find an existing Outline collection with this exact name, or create one. Find an existing topic document (a top-level document in the configured
Matching by name first (rather than always creating) means naming a default collection) with this exact title, or create one. Matching by
"new" topic that happens to match one you already made doesn't spawn a name first (rather than always creating) means naming a "new" topic
duplicate collection. that happens to match one you already made doesn't spawn a duplicate.
""" """
existing = _outline_request('collections.list', {'limit': 100}) for topic in list_outline_topics():
for c in existing.get('data', []): if topic['name'] == name:
if c['name'] == name: return topic
return {'id': c['id'], 'name': c['name']} config = load_outline_config()
created = _outline_request('collections.create', {'name': name, 'permission': 'read_write'}) created = _outline_request('documents.create', {
'title': name,
'text': '',
'collectionId': config['default_collection_id'],
'publish': True
})
return {'id': created['data']['id'], 'name': name} return {'id': created['data']['id'], 'name': name}
def push_note_to_outline(course: Course, lesson_path: str, lesson_title: str, def push_note_to_outline(course: Course, lesson_path: str, lesson_title: str,
topic_id: str, new_topic_name: str) -> Dict[str, Any]: 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 Push a lesson's saved note to Outline as a child document nested under
(collection). Creates the document on the first push and updates the the chosen topic document (which itself lives in the configured default
same one (by the id stashed in the progress file) on every push after collection - see list_outline_topics). Creates the note document on the
that. 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 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 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, (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 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 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 view (bfcache restore, for instance) would otherwise re-send
the same "new topic" intent every time and create a duplicate the same "new topic" intent every time and create a duplicate topic
collection per navigation. 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) progress = ProgressTracker.load_progress(course)
entry = progress.get(lesson_path, {}) entry = progress.get(lesson_path, {})
note = entry.get('note', '') 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', { created = _outline_request('documents.create', {
'title': title, 'title': title,
'text': note, 'text': note,
'collectionId': topic_id, 'collectionId': config['default_collection_id'],
'parentDocumentId': topic_id,
'publish': True 'publish': True
}) })
document_id = created['data']['id'] document_id = created['data']['id']
@@ -1996,20 +2043,29 @@ def update_lesson_note_topic_api():
@app.route('/api/outline/config', methods=['GET']) @app.route('/api/outline/config', methods=['GET'])
def get_outline_config_api(): 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() 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']) @app.route('/api/outline/config', methods=['POST'])
def set_outline_config_api(): 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 {} data = request.json or {}
base_url = (data.get('base_url') or '').strip() base_url = (data.get('base_url') or '').strip()
api_token = (data.get('api_token') or '').strip() api_token = (data.get('api_token') or '').strip()
if not base_url: if not base_url:
return jsonify({'error': 'Outline URL is required'}), 400 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}) return jsonify({'success': True})
@@ -2025,7 +2081,7 @@ def test_outline_connection_api():
@app.route('/api/outline/collections') @app.route('/api/outline/collections')
def list_outline_collections_api(): 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: try:
result = _outline_request('collections.list', {'limit': 100}) result = _outline_request('collections.list', {'limit': 100})
except RuntimeError as e: except RuntimeError as e:
@@ -2034,10 +2090,20 @@ def list_outline_collections_api():
return jsonify({'collections': collections}) 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']) @app.route('/api/outline/resolve-topic', methods=['POST'])
def resolve_outline_topic_api(): 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 away (find-or-create), rather than deferring to the fire-and-forget
pagehide push - see push_note_to_outline's docstring for why that 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, matters (a page that fires pagehide more than once, e.g. via bfcache,
+13 -4
View File
@@ -606,11 +606,11 @@
const topicStatus = document.getElementById('outline-topic-status'); const topicStatus = document.getElementById('outline-topic-status');
if (topicSelect) { if (topicSelect) {
fetch('/api/outline/collections') fetch('/api/outline/topics')
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
const newOption = topicSelect.querySelector('option[value="__new__"]'); const newOption = topicSelect.querySelector('option[value="__new__"]');
(data.collections || []).forEach(function(c) { (data.topics || []).forEach(function(c) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = c.id; opt.value = c.id;
opt.textContent = c.name; opt.textContent = c.name;
@@ -624,7 +624,7 @@
newTopicInput.value = INITIAL_TOPIC_NAME; newTopicInput.value = INITIAL_TOPIC_NAME;
} }
if (data.error && topicStatus) { if (data.error && topicStatus) {
topicStatus.textContent = 'Outline not reachable - topic list may be incomplete'; topicStatus.textContent = data.error;
} }
}) })
.catch(() => {}); .catch(() => {});
@@ -805,8 +805,17 @@
}, 3000); }, 3000);
} }
// Keyboard shortcuts // Keyboard shortcuts - skip while typing in the note (or any
// other text field), so space/arrow keys type normally instead
// of hijacking video playback.
function isTypingTarget(el) {
if (!el) return false;
const tag = el.tagName;
return tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'SELECT' || el.isContentEditable;
}
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (isTypingTarget(e.target)) return;
if (activeMedia && !e.ctrlKey && !e.altKey && !e.metaKey) { if (activeMedia && !e.ctrlKey && !e.altKey && !e.metaKey) {
switch(e.key) { switch(e.key) {
case ' ': case ' ':
+37 -3
View File
@@ -524,8 +524,9 @@
<div class="card"> <div class="card">
<h2>Outline Integration</h2> <h2>Outline Integration</h2>
<p class="setting-desc" style="margin-bottom: 12px;"> <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 Pick a topic on a lesson's note (on the lesson page) to push it there when you leave the page -
Outline when you leave the page. Notes without a topic stay local only. each topic becomes a document inside the collection below, and the note is nested under it.
Notes without a topic stay local only.
</p> </p>
<div class="setting-row" style="flex-direction: column; align-items: stretch; gap: 8px;"> <div class="setting-row" style="flex-direction: column; align-items: stretch; gap: 8px;">
<label for="outline-url">Outline URL <label for="outline-url">Outline URL
@@ -536,6 +537,12 @@
<label for="outline-token" style="margin-top: 4px;">API token</label> <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);" <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"> placeholder="Leave blank to keep the current token">
<label for="outline-collection" style="margin-top: 4px;">Default collection
<span class="setting-desc">Where topic documents get created - e.g. "Training Notes"</span>
</label>
<select id="outline-collection">
<option value="">Save your URL/token first, then pick one</option>
</select>
<div style="display: flex; gap: 10px; margin-top: 4px;"> <div style="display: flex; gap: 10px; margin-top: 4px;">
<button class="btn" onclick="saveOutlineConfig()">Save</button> <button class="btn" onclick="saveOutlineConfig()">Save</button>
<button class="btn btn-secondary" onclick="testOutlineConnection()">Test Connection</button> <button class="btn btn-secondary" onclick="testOutlineConnection()">Test Connection</button>
@@ -1002,6 +1009,25 @@
document.getElementById('outline-url').value = data.base_url || ''; document.getElementById('outline-url').value = data.base_url || '';
document.getElementById('outline-config-status').textContent = document.getElementById('outline-config-status').textContent =
data.configured ? 'Configured' : 'Not configured'; data.configured ? 'Configured' : 'Not configured';
loadOutlineCollectionsDropdown(data.default_collection_id || '');
})
.catch(() => {});
}
function loadOutlineCollectionsDropdown(selectedId) {
const select = document.getElementById('outline-collection');
fetch('/api/outline/collections')
.then(r => r.json())
.then(data => {
const collections = data.collections || [];
if (collections.length === 0) {
select.innerHTML = '<option value="">No collections found - check your URL/token</option>';
return;
}
select.innerHTML = collections.map(c =>
`<option value="${c.id}">${c.name}</option>`
).join('');
if (selectedId) select.value = selectedId;
}) })
.catch(() => {}); .catch(() => {});
} }
@@ -1010,12 +1036,20 @@
const status = document.getElementById('outline-test-status'); const status = document.getElementById('outline-test-status');
const url = document.getElementById('outline-url').value.trim(); const url = document.getElementById('outline-url').value.trim();
const token = document.getElementById('outline-token').value.trim(); const token = document.getElementById('outline-token').value.trim();
const collectionSelect = document.getElementById('outline-collection');
const collectionId = collectionSelect.value;
const collectionName = collectionId ? collectionSelect.options[collectionSelect.selectedIndex].text : '';
status.style.color = 'var(--text-muted)'; status.style.color = 'var(--text-muted)';
status.textContent = 'Saving...'; status.textContent = 'Saving...';
fetch('/api/outline/config', { fetch('/api/outline/config', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ base_url: url, api_token: token }) body: JSON.stringify({
base_url: url,
api_token: token,
default_collection_id: collectionId,
default_collection_name: collectionName
})
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {