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
+43
View File
@@ -205,6 +205,49 @@ you need real data for every result you return).
progress+notes file (stdlib `zipfile`, no new dependency) - not the progress+notes file (stdlib `zipfile`, no new dependency) - not the
course files themselves. course files themselves.
## Outline integration (this session)
Push lesson notes to a self-hosted Outline instance
(`https://mikeline.michaelsitz.com`), organized by a "topic" the user picks
per note - each topic is its own Outline collection. Only notes with a
topic selected get pushed; everything else stays local-only, same as
before.
- **Credential handling**: the API token lives in its own
`OUTLINE_CONFIG_FILE` (`outline_config.json` in `DATA_DIR`), deliberately
*not* part of `DEFAULT_SETTINGS`/`load_settings` - those flow through
`GET /api/settings`, which `theme.js` fetches on every page load, and a
secret has no business riding along in that response. `GET
/api/outline/config` only ever returns `{configured, base_url}`, never
the token. Confirmed via a real (mocked-backend) test: token round-trips
through save but never comes back out on GET.
- **Topic chooser = live Outline collections**, not a locally-cached list -
`GET /api/outline/collections` proxies `collections.list` fresh every
time. Naming a new topic resolves it to a real collection **immediately**
(find-by-exact-name-or-create, `resolve_outline_topic()` /
`POST /api/outline/resolve-topic`) the moment you tab out of the "new
topic" field, rather than waiting until the note is pushed.
- **Why eager resolution, not deferred**: the first design deferred
collection creation to push-time (avoid an empty collection if the topic
was never actually used) - caught a real bug testing it: the push 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
browser back/forward-cache restore, for instance) would silently
re-create a *new* collection every time, since the client had no way to
learn the topic name had already been resolved. Fixed by resolving up
front and by making the deferred fallback path dedupe-by-name too
(`resolve_outline_topic`) - verified live (mocked Outline backend) that
firing multiple sequential `pagehide` events for the same lesson creates
the collection/document once and updates thereafter, never duplicates.
- **Push flow**: `POST /api/outline/push` - creates the Outline document on
first push, updates the same one (by the id stashed in the lesson's
progress-file entry, alongside the existing `note` field) on every push
after. No-ops if the note is empty.
- Verified end-to-end against a local mock Outline server (not the real
instance - no write-testing against live external services this
session), including Settings → Test Connection hitting the real HTTP
path.
## 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.
+270 -2
View File
@@ -12,6 +12,8 @@ import sys
import argparse import argparse
import io import io
import zipfile import zipfile
import urllib.request
import urllib.error
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta from datetime import datetime, timedelta
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
@@ -901,6 +903,130 @@ def get_library_stats() -> Dict[str, Any]:
} }
# ---- Outline integration ----
# Deliberately kept separate from DEFAULT_SETTINGS/load_settings/save_settings:
# those all flow through GET /api/settings, which theme.js fetches on every
# page load - not where an API token should ever ride along.
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'."""
try:
if os.path.exists(OUTLINE_CONFIG_FILE):
with open(OUTLINE_CONFIG_FILE, 'r') as f:
data = json.load(f)
if isinstance(data, dict):
return {
'base_url': data.get('base_url', ''),
'api_token': data.get('api_token', '')
}
except (json.JSONDecodeError, OSError) as e:
print(f"Could not load Outline config: {e}")
return {'base_url': '', 'api_token': ''}
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."""
current = load_outline_config()
current['base_url'] = base_url.rstrip('/')
if api_token:
current['api_token'] = api_token
os.makedirs(DATA_DIR, exist_ok=True)
with open(OUTLINE_CONFIG_FILE, 'w') as f:
json.dump(current, f, indent=2)
def _outline_request(path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
"""
POST to the Outline API (every Outline endpoint is POST, even 'list'
ones) with the stored token. Raises RuntimeError with a readable message
on any failure - not configured, unreachable, or a non-2xx response.
"""
config = load_outline_config()
if not config['base_url'] or not config['api_token']:
raise RuntimeError('Outline is not configured')
url = f"{config['base_url']}/api/{path}"
body = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(url, data=body, method='POST', headers={
'Authorization': f"Bearer {config['api_token']}",
'Content-Type': 'application/json',
'Accept': 'application/json',
})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode('utf-8'))
except urllib.error.HTTPError as e:
detail = e.read().decode('utf-8', errors='replace')
raise RuntimeError(f'Outline returned {e.code}: {detail[:200]}')
except urllib.error.URLError as e:
raise RuntimeError(f'Could not reach Outline: {e.reason}')
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.
"""
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'})
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.
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
(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.
"""
progress = ProgressTracker.load_progress(course)
entry = progress.get(lesson_path, {})
note = entry.get('note', '')
if not note:
return {'success': False, 'error': 'No note to push'}
if not topic_id:
if not new_topic_name:
return {'success': False, 'error': 'No topic selected'}
topic_id = resolve_outline_topic(new_topic_name)['id']
ProgressTracker.set_lesson_outline_topic(course, lesson_path, topic_id, '')
title = f"{lesson_title}{course.name}"
document_id = entry.get('outline_document_id')
if document_id:
_outline_request('documents.update', {'id': document_id, 'title': title, 'text': note})
else:
created = _outline_request('documents.create', {
'title': title,
'text': note,
'collectionId': topic_id,
'publish': True
})
document_id = created['data']['id']
ProgressTracker.set_lesson_outline_document_id(course, lesson_path, document_id)
return {'success': True, 'document_id': document_id, 'topic_id': topic_id}
RECENT_VIEWS_FILE = os.path.join(DATA_DIR, 'recent_views.json') RECENT_VIEWS_FILE = os.path.join(DATA_DIR, 'recent_views.json')
MAX_RECENT_VIEWS = 20 MAX_RECENT_VIEWS = 20
@@ -1057,6 +1183,35 @@ class ProgressTracker:
entry.pop('note', None) entry.pop('note', None)
ProgressTracker.save_progress(course, progress) ProgressTracker.save_progress(course, progress)
@staticmethod
def set_lesson_outline_topic(course: Course, lesson_path: str, topic_id: str, topic_name: str):
"""
Remember which Outline topic (collection) a lesson's note should push
to - an existing collection id, or a not-yet-created topic name (see
push_note_to_outline). Clearing the pick (both blank) removes it so
pagehide stops firing a push for this lesson.
"""
progress = ProgressTracker.load_progress(course)
entry = progress.setdefault(lesson_path, {})
if topic_id:
entry['outline_topic_id'] = topic_id
entry.pop('outline_topic_name', None)
elif topic_name:
entry['outline_topic_name'] = topic_name
entry.pop('outline_topic_id', None)
else:
entry.pop('outline_topic_id', None)
entry.pop('outline_topic_name', None)
ProgressTracker.save_progress(course, progress)
@staticmethod
def set_lesson_outline_document_id(course: Course, lesson_path: str, document_id: str):
"""Remember the Outline document a lesson's note was pushed to, so the next push updates it instead of creating a duplicate."""
progress = ProgressTracker.load_progress(course)
entry = progress.setdefault(lesson_path, {})
entry['outline_document_id'] = document_id
ProgressTracker.save_progress(course, progress)
@staticmethod @staticmethod
def mark_all_completed(course: Course): def mark_all_completed(course: Course):
"""Mark every lesson in the course as completed, in one save.""" """Mark every lesson in the course as completed, in one save."""
@@ -1706,13 +1861,15 @@ def view_lesson(lesson_path: str):
# Read the note directly from the progress file rather than the Lesson # Read the note directly from the progress file rather than the Lesson
# object - apply_progress_to_tree (which populates Lesson fields) isn't # object - apply_progress_to_tree (which populates Lesson fields) isn't
# called on this code path, only on the dashboard's tree render. # called on this code path, only on the dashboard's tree render.
note = ProgressTracker.load_progress(current_course).get(lesson_path, {}).get('note', '') lesson_progress = ProgressTracker.load_progress(current_course).get(lesson_path, {})
return render_template('lesson_view.html', return render_template('lesson_view.html',
course=current_course, course=current_course,
lesson=lesson, lesson=lesson,
lesson_path=lesson_path, lesson_path=lesson_path,
lesson_note=note, lesson_note=lesson_progress.get('note', ''),
outline_topic_id=lesson_progress.get('outline_topic_id', ''),
outline_topic_name=lesson_progress.get('outline_topic_name', ''),
prev_lesson=prev_lesson, prev_lesson=prev_lesson,
next_lesson=next_lesson) next_lesson=next_lesson)
@@ -1818,6 +1975,117 @@ def update_lesson_note_api():
return jsonify({'success': True}) return jsonify({'success': True})
@app.route('/api/lesson-note/topic', methods=['POST'])
def update_lesson_note_topic_api():
"""Remember which Outline topic (collection) a lesson's note should push to."""
global current_course
if not current_course:
return jsonify({'error': 'No course loaded'}), 400
data = request.json or {}
lesson_path = data.get('lesson_path')
if not lesson_path:
return jsonify({'error': 'lesson_path is required'}), 400
topic_id = (data.get('topic_id') or '').strip()
topic_name = (data.get('topic_name') or '').strip()
ProgressTracker.set_lesson_outline_topic(current_course, lesson_path, topic_id, topic_name)
return jsonify({'success': True})
@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."""
config = load_outline_config()
return jsonify({'configured': bool(config['base_url'] and config['api_token']), 'base_url': config['base_url']})
@app.route('/api/outline/config', methods=['POST'])
def set_outline_config_api():
"""Save the Outline base URL / API token."""
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)
return jsonify({'success': True})
@app.route('/api/outline/test')
def test_outline_connection_api():
"""Ping Outline with the stored config, for the Settings page's 'Test Connection' button."""
try:
_outline_request('collections.list', {'limit': 1})
return jsonify({'success': True})
except RuntimeError as e:
return jsonify({'success': False, 'error': str(e)}), 502
@app.route('/api/outline/collections')
def list_outline_collections_api():
"""The topic chooser's option list - live from Outline, not locally cached."""
try:
result = _outline_request('collections.list', {'limit': 100})
except RuntimeError as e:
return jsonify({'collections': [], 'error': str(e)})
collections = [{'id': c['id'], 'name': c['name']} for c in result.get('data', [])]
return jsonify({'collections': collections})
@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
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,
would otherwise send the same 'create a new topic' intent repeatedly).
"""
data = request.json or {}
name = (data.get('name') or '').strip()
if not name:
return jsonify({'error': 'name is required'}), 400
try:
topic = resolve_outline_topic(name)
except RuntimeError as e:
return jsonify({'error': str(e)}), 502
return jsonify({'success': True, 'id': topic['id'], 'name': topic['name']})
@app.route('/api/outline/push', methods=['POST'])
def push_outline_note_api():
"""
Push a lesson's note to Outline. Called via navigator.sendBeacon() on
pagehide, so this has no interactive caller to report errors back to in
the common case - failures are logged server-side and otherwise silent,
matching the fire-and-forget nature of the trigger.
"""
global current_course
if not current_course:
return jsonify({'error': 'No course loaded'}), 400
data = request.json or {}
lesson_path = data.get('lesson_path')
lesson_title = data.get('lesson_title', lesson_path)
topic_id = (data.get('topic_id') or '').strip()
new_topic_name = (data.get('new_topic_name') or '').strip()
if not lesson_path:
return jsonify({'error': 'lesson_path is required'}), 400
try:
result = push_note_to_outline(current_course, lesson_path, lesson_title, topic_id, new_topic_name)
except RuntimeError as e:
print(f"Outline push failed: {e}")
return jsonify({'success': False, 'error': str(e)}), 502
if not result['success']:
print(f"Outline push skipped: {result['error']}")
return jsonify(result)
@app.route('/api/course/mark-watched', methods=['POST']) @app.route('/api/course/mark-watched', methods=['POST'])
def mark_course_watched_api(): def mark_course_watched_api():
"""Mark every lesson in the currently loaded course as completed.""" """Mark every lesson in the currently loaded course as completed."""
+151
View File
@@ -205,6 +205,31 @@
margin-top: 6px; margin-top: 6px;
min-height: 1.2em; 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 { .navigation {
margin: 20px 0; margin: 20px 0;
display: flex; display: flex;
@@ -354,6 +379,17 @@
<textarea id="lesson-note" class="lesson-note-textarea" <textarea id="lesson-note" class="lesson-note-textarea"
placeholder="Jot something down about this lesson…">{{ lesson_note }}</textarea> placeholder="Jot something down about this lesson…">{{ lesson_note }}</textarea>
<span id="note-status" class="note-status"></span> <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>
<div class="nav-buttons"> <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) { if (activeMedia) {
let lastSaveTime = 0; let lastSaveTime = 0;
+82
View File
@@ -521,6 +521,29 @@
</div> </div>
</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"> <div class="actions">
<button class="btn btn-secondary" onclick="resetSettings()">Reset to Defaults</button> <button class="btn btn-secondary" onclick="resetSettings()">Reset to Defaults</button>
<span id="save-status">✓ Saved</span> <span id="save-status">✓ Saved</span>
@@ -971,6 +994,65 @@
.catch(err => console.error('Failed to reset video size:', err)); .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() { function resetSettings() {
fetch('/api/settings/reset', { method: 'POST' }) fetch('/api/settings/reset', { method: 'POST' })
.then(r => r.json()) .then(r => r.json())