Files
Protocolbot 4c05c1067e fix: YAML folded scalar parser, delete redirect, #protocols routing, forked_from null
- Fix YAML fallback parser to properly handle folded scalars (>-) at
  both top-level (description, outcome, practice) and step-level
  (step descriptions). Previously stored '>-' as the literal value
  instead of the folded text.

- Fix delete protocol/collection: reset currentRoute guard so
  navigate('library') actually works after deletion.

- Change #library hash to #protocols throughout (routing, nav links,
  breadcrumb shows 'protocols/' on the main page).

- Fix breadcrumb home click not working from detail pages (allow
  re-navigation to library even if currentRoute matches).

- Fix forked_from: null showing as a clickable link — now suppressed
  when value is null or the string 'null'.

- README: replace curl seed instructions with About page button,
  add About page customization instructions for whitelabeling.
2026-07-06 20:42:44 -06:00

241 lines
8.8 KiB
PHP

<?php
/**
* YAML import/export helpers.
* Uses the yaml extension if available, falls back to a minimal parser.
*/
function protocol_to_yaml(array $p): string {
$lines = [];
$lines[] = "id: " . ($p['slug'] ?? '');
$lines[] = "title: " . yaml_quote($p['title'] ?? '');
$lines[] = "description: " . yaml_multiline($p['description'] ?? '');
$lines[] = "source: " . yaml_quote($p['source'] ?? '');
if (!empty($p['source_url'])) $lines[] = "source_url: " . yaml_quote($p['source_url']);
$lines[] = "tags: " . yaml_array(json_decode($p['tags'] ?? '[]', true) ?: []);
if (!empty($p['forked_from'])) $lines[] = "forked_from: " . yaml_quote($p['forked_from']);
if (!empty($p['image'])) $lines[] = "image: " . yaml_quote($p['image']);
$lines[] = "steps:";
$steps = json_decode($p['steps'] ?? '[]', true) ?: [];
foreach ($steps as $step) {
$lines[] = " - headline: " . yaml_quote($step['headline'] ?? '');
$lines[] = " description: " . yaml_multiline($step['description'] ?? '', 6);
}
if (!empty($p['outcome'])) $lines[] = "outcome: " . yaml_multiline($p['outcome']);
if (!empty($p['practice'])) $lines[] = "practice: " . yaml_multiline($p['practice']);
return implode("\n", $lines) . "\n";
}
function collection_to_yaml(array $c, array $items): string {
$lines = [];
$lines[] = "id: " . ($c['slug'] ?? '');
$lines[] = "title: " . yaml_quote($c['title'] ?? '');
$lines[] = "description: " . yaml_multiline($c['description'] ?? '');
$lines[] = "source: " . yaml_quote($c['source'] ?? '');
if (!empty($c['image'])) $lines[] = "image: " . yaml_quote($c['image']);
$lines[] = "protocols:";
foreach ($items as $item) {
if ($item['item_type'] === 'protocol') {
$lines[] = " - " . $item['item_slug'];
}
}
$nested = array_filter($items, fn($i) => $i['item_type'] === 'collection');
if ($nested) {
$lines[] = "collections:";
foreach ($nested as $item) {
$lines[] = " - " . $item['item_slug'];
}
}
return implode("\n", $lines) . "\n";
}
function yaml_quote(string $s): string {
if ($s === '') return '""';
if (preg_match('/[:#{}&*!|>"\'\[\]]/', $s) || preg_match('/^\s/', $s)) {
$s = str_replace('"', '\\"', $s);
return '"' . $s . '"';
}
return $s;
}
function yaml_multiline(string $s, int $indent = 0): string {
if ($s === '') return '""';
$pad = str_repeat(' ', $indent);
// Use folded scalar for readability
$lines = explode("\n", trim($s));
if (count($lines) <= 1 && strlen($s) < 60) {
return yaml_quote($s);
}
$out = ">-";
foreach ($lines as $line) {
$out .= "\n" . $pad . " " . $line;
}
return $out;
}
function yaml_array(array $arr): string {
if (empty($arr)) return '[]';
return '[' . implode(', ', array_map('yaml_quote', $arr)) . ']';
}
/**
* Parse a YAML protocol file into a database-ready array.
* Uses yaml_parse if available, otherwise a simple parser for the protocol format.
*/
function parse_protocol_yaml(string $yaml_text): ?array {
if (function_exists('yaml_parse')) {
// Harden against PHP object injection via !php/object and !php/const tags.
// The callbacks must be valid callables; null them out to neutralize deserialization.
$neutralize = fn() => null;
$data = yaml_parse($yaml_text, 0, $ndocs, [
'!php/object' => $neutralize,
'!php/const' => $neutralize,
]);
if (!is_array($data)) return null;
} else {
$data = simple_yaml_parse($yaml_text);
if (!is_array($data)) return null;
}
$steps = [];
foreach (($data['steps'] ?? []) as $step) {
$steps[] = [
'headline' => $step['headline'] ?? '',
'description' => $step['description'] ?? '',
];
}
return [
'slug' => $data['id'] ?? slugify($data['title'] ?? 'untitled'),
'title' => $data['title'] ?? '',
'description' => $data['description'] ?? '',
'source' => $data['source'] ?? '',
'source_url' => $data['source_url'] ?? '',
'tags' => json_encode($data['tags'] ?? []),
'forked_from' => $data['forked_from'] ?? null,
'image' => $data['image'] ?? null,
'steps' => json_encode($steps),
'outcome' => $data['outcome'] ?? '',
'practice' => $data['practice'] ?? '',
];
}
/**
* Minimal YAML parser for simple protocol files.
* 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_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_indent = 0;
$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_target = null;
$folded_top_key = null;
$folded_buffer = '';
}
};
foreach ($lines as $line) {
// Skip comments
if (preg_match('/^\s*#/', $line)) continue;
// Folded scalar continuation — indented lines that are more indented than the key
if ($in_folded) {
$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
}
}
// 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' => ''];
continue;
}
// Step description: " description: ..."
if (preg_match('/^(\s+)description:\s*(.*)$/', $line, $m) && $current_step_idx >= 0) {
$val = trim($m[2], '"');
if ($val === '>-' || $val === '>+' || $val === '|' || $val === '|-' || $val === '|+') {
// Start folded scalar for step description
$in_folded = true;
$folded_target = 'step';
$folded_buffer = '';
$folded_indent = strlen($m[1]);
} else {
$result['steps'][$current_step_idx]['description'] = $val;
}
continue;
}
// Top-level key: value
if (preg_match('/^([a-z_]+):\s*(.*)$/', $line, $m)) {
$flush_folded();
$key = $m[1];
$val = trim($m[2]);
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;
}
}
$flush_folded();
return $result;
}