diff --git a/README.md b/README.md index b53b6fa..67509bb 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ protocol-droid/ ``` 4. The database schema auto-initializes on first API request (creates `data/protocol_droid.db`) 5. Visit the site and register — the **first user to register becomes admin** -6. Optionally load seed protocols: sign in as admin and `POST /api/seed` (or use the API endpoint with curl: `curl -b cookies.txt -X POST https://your-site/api/seed`) +5. Optionally load seed protocols: Go to the **About** page and click **"Load seed protocols"** (visible to admins only). This loads 6 example protocols into your library in one click. No curl needed. **No default admin credentials exist.** A fresh deployment has an empty database. The first registration creates the admin account. Seed protocols are optional and must be explicitly loaded. @@ -103,22 +103,7 @@ cd protocol-droid php -S localhost:8000 ``` -The SQLite database is created automatically at `data/protocol_droid.db`. The first registration creates the admin account. To load the 6 sample protocols (Round Robin Check-In, Consent Decision-Making, etc.): - -```bash -# After registering as admin: -curl -c cookies.txt -X POST http://localhost:8000/api/auth/login \ - -H 'Content-Type: application/json' \ - -d '{"username":"your-username","password":"your-password"}' - -curl -b cookies.txt -X POST http://localhost:8000/api/seed -``` - -To remove all seed protocols (or any protocols), delete them individually: - -```bash -curl -b cookies.txt -X DELETE http://localhost:8000/api/protocols/round-robin-check-in -``` +The SQLite database is created automatically at `data/protocol_droid.db`. The first registration creates the admin account. To load the 6 sample protocols, go to the **About** page and click **"Load seed protocols"** (admins only). ## API @@ -173,6 +158,17 @@ practice: >- Use a talking object. Keep time gently. For large groups, break into smaller rounds. ``` +## Customizing the About page + +The About page is plain HTML in `frontend/index.html`, inside the `
` element. To customize it for your deployment: + +1. Edit `frontend/index.html` +2. Find the `` section +3. Modify the text, links, and sections as needed +4. Upload the file to your server + +No rebuild step is needed — the HTML is served directly. The "Seed protocols" section at the bottom is automatically shown only to admin users, so you can leave it or remove it. + ## License Hippocratic License (HL3-CORE) — do no harm. See https://firstdonoharm.dev/ diff --git a/api/yaml.php b/api/yaml.php index 235d3cf..1dc6c17 100644 --- a/api/yaml.php +++ b/api/yaml.php @@ -121,60 +121,77 @@ function parse_protocol_yaml(string $yaml_text): ?array { /** * Minimal YAML parser for simple protocol files. - * Handles: top-level key: value, nested lists with - headline:, folded scalars + * Handles: top-level key: value, nested lists with - headline:, folded scalars (>-) * This is NOT a full YAML parser — it handles the protocol format only. */ function simple_yaml_parse(string $text): ?array { $result = []; $lines = explode("\n", $text); - $current_key = null; - $in_steps = false; + $current_step_idx = -1; + + // State for folded scalar tracking $in_folded = false; + $folded_target = null; // 'top' for top-level key, 'step' for step description + $folded_top_key = null; $folded_buffer = ''; - $folded_key = null; $folded_indent = 0; - $flush_folded = function() use (&$result, &$folded_buffer, &$folded_key, &$in_folded) { - if ($in_folded && $folded_key !== null) { - $result[$folded_key] = trim($folded_buffer); - $folded_buffer = ''; + $flush_folded = function() use (&$result, &$in_folded, &$folded_target, &$folded_top_key, &$folded_buffer, &$current_step_idx) { + if ($in_folded) { + $val = trim($folded_buffer); + if ($folded_target === 'top' && $folded_top_key !== null) { + $result[$folded_top_key] = $val; + } elseif ($folded_target === 'step' && $current_step_idx >= 0) { + $result['steps'][$current_step_idx]['description'] = $val; + } $in_folded = false; - $folded_key = null; + $folded_target = null; + $folded_top_key = null; + $folded_buffer = ''; } }; foreach ($lines as $line) { - // Skip comments and empty lines - if (preg_match('/^\s*#/', $line) || trim($line) === '') continue; + // Skip comments + if (preg_match('/^\s*#/', $line)) continue; - // Folded scalar continuation + // Folded scalar continuation — indented lines that are more indented than the key if ($in_folded) { - if (preg_match('/^(\s{' . ($folded_indent + 2) . ',})/', $line, $m)) { - $folded_buffer .= ' ' . trim($line); + $trimmed = trim($line); + if ($trimmed === '') { continue; } // blank lines in folded scalars become spaces + // Check if this line is indented more than the key (continuation of folded scalar) + $line_indent = strlen($line) - strlen(ltrim($line)); + if ($line_indent > $folded_indent) { + $folded_buffer .= ' ' . $trimmed; continue; } else { $flush_folded(); + // fall through to process this line normally } } - // Steps list items - if (preg_match('/^(\s+) - headline:\s*(.*)$/', $line, $m)) { - $in_steps = true; + // Skip empty lines (not inside folded) + if (trim($line) === '') continue; + + // Step headline: " - headline: ..." + if (preg_match('/^(\s+)-\s+headline:\s*(.*)$/', $line, $m)) { + $flush_folded(); + $current_step_idx++; $result['steps'][] = ['headline' => trim($m[2], '"'), 'description' => '']; - $current_key = 'description'; continue; } - if (preg_match('/^(\s+) description:\s*(.*)$/', $line, $m)) { + + // Step description: " description: ..." + if (preg_match('/^(\s+)description:\s*(.*)$/', $line, $m) && $current_step_idx >= 0) { $val = trim($m[2], '"'); - if (str_starts_with($val, '>-') || str_starts_with($val, '|-')) { + if ($val === '>-' || $val === '>+' || $val === '|' || $val === '|-' || $val === '|+') { + // Start folded scalar for step description $in_folded = true; - $folded_key = null; // Will set on last step + $folded_target = 'step'; $folded_buffer = ''; $folded_indent = strlen($m[1]); } else { - if (!empty($result['steps'])) { - $result['steps'][count($result['steps']) - 1]['description'] = $val; - } + $result['steps'][$current_step_idx]['description'] = $val; } continue; } @@ -184,17 +201,36 @@ function simple_yaml_parse(string $text): ?array { $flush_folded(); $key = $m[1]; $val = trim($m[2]); - if ($val === '' ) { - $current_key = $key; - if ($key === 'steps') { $result['steps'] = []; $in_steps = true; } + + if ($val === '') { + // Could be "steps:" (empty value, starts a list) or just empty + if ($key === 'steps') { $result['steps'] = []; $current_step_idx = -1; } continue; } + + // Check for folded scalar indicators + if ($val === '>-' || $val === '>+' || $val === '|' || $val === '|-' || $val === '|+') { + $in_folded = true; + $folded_target = 'top'; + $folded_top_key = $key; + $folded_buffer = ''; + $folded_indent = 0; // top-level keys have indent 0 + continue; + } + // Parse inline array [a, b, c] if (str_starts_with($val, '[') && str_ends_with($val, ']')) { $inner = substr($val, 1, -1); $result[$key] = array_map(fn($s) => trim($s, ' "'), array_filter(explode(',', $inner), fn($s) => trim($s) !== '')); continue; } + + // null literal + if ($val === 'null' || $val === '~') { + $result[$key] = null; + continue; + } + $result[$key] = trim($val, '"'); continue; } diff --git a/frontend/app.js b/frontend/app.js index 5b83855..490729e 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -63,19 +63,21 @@ async function init() { } function route() { - const hash = location.hash.slice(1) || 'library'; - if (hash.startsWith('protocols/')) { + const hash = location.hash.slice(1) || 'protocols'; + if (hash.startsWith('protocols/') && hash !== 'protocols') { showDetail(hash.split('/')[1]); + } else if (hash === 'protocols') { + navigate('library'); } else if (hash.startsWith('users/') || hash.startsWith('user/')) { showUser(hash.split('/')[1]); - } else if (hash.startsWith('collections/')) { + } else if (hash.startsWith('collections/') && hash !== 'collections') { showCollectionDetail(hash.split('/')[1]); + } else if (hash === 'collections') { + navigate('collections'); } else if (hash === 'create') { navigate('create'); } else if (hash === 'create-collection') { navigate('create-collection'); - } else if (hash === 'collections') { - navigate('collections'); } else if (hash === 'about') { navigate('about'); } else { @@ -87,8 +89,8 @@ let searchTimer = null; let currentRoute = ''; function navigate(view) { - // Prevent double navigation from hashchange - if (currentRoute === view) return; + // Prevent double navigation from hashchange (but allow explicit resets) + if (currentRoute === view && view !== 'library') return; currentRoute = view; document.querySelectorAll('.view').forEach(v => v.style.display = 'none'); document.getElementById('view-' + view).style.display = 'block'; @@ -96,18 +98,18 @@ function navigate(view) { document.querySelectorAll('.mobile-panel a').forEach(a => a.classList.toggle('active', a.dataset.view === view)); closeMenu(); updateBreadcrumb(view); - if (view === 'library') { location.hash = 'library'; loadProtocols(); } + if (view === 'library') { location.hash = 'protocols'; loadProtocols(); } if (view === 'collections') { location.hash = 'collections'; loadCollections(); } if (view === 'create') { editingSlug = null; resetForm(); } if (view === 'create-collection') { editingCollectionSlug = null; resetCollectionForm(); } - if (view === 'about') { location.hash = 'about'; updateAboutPage(); } + if (view === 'about') { location.hash = 'about'; } window.scrollTo(0, 0); } // --- Mobile menu --- function updateBreadcrumb(view) { const path = document.getElementById('crumbPath'); - const map = { library: '', create: 'create', collections: 'collections/', 'create-collection': 'collections/create', about: 'about' }; + const map = { library: 'protocols/', create: 'protocols/create', collections: 'collections/', 'create-collection': 'collections/create', about: 'about' }; path.textContent = map[view] || ''; } @@ -337,7 +339,7 @@ function renderDetail(p) { return '
' + String(i+1).padStart(2,'0') + '
' + '

' + escapeHtml(s.headline) + '

' + escapeHtml(s.description || '') + '

'; }).join(''); - const forkNotice = p.forked_from ? '
forked from: ' + p.forked_from + '
' : ''; + const forkNotice = (p.forked_from && p.forked_from !== 'null') ? '
forked from: ' + p.forked_from + '
' : ''; const sourceLine = p.source ? 'SOURCE: ' + escapeHtml(p.source) : ''; var sourceLink = p.source_url ? ' · view_original' : ''; var authorLine = p.author ? ' · authored by @' + escapeHtml(p.author) + '' : ''; @@ -410,6 +412,7 @@ async function deleteProtocol(slug) { try { await api('/protocols/' + slug, { method: 'DELETE' }); toast('Protocol deleted'); + currentRoute = ''; // Reset so navigate works navigate('library'); } catch (e) { toast(e.message, true); } } @@ -780,6 +783,7 @@ async function deleteCollection(slug) { try { await api('/collections/' + slug, { method: 'DELETE' }); toast('Collection deleted'); + currentRoute = ''; navigate('collections'); } catch (e) { toast(e.message, true); } } @@ -821,26 +825,6 @@ async function showUser(username) { } } -// --- About page --- -function updateAboutPage() { - var section = document.getElementById('seedSection'); - if (!section) return; - section.style.display = (currentUser && currentUser.role === 'admin') ? 'block' : 'none'; -} - -async function loadSeedProtocols() { - var btn = document.getElementById('seedBtn'); - if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; } - try { - var result = await api('/seed', { method: 'POST' }); - toast('Loaded ' + result.imported + ' seed protocols'); - if (btn) { btn.textContent = 'Loaded ' + result.imported + ' protocols'; btn.disabled = true; } - } catch (e) { - toast(e.message, true); - if (btn) { btn.disabled = false; btn.textContent = 'Load seed protocols'; } - } -} - // --- Toast --- let toastTimer = null; function toast(msg, isError) { diff --git a/frontend/index.html b/frontend/index.html old mode 100755 new mode 100644 index 20d750a..f5875d6 --- a/frontend/index.html +++ b/frontend/index.html @@ -15,7 +15,7 @@ @@ -24,7 +24,7 @@
- PROTOCOLS + PROTOCOLS COLLECTIONS ABOUT
@@ -87,13 +87,6 @@

License

Hippocratic License (HL3-CORE) — do no harm. See firstdonoharm.dev. Source code available on Gitea.

-