Files
protocol-droid/api/yaml.php
T
Protocolbot 3386ac6542 feat: switch from MySQL to SQLite for zero-dependency deployment
- Rewrite db.php to use SQLite via PDO (no external DB server needed)
- Rewrite schema.sql for SQLite (AUTOINCREMENT, CHECK constraints,
  triggers for updated_at, ON CONFLICT instead of ON DUPLICATE KEY)
- Fix config.php upsert to use ON CONFLICT syntax
- Fix index.php: tag filter via json_each, vote upsert via ON CONFLICT,
  and fix protocol create route (was returning 405 for POST /api/protocols)
- Fix yaml.php seed import: replace invalid false callbacks with fn() => null,
  fix yaml_parse pos argument (-1 -> 0)
- Update Dockerfile: drop pdo_mysql/mysqli (pdo_sqlite is built in)
- Update CloudronManifest.json: remove mysql addon, bump to v0.3.0
- Update README for SQLite deployment
2026-07-06 14:42:45 -06:00

205 lines
7.2 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_key = null;
$in_steps = false;
$in_folded = false;
$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 = '';
$in_folded = false;
$folded_key = null;
}
};
foreach ($lines as $line) {
// Skip comments and empty lines
if (preg_match('/^\s*#/', $line) || trim($line) === '') continue;
// Folded scalar continuation
if ($in_folded) {
if (preg_match('/^(\s{' . ($folded_indent + 2) . ',})/', $line, $m)) {
$folded_buffer .= ' ' . trim($line);
continue;
} else {
$flush_folded();
}
}
// Steps list items
if (preg_match('/^(\s+) - headline:\s*(.*)$/', $line, $m)) {
$in_steps = true;
$result['steps'][] = ['headline' => trim($m[2], '"'), 'description' => ''];
$current_key = 'description';
continue;
}
if (preg_match('/^(\s+) description:\s*(.*)$/', $line, $m)) {
$val = trim($m[2], '"');
if (str_starts_with($val, '>-') || str_starts_with($val, '|-')) {
$in_folded = true;
$folded_key = null; // Will set on last step
$folded_buffer = '';
$folded_indent = strlen($m[1]);
} else {
if (!empty($result['steps'])) {
$result['steps'][count($result['steps']) - 1]['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 === '' ) {
$current_key = $key;
if ($key === 'steps') { $result['steps'] = []; $in_steps = true; }
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;
}
$result[$key] = trim($val, '"');
continue;
}
}
$flush_folded();
return $result;
}