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:
2026-08-22 12:12:50 -04:00
co-authored by Claude Sonnet 5
parent a6c49d3762
commit 5fe3fafc7a
4 changed files with 546 additions and 2 deletions
+151
View File
@@ -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 @@
<textarea id="lesson-note" class="lesson-note-textarea"
placeholder="Jot something down about this lesson…">{{ lesson_note }}</textarea>
<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 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) {
let lastSaveTime = 0;
+82
View File
@@ -521,6 +521,29 @@
</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">
<button class="btn btn-secondary" onclick="resetSettings()">Reset to Defaults</button>
<span id="save-status">✓ Saved</span>
@@ -971,6 +994,65 @@
.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() {
fetch('/api/settings/reset', { method: 'POST' })
.then(r => r.json())