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:
@@ -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