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,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;
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user