{
    "Core": {
        "functions": {
            "active": {
                "args": "bool $cond, string $classList = void",
                "return": "string",
                "body": "$cond || $classList ? ' class=\"'.$classList.($cond ? ($classList ? space : void).'active' : void).'\"' : void",
                "meta": {
                    "version": "1.0",
                    "summary": "Build active class attribute for UI state",
                    "advice": "Writes the whole class attribute, or nothing at all when there is nothing to say, so a link needs no ternary in the view. Because it emits the attribute rather than a class name, it goes into the tag itself and not inside another class attribute.",
                    "package": "view",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "active class ui view html"
                },
                "file": "/srv/control/phlo/resources/active.phlo",
                "line": 9,
                "source": "function"
            },
            "age": {
                "args": "int $time",
                "return": "int",
                "body": "time() - $time",
                "meta": {
                    "version": "1.0",
                    "summary": "Get age in seconds since a given timestamp",
                    "advice": "Seconds since a timestamp, so an expiry or a cache check reads as a comparison. Nothing more than that: use time_human for something a reader sees.",
                    "package": "time",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "age time timestamp"
                },
                "file": "/srv/control/phlo/resources/age.phlo",
                "line": 9,
                "source": "function"
            },
            "age_human": {
                "args": "int $age",
                "return": "string",
                "body": "time_human(time() - $age)",
                "meta": {
                    "version": "1.0",
                    "summary": "Convert age in seconds to human readable text",
                    "advice": "The reader-facing side of age(): give it an age in seconds and it gives a label like 3 hours. Pass an age here and a timestamp to time_human; the two are easy to swap and both give an answer, only the wrong one.",
                    "package": "time",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "time_human",
                    "tags": "age human time format"
                },
                "file": "/srv/control/phlo/resources/age.human.phlo",
                "line": 10,
                "source": "function"
            },
            "apcu": {
                "args": "$key, $cb, int $duration = 3600, bool $log = true",
                "body": "first($value = apcu_entry($key, $cb, $duration), $log && debug('C: '.(strlen($key) > 58 ? substr($key, 0, 55).'...' : $key).(is_array($value) ? ' ('.count($value).')' : (is_numeric($value) ? \":$value\" : (is_string($value) ? ':string:'.strlen($value) : colon.gettype($value))))))",
                "comments": "apcu_entry holds a lock on the key for the length of the callback.\nEverything else asking for this key waits with it. That is what stops ten parallel\nrequests from computing the same value ten times, and it is also why a callback that\nmakes a slow network call belongs outside this function.",
                "meta": {
                    "version": "1.0",
                    "summary": "Cache callback results in APCu",
                    "advice": "Wraps a callback in APCu with one line, and the callback only runs when the value is not there. The cache lives in one server's shared memory and is gone after a restart, and a CLI run has its own unless apc.enable_cli is on, which makes it right for something expensive and reproducible and wrong for something authoritative. Key it on everything the answer depends on, or two different questions get the same answer.",
                    "package": "cache",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "php-ext:apcu",
                    "tags": "cache apcu performance"
                },
                "file": "/srv/control/phlo/resources/apcu.phlo",
                "line": 14,
                "source": "function"
            },
            "await": {
                "args": "...$jobs",
                "return": "array",
                "body": "\tif (daemon) return daemon::await($jobs)\n\t$children = []\n\t$open = []\n\tforeach ($jobs AS $i => $job){\n\t\t[$cb, $args] = is_array($job) ? [$job[0], array_slice($job, 1)] : [$job, []]\n\t\t$cmd = cli.space.escapeshellarg($_SERVER['SCRIPT_FILENAME']).space.escapeshellarg($cb).loop($args, fn($a) => space.escapeshellarg((string)$a), void)\n\t\t$desc = [0 => ['pipe','r'], 1 => ['pipe','w'], 2 => ['pipe','w']]\n\t\t$proc = proc_open($cmd, $desc, $pipes)\n\t\tfclose($pipes[0])\n\t\tstream_set_blocking($pipes[1], false)\n\t\tstream_set_blocking($pipes[2], false)\n\t\t$children[$i] = obj(proc: $proc, out: $pipes[1], err: $pipes[2], stdout: void, stderr: void)\n\t\t$open['o'.$i] = $pipes[1]\n\t\t$open['e'.$i] = $pipes[2]\n\t}\n\t// Drain every child's stdout AND stderr together: reading one stream to EOF before the\n\t// other deadlocks a child that fills the unread pipe. Bound the whole wait so a hung\n\t// child cannot block the caller forever.\n\t$deadline = time() + (defined('await_timeout') ? await_timeout : 300)\n\twhile ($open){\n\t\t$read = $open\n\t\t$write = $except = []\n\t\tif (@stream_select($read, $write, $except, 1) === false) break\n\t\tforeach ($read AS $key => $stream){\n\t\t\t$chunk = fread($stream, 65536)\n\t\t\tif ($chunk === void || $chunk === false){\n\t\t\t\tfeof($stream) && $open = array_diff_key($open, [$key => 1])\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t$i = substr($key, 1)\n\t\t\tif ($key[0] === 'o') $children[$i]->stdout .= $chunk\n\t\t\telse $children[$i]->stderr .= $chunk\n\t\t}\n\t\tif (time() >= $deadline) break\n\t}\n\t// Terminate any child still running (deadline or a stream_select error). SIGKILL cannot\n\t// be ignored, so proc_close below will not block on a process that drops SIGTERM.\n\tforeach ($children AS $child) (proc_get_status($child->proc)['running'] ?? false) && proc_terminate($child->proc, 9)\n\t$results = []\n\tforeach ($children AS $i => $child){\n\t\tfclose($child->out)\n\t\tfclose($child->err)\n\t\t$code = proc_close($child->proc)\n\t\t$err = trim($child->stderr)\n\t\tif ($err !== void){\n\t\t\t$ej = json_decode($err, true)\n\t\t\t$results[$i] = json_last_error() === JSON_ERROR_NONE ? $ej : $err\n\t\t\tcontinue\n\t\t}\n\t\tif ($code !== 0){\n\t\t\t$results[$i] = obj(error: 'CLI process failed', code: $code)\n\t\t\tcontinue\n\t\t}\n\t\t$json = json_decode($child->stdout, true)\n\t\t$results[$i] = json_last_error() === JSON_ERROR_NONE ? $json : $child->stdout\n\t}\n\treturn $results",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Run app targets in parallel, via the daemon pool or one-shot CLI processes",
                    "advice": "Runs several app targets at once and returns their results in the order you asked, so three slow calls take as long as the slowest instead of their sum. Under the daemon they run in the pool; without it each becomes a CLI process. The whole wait is bounded, five minutes unless await_timeout says otherwise, and a child still running is killed rather than waited for, so one hung job cannot hold a request.",
                    "package": "runtime",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "cli obj",
                    "tags": "await parallel cli process daemon"
                },
                "file": "/srv/control/phlo/resources/await.phlo",
                "line": 11,
                "source": "function"
            },
            "button": {
                "args": "...$args",
                "return": "string",
                "body": "tag('button', ...$args)",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "DOM form tags for button, input, select and textarea",
                    "advice": "button(), input(), select() and textarea() are tag() with the name filled in, so they take the same named arguments and the same escaping. They exist because these four appear so often that the name is noise.",
                    "package": "view",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "tag",
                    "tags": "form tags html view"
                },
                "file": "/srv/control/phlo/resources/tags.form.phlo",
                "line": 11,
                "source": "function"
            },
            "camel": {
                "args": "string $text",
                "return": "string",
                "body": "lcfirst(str_replace(space, void, ucwords(lcfirst($text))))",
                "meta": {
                    "version": "1.0",
                    "summary": "Convert text to camelCase",
                    "advice": "Turns a sentence into camelCase, for making a property or key name out of a title. It splits on spaces, so a hyphen or an underscore survives into the result.",
                    "package": "string",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "camelcase string format"
                },
                "file": "/srv/control/phlo/resources/camel.phlo",
                "line": 9,
                "source": "function"
            },
            "chunk": {
                "args": "...$cmds",
                "return": "void",
                "body": "\t$res = %res\n\t$cli = %req->cli\n\t!$res->streaming && $res->done && error('Output already started, invalid chunk()')\n\tif (debug){\n\t\t$res->dump && [$cmds['dump'] = $res->dump, $res->dump = []]\n\t\t$res->debug && [$cmds['debug'] = $res->debug, $res->debug = []]\n\t}\n\tif (!$res->streaming){\n\t\t$res->streaming = true\n\t\t$res->type = 'application/x-ndjson'\n\t\t$res->header('Cache-Control', 'no-store')\n\t\t$res->header('X-Content-Type-Options', 'nosniff')\n\t\t$res->render()\n\t}\n\tprint(json_encode($cmds, jsonFlat).lf)\n\t$cli || [@ob_flush(), flush()]",
                "meta": {
                    "version": "1.0",
                    "summary": "Stream JSON chunks over CLI or Server-Sent Events",
                    "advice": "Sends a piece of a response now instead of at the end, so long work reports as it goes. The first call switches the response to a stream and fixes the content type, so anything printed after it is part of that stream and a normal return can no longer be made. In the browser these arrive as commands; on the CLI they are plain lines.",
                    "package": "runtime",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "debug",
                    "tags": "chunk stream sse cli async"
                },
                "file": "/srv/control/phlo/resources/chunk.phlo",
                "line": 10,
                "source": "function"
            },
            "create": {
                "args": "iterable $items, \\Closure $keyCb, ?\\Closure $valueCb = null",
                "return": "array",
                "body": "array_combine(loop($items, $keyCb), $valueCb ? loop($items, $valueCb) : $items)",
                "meta": {
                    "version": "1.0",
                    "summary": "Create associative array from iterable using callbacks",
                    "advice": "Turns a list into a keyed array in one pass: the first callback makes the key, the optional second makes the value. Keys that repeat overwrite each other, so this doubles as a way to fold a list into its unique entries.",
                    "package": "array",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "create array iterable callback"
                },
                "file": "/srv/control/phlo/resources/create.phlo",
                "line": 9,
                "source": "function"
            },
            "en": {
                "args": "$text, ...$args",
                "return": "string",
                "body": "%lang->translation('en', $text, ...$args)",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Language and translation resource",
                    "package": "i18n",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@cookies @AI @INI phlo.async",
                    "advice": "%lang prints the current app language through its view, so it drops straight into a link or an attribute. Beyond that it is the translation layer behind nl() and en(): a phrase is hashed, looked up in langs/<lang>.ini, and translated by the AI in the background when it is missing, so the first visitor reads the source text and the next one reads the translation. Editing a source phrase changes its hash and orphans the old translation, so a rewrite costs a re-translation.",
                    "tags": "lang translation i18n locale ai"
                },
                "file": "/srv/control/phlo/resources/lang.phlo",
                "line": 12,
                "source": "function"
            },
            "exec_stream": {
                "args": "string $cmd, ?int $timeoutSec = 0",
                "return": "Generator",
                "body": "\t$desc = [0 => ['pipe','r'], 1 => ['pipe','w'], 2 => ['pipe','w']]\n\t$proc = proc_open($cmd, $desc, $pipes)\n\tif (!is_resource($proc)) return\n\tstream_set_blocking($pipes[1], false)\n\tstream_set_blocking($pipes[2], false)\n\t$bufOut = void\n\t$bufErr = void\n\twhile (true){\n\t\t$status = proc_get_status($proc)\n\t\t$running = $status['running']\n\t\t$read = []\n\t\t$w = null\n\t\t$e = null\n\t\tif (!feof($pipes[1])) $read[] = $pipes[1]\n\t\tif (!feof($pipes[2])) $read[] = $pipes[2]\n\t\tif ($read) @stream_select($read, $w, $e, 0, 200000)\n\t\tforeach ($read AS $r){\n\t\t\t$chunk = fread($r, 8192)\n\t\t\tif ($chunk === void || $chunk === false) continue\n\t\t\tif ($r === $pipes[1]){\n\t\t\t\t$bufOut .= $chunk\n\t\t\t\twhile (($pos = strpos($bufOut, lf)) !== false){\n\t\t\t\t\t$line = substr($bufOut, 0, $pos)\n\t\t\t\t\t$bufOut = substr($bufOut, $pos + 1)\n\t\t\t\t\tyield obj(data: $line)\n\t\t\t\t}\n\t\t\t}\n\t\t\telse {\n\t\t\t\t$bufErr .= $chunk\n\t\t\t\twhile (($pos = strpos($bufErr, lf)) !== false){\n\t\t\t\t\t$line = substr($bufErr, 0, $pos)\n\t\t\t\t\t$bufErr = substr($bufErr, $pos + 1)\n\t\t\t\t\tyield obj(data: $line, error: true)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (!$running) break\n\t\tif ($timeoutSec > 0 && ($status['running_time'] ?? 0) > $timeoutSec){\n\t\t\tproc_terminate($proc)\n\t\t\tyield obj(data: 'process timeout', error: true)\n\t\t\tbreak\n\t\t}\n\t}\n\tif ($bufOut !== void) yield obj(data: $bufOut)\n\tif ($bufErr !== void) yield obj(data: $bufErr, error: true)\n\tforeach ($pipes AS $p) @fclose($p)\n\tproc_close($proc)",
                "comments": "Reads stdout and stderr in one select loop rather than one after the other.\nA command that fills the pipe nobody is reading blocks forever, so draining one stream to\nthe end before touching the other deadlocks exactly the commands that say the most.",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Stream shell command output via yielding",
                    "advice": "Yields a shell command's output line by line, with error true on the lines that came from stderr, so a failure can be shown as it happens instead of after. It reads stdout and stderr together, which is what keeps a command that writes a lot to one of them from deadlocking. Pass a timeout for anything that could hang; zero waits forever.",
                    "package": "runtime",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "stream shell cli process yield"
                },
                "file": "/srv/control/phlo/resources/exec.stream.phlo",
                "line": 13,
                "source": "function"
            },
            "HTTP": {
                "args": "string $url, array $headers = [], bool $JSON = false, $POST = null, $PUT = null, $PATCH = null, $QUERY = null, bool $DELETE = false, string|bool|null $agent = null, string|bool $cookies = false, int $timeout = 15, &$response = null",
                "body": "\t$curl = curl_init($url)\n\tif ($POST !== null || $PUT !== null || $PATCH !== null || $QUERY !== null){\n\t\tif (!is_null($POST)) [$method = 'POST', $content = $POST]\n\t\telseif (!is_null($PUT)) [$method = 'PUT', $content = $PUT]\n\t\telseif (!is_null($PATCH)) [$method = 'PATCH', $content = $PATCH]\n\t\telseif (!is_null($QUERY)) [$method = 'QUERY', $content = $QUERY]\n\t\tcurl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method)\n\t\tif ($JSON) [!is_string($content) && $content = json_encode($content), array_push($headers, 'Content-Type: application/json', 'Content-Length: '.strlen($content))]\n\t\tcurl_setopt($curl, CURLOPT_POSTFIELDS, $content)\n\t}\n\telseif ($DELETE) curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'DELETE')\n\t$agent && curl_setopt($curl, CURLOPT_USERAGENT, $agent === true ? phlo('req')->userAgent : $agent)\n\tif ($cookies !== false) [$jar = $cookies === true ? data.'cookies.txt' : $cookies, curl_setopt($curl, CURLOPT_COOKIEFILE, $jar), curl_setopt($curl, CURLOPT_COOKIEJAR, $jar)]\n\t$resHeaders = []\n\tcurl_setopt_array($curl, [CURLOPT_HTTPHEADER => $headers, CURLOPT_FOLLOWLOCATION => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => $timeout, CURLOPT_ENCODING => void, CURLOPT_HEADERFUNCTION => function($ch, $line) use (&$resHeaders){\n\t\t$parts = explode(colon, $line, 2)\n\t\tcount($parts) === 2 && $resHeaders[strtolower(trim($parts[0]))] = trim($parts[1])\n\t\treturn strlen($line)\n\t}])\n\t$res = curl_exec($curl)\n\t$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE)\n\t$response = obj(ok: $res !== false && $status >= 200 && $status < 300, status: $status, headers: $resHeaders, error: $res === false ? curl_error($curl) : null)\n\tif ($res === false) error('HTTP error: '.curl_error($curl))\n\treturn $res",
                "comments": "Follows redirects on its own and raises only when the transport itself fails.\nA 404 or a 500 comes back as a body with no error at all. Pass $response by reference to\nsee the status, the headers and whether it counted as a success; that is the only way to\ntell an empty answer from a failed one. Connecting is capped at five seconds regardless\nof $timeout, which bounds the request as a whole once the connection stands.",
                "meta": {
                    "version": "1.0",
                    "summary": "HTTP request helper via cURL",
                    "advice": "One function for a whole request: a URL and headers is a GET, and POST, PUT, PATCH, QUERY or DELETE named as an argument decides the rest. Pass an array with JSON true and it is sent as JSON. The timeout is fifteen seconds and it is a real limit, so raise it deliberately for a slow API rather than by accident. Pass response by reference to see the status and the headers, which is the only way to tell an empty answer from a failure.",
                    "package": "network",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "php-ext:curl",
                    "tags": "http curl request api"
                },
                "file": "/srv/control/phlo/resources/HTTP.phlo",
                "line": 15,
                "source": "function"
            },
            "input": {
                "args": "...$args",
                "return": "string",
                "body": "tag('input', ...$args)",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "DOM form tags for button, input, select and textarea",
                    "advice": "button(), input(), select() and textarea() are tag() with the name filled in, so they take the same named arguments and the same escaping. They exist because these four appear so often that the name is noise.",
                    "package": "view",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "tag",
                    "tags": "form tags html view"
                },
                "file": "/srv/control/phlo/resources/tags.form.phlo",
                "line": 12,
                "source": "function"
            },
            "n8n": {
                "args": "$webhook, ?array $data = null, $test = false",
                "body": "HTTP(%creds->n8n->server.'webhook'.($test ? '-test' : '').'/'.$webhook, POST: $data)",
                "meta": {
                    "version": "1.0",
                    "summary": "Call n8n webhook endpoint",
                    "advice": "Fires an n8n workflow from your app, with test true to hit the test URL that only listens while the editor is open. It posts and returns what came back; n8n itself decides whether that is the result or just an acknowledgement, so a long workflow is better answered by a callback than by waiting here.",
                    "package": "network",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "HTTP creds:n8n",
                    "tags": "n8n webhook http automation"
                },
                "file": "/srv/control/phlo/resources/n8n.phlo",
                "line": 10,
                "source": "function"
            },
            "nl": {
                "args": "$text, ...$args",
                "return": "string",
                "body": "%lang->translation('nl', $text, ...$args)",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Language and translation resource",
                    "package": "i18n",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@cookies @AI @INI phlo.async",
                    "advice": "%lang prints the current app language through its view, so it drops straight into a link or an attribute. Beyond that it is the translation layer behind nl() and en(): a phrase is hashed, looked up in langs/<lang>.ini, and translated by the AI in the background when it is missing, so the first visitor reads the source text and the next one reads the translation. Editing a source phrase changes its hash and orphans the old translation, so a rewrite costs a re-translation.",
                    "tags": "lang translation i18n locale ai"
                },
                "file": "/srv/control/phlo/resources/lang.phlo",
                "line": 11,
                "source": "function"
            },
            "notify": {
                "args": "string $title, string $body = void, string $type = 'info', string $level = 'info', ?string $user = null",
                "return": "void",
                "body": "\t$cfg = %creds->notify ?? null\n\tif (!$cfg) return\n\t$url = $cfg->url ?? void\n\t$secret = $cfg->secret ?? void\n\tif ($url === void || $secret === void) return\n\ttry {\n\t\tHTTP($url, ['secret: '.$secret], true, [\n\t\t\t'app' => (string)($cfg->app ?? (defined('id') ? id : 'app')),\n\t\t\t'server' => $cfg->server ?? 'local',\n\t\t\t'host' => %req->host ?? void,\n\t\t\t'type' => $type,\n\t\t\t'level' => $level,\n\t\t\t'title' => $title,\n\t\t\t'body' => $body,\n\t\t\t'user' => $user,\n\t\t])\n\t}\n\tcatch (\\Throwable $e){}",
                "meta": {
                    "version": "1.0",
                    "summary": "Notification to the central hub: POST to [notify].url (secret header) via Phlo's HTTP() function. No-op without [notify] config.",
                    "advice": "One line to send a notification to the central hub, and quietly nothing at all when no hub is configured, so the same code runs on a laptop and on a server. That silence cuts both ways: a missing config gives no error, so check the hub itself when a message fails to arrive.",
                    "package": "fleet",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "HTTP"
                },
                "file": "/srv/control/phlo/resources/notify.phlo",
                "line": 9,
                "source": "function"
            },
            "phlo": {
                "args": "?string $phloName = null, ...$args",
                "return": "mixed",
                "body": "static $list = [];\nif ($phloName === 'tech/reset'){\n\tobj::$classProps = [];\n\treturn array_keys($list = array_filter($list, static fn($obj) => $obj->objPers));\n}\nif ($phloName === null) return array_keys($list);\n$class = strtr($phloName, [slash => us]);\n$handle = method_exists($class, '__handle') ? $class::__handle(...$args) : ($args ? null : $phloName);\nif ($handle === true){\n\tif (isset($list[$phloName])) return $list[$phloName]->objImport(...$args);\n\t$handle = $phloName;\n}\nelseif ($handle && isset($list[$handle])) return $list[$handle];\n$object = new $class(...$args);\nif ($handle) $list[$handle] = $object;\nif ($object->hasMethod('controller') && (!phlo('req')->cli || $phloName !== 'app')) $object->controller();\nreturn $object;",
                "file": "/srv/control/phlo/phlo.php",
                "line": 241,
                "source": "native"
            },
            "phlo_app": {
                "args": "...$args",
                "return": "void",
                "body": "if ($args['trace'] ??= false) require_once __DIR__.'/classes/trace.php';\nrequire_once __DIR__.'/functions'.($args['trace'] ? '.trace.php' : '.php');\nrequire_once __DIR__.'/classes/obj.php';\nrequire_once __DIR__.'/classes/req.php';\nrequire_once __DIR__.'/classes/res.php';\n$args['app']       ??  error('No \"app\" path defined');\n$args['debug']     ??= false;\n$args['build']     ??= false;\n$args['host']      ??= null;\n$args['control']   ??= ($args['build'] && $args['debug']) ? 'phlo' : false;\n$args['auth']      ??= false;\n$args['data']      ??= $args['app'].'data/';\n$args['php']       ??= $args['app'].'php/';\n$args['www']       ??= $args['app'].'www/';\n$args['cli']       ??= ZEND_THREAD_SAFE ? 'php-zts' : 'php';\n$args['thread']    ??= false;\n$args['daemon']    ??= false;\n$args['build'] && $args['thread'] && error('Phlo build and thread mode cannot be combined');\n$args['build'] && !is_file($args['data'].'app.json') && error('Phlo build mode requires data/app.json');\n$args['auth'] && !$args['build'] && error('Auth requires build mode');\nforeach ($args as $key => $value) define($key, $value);\ndefine('engine', __DIR__.slash);\nif ($args['debug']) require_once __DIR__.'/debug.php';\nif ($args['build']) require_once __DIR__.'/classes/changed.php';\nif ($args['daemon']) require_once __DIR__.'/classes/daemon.php';\nif ($args['trace']) trace::boot($args['app']);\nset_error_handler(static function(int $level, string $msg, string $file = '', int $line = 0):bool {\n\tif (!(error_reporting() & $level)) return false;\n\tthrow new ErrorException($msg, 0, $level, $file, $line);\n});\nset_exception_handler('phlo_exception');\nspl_autoload_register(static function(string $class):void {\n\tstatic $map = null, $mtime = null;\n\t$file = php.'classmap.php';\n\tif ($map === null || $mtime !== (is_file($file) ? filemtime($file) : null)){\n\t\t$map   = is_file($file) ? require $file : [];\n\t\t$mtime = is_file($file) ? filemtime($file) : null;\n\t}\n\tif (isset($map[$class])){ require_once php.$map[$class]; return; }\n});\nif ($args['build']){\n\t$engineMap = ['build' => 'build', 'reflect' => 'reflect', 'build_file' => 'file', 'build_node' => 'node', 'build_builder' => 'builder', 'build_css' => 'css', 'build_icons' => 'icons'];\n\tspl_autoload_register(static function(string $class) use ($engineMap):void {\n\t\t$name = $engineMap[strtolower($class)] ?? null;\n\t\tif ($name !== null) require_once engine.'classes/'.$name.'.php';\n\t});\n}\ndefined('composer') && spl_autoload_register(static function(string $class):void {\n\tstatic $loaded = false;\n\tif ($loaded) return;\n\t$loaded = true;\n\trequire_once composer.'vendor/autoload.php';\n\tforeach (spl_autoload_functions() as $fn){\n\t\tif (is_array($fn) && ($fn[0] ?? null) instanceof \\Composer\\Autoload\\ClassLoader){\n\t\t\tspl_autoload_unregister($fn);\n\t\t\tspl_autoload_register($fn);\n\t\t\t$fn[0]->loadClass($class);\n\t\t\treturn;\n\t\t}\n\t}\n});\nif ($args['thread'] !== false && PHP_SAPI !== 'cli'){\n\tignore_user_abort(true);\n\t$handle = static function():void { phlo_thread(); };\n\tfor ($i = 1; !$args['thread'] || $i <= $args['thread']; ++$i){\n\t\t$keepRunning = frankenphp_handle_request($handle);\n\t\tphlo('tech/reset');\n\t\tif (session_status() === PHP_SESSION_ACTIVE) session_write_close();\n\t\tgc_collect_cycles();\n\t\tif (!$keepRunning) break;\n\t}\n\treturn;\n}\nphlo_thread();",
                "file": "/srv/control/phlo/phlo.php",
                "line": 39,
                "source": "native"
            },
            "phlo_async": {
                "args": "string $cb, ...$args",
                "return": "bool",
                "body": "\tif (daemon) return daemon::fire($cb, $args)\n\t$cmd = cli.space.escapeshellarg($_SERVER['SCRIPT_FILENAME']).space.escapeshellarg($cb).loop($args, fn($a) => space.escapeshellarg((string)$a), void).' > /dev/null 2>&1 & echo $!'\n\texec($cmd, $r)\n\treturn isset($r[0]) && ctype_digit($r[0]) && (int)$r[0] > 0",
                "meta": {
                    "version": "1.0",
                    "summary": "Run an app target in the background, via the daemon pool or a one-shot CLI process",
                    "advice": "Fire and forget: it starts an app target and returns at once, so nothing that follows can see the result or whether it went well. Use it for work a visitor should not wait on, and let the job write its own outcome somewhere you can read back. Use phlo_sync when you need the answer, await when you need several.",
                    "package": "runtime",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "cli",
                    "tags": "async cli process background app daemon"
                },
                "file": "/srv/control/phlo/resources/phlo.async.phlo",
                "line": 10,
                "source": "function"
            },
            "phlo_cli": {
                "args": "array $args",
                "return": "void",
                "body": "if (!$args) return;\n$target = array_shift($args);\n$result = phlo_dispatch($target, $args);\nif (isset($result)) print(json_encode($result, jsonFlat).lf);",
                "file": "/srv/control/phlo/phlo.php",
                "line": 191,
                "source": "native"
            },
            "phlo_dispatch": {
                "args": "string $target, array $args = []",
                "return": "mixed",
                "body": "if (str_contains($target, dot)){\n\t[$object, $method] = explode(dot, $target, 2);\n\t$handle = phlo($object);\n\treturn $args ? $handle->$method(...$args) : ($handle->hasMethod($method) ? $handle->$method() : $handle->$method);\n}\nif (str_contains($target, '::')){\n\t[$class, $method] = explode('::', $target, 2);\n\treturn $class::$method(...$args);\n}\nreturn $target(...$args);",
                "file": "/srv/control/phlo/phlo.php",
                "line": 178,
                "source": "native"
            },
            "phlo_exception": {
                "args": "Throwable $e",
                "return": "void",
                "body": "require_once engine.'error.php';\nphlo_error_handle($e);",
                "file": "/srv/control/phlo/phlo.php",
                "line": 34,
                "source": "native"
            },
            "phlo_exists": {
                "args": "string $obj",
                "return": "bool",
                "body": "is_file(php.strtr($obj, [us => dot]).'.php')",
                "meta": {
                    "version": "1.0",
                    "summary": "Check if compiled Phlo class exists",
                    "advice": "Asks whether a Phlo object was built, by looking for the generated PHP rather than by loading anything, so it costs nothing and triggers no autoload. Use it to make a resource optional: check first, then call. This is a build question, not a runtime one, so a resource added after the last build answers no until it is rebuilt.",
                    "package": "build",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "phlo exists class build runtime"
                },
                "file": "/srv/control/phlo/resources/phlo.exists.phlo",
                "line": 9,
                "source": "function"
            },
            "phlo_load": {
                "args": "bool $http",
                "return": "void",
                "body": "static $loaded = false, $loadedApp = null;\nif ($loaded && $loadedApp === app){\n\tif ($http && !phlo('res')->type) phlo('res')->type = 'text/html; charset=UTF-8';\n\treturn;\n}\nif (build && (!is_file(php.'functions.php') || !is_file(php.'app.php') || build_base::changed())){\n\tdebug('Builder started');\n\t$changed = build::run();\n\t$changed && debug('Built '.implode(', ', array_map('basename', $changed)).' ('.count($changed).')');\n}\nif (!is_file(php.'functions.php') || !is_file(php.'app.php')) error('Compiled runtime not available');\nif (!$loaded){\n\trequire_once php.'functions.php';\n\t$loaded = true;\n}\nif ($loadedApp !== app){\n\trequire_once php.'app.php';\n\t$loadedApp = app;\n}\nif ($http && !phlo('res')->type) phlo('res')->type = 'text/html; charset=UTF-8';",
                "file": "/srv/control/phlo/phlo.php",
                "line": 154,
                "source": "native"
            },
            "phlo_serve": {
                "return": "void",
                "body": "ini_set('display_errors', 'stderr');\nstream_set_blocking(STDIN, true);\nfwrite(STDOUT, json_encode(['t' => 'ready']).lf);\nwhile (($line = fgets(STDIN)) !== false){\n\t$line = trim($line);\n\tif ($line === void) continue;\n\t$msg    = json_decode($line, true) ?: [];\n\t$id     = $msg['id']     ?? null;\n\t$target = (string)($msg['target'] ?? void);\n\t$args   = (array)($msg['args'] ?? []);\n\t$stream = (bool)($msg['stream'] ?? false);\n\t$lineBuf = void;\n\t$emit = static function(string $chunk) use (&$lineBuf, $id):string {\n\t\t$lineBuf .= $chunk;\n\t\twhile (($pos = strpos($lineBuf, lf)) !== false){\n\t\t\t$out = substr($lineBuf, 0, $pos);\n\t\t\t$lineBuf = substr($lineBuf, $pos + 1);\n\t\t\tfwrite(STDOUT, json_encode(['id' => $id, 't' => 'line', 'data' => $out], jsonFlat).lf);\n\t\t}\n\t\treturn void;\n\t};\n\ttry {\n\t\tif ($target === void) error('No target');\n\t\tif ($stream){\n\t\t\tob_start($emit, 1);\n\t\t\t$result = phlo_dispatch($target, $args);\n\t\t\twhile (ob_get_level()) ob_end_flush();\n\t\t\tif ($lineBuf !== void) fwrite(STDOUT, json_encode(['id' => $id, 't' => 'line', 'data' => $lineBuf], jsonFlat).lf);\n\t\t}\n\t\telse $result = phlo_dispatch($target, $args);\n\t\tfwrite(STDOUT, json_encode(['id' => $id, 't' => 'done', 'result' => $result], jsonFlat).lf);\n\t}\n\tcatch (Throwable $e){\n\t\twhile (ob_get_level()) ob_end_clean();\n\t\tfwrite(STDOUT, json_encode(['id' => $id, 't' => 'error', 'message' => $e->getMessage()], jsonFlat).lf);\n\t}\n\tphlo('tech/reset');\n\tif (session_status() === PHP_SESSION_ACTIVE) session_write_close();\n\tgc_collect_cycles();\n}",
                "file": "/srv/control/phlo/phlo.php",
                "line": 198,
                "source": "native"
            },
            "phlo_stream": {
                "args": "string $cb, ...$args",
                "return": "Generator",
                "body": "\tif (daemon){\n\t\tyield from daemon::stream($cb, $args)\n\t\treturn\n\t}\n\tyield from exec_stream(cli.space.escapeshellarg($_SERVER['SCRIPT_FILENAME']).space.escapeshellarg($cb).loop($args, fn($a) => space.escapeshellarg((string)$a), void))",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Stream an app target's output line by line, via the daemon pool or a one-shot CLI process",
                    "advice": "For a job whose output you want while it runs rather than at the end: it yields line by line, so a foreach can pass every line straight to chunk() and a page fills up as the work happens. The generator only advances while you read it, so a caller that stops reading stops the job.",
                    "package": "runtime",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "exec_stream cli",
                    "tags": "stream phlo cli process yield daemon"
                },
                "file": "/srv/control/phlo/resources/phlo.stream.phlo",
                "line": 11,
                "source": "function"
            },
            "phlo_sync": {
                "args": "string $cb, ...$args",
                "body": "\tif (daemon) return daemon::run($cb, $args)\n\t$cmd = cli.space.escapeshellarg($_SERVER['SCRIPT_FILENAME']).space.escapeshellarg($cb).loop($args, fn($a) => space.escapeshellarg((string)$a), void)\n\texec($cmd.' 2>&1', $r, $code)\n\t$out = implode(lf, $r)\n\tif ($code !== 0) error('Could not execute \"'.esc($cb).'\" via CLI')\n\t$j = json_decode($out, true)\n\tif (json_last_error() !== JSON_ERROR_NONE) return $out\n\tif (is_array($j) && isset($j['error'])) error($j['error'])\n\treturn $j",
                "meta": {
                    "version": "1.0",
                    "summary": "Run an app target synchronously, via the daemon pool or a one-shot CLI process",
                    "advice": "Runs an app target and gives you its return value, in the pool under the daemon and as a CLI process without one. JSON output is decoded, anything else comes back as text, and a job that answers with an error field raises it here, so a failure surfaces in the caller rather than being swallowed.",
                    "package": "runtime",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "cli",
                    "tags": "sync cli process app daemon"
                },
                "file": "/srv/control/phlo/resources/phlo.sync.phlo",
                "line": 10,
                "source": "function"
            },
            "phlo_thread": {
                "return": "void",
                "body": "try {\n\t$req = phlo('req');\n\tif ($req->cli){\n\t\t$target = $req->args[0] ?? void;\n\t\tif (str_starts_with($target, 'build::') || str_starts_with($target, 'reflect::')){\n\t\t\tphlo_cli($req->args);\n\t\t\treturn;\n\t\t}\n\t\tphlo_load(false);\n\t\tphlo('app');\n\t\tphlo_cli($req->args);\n\t\treturn;\n\t}\n\t$isControl = build && debug && control && str_starts_with($req->path.slash, control.slash);\n\tif (auth && !$isControl){\n\t\tphlo_auth('site', 'Phlo App - '.host);\n\t\tif (phlo('res')->done) return;\n\t}\n\tif ($isControl){\n\t\trequire_once engine.'control.php';\n\t\tphlo_control::handle(substr($req->path, strlen(control) + 1));\n\t\tphlo('res')->render();\n\t\treturn;\n\t}\n\tphlo_load(true);\n\tphlo('app');\n\tphlo('res')->render();\n}\ncatch (RuntimeException $e){\n\tif ($e->getMessage() === 'PhloDump') return;\n\tphlo_exception($e);\n}\ncatch (Throwable $e){\n\tphlo_exception($e);\n}",
                "file": "/srv/control/phlo/phlo.php",
                "line": 116,
                "source": "native"
            },
            "select": {
                "args": "...$args",
                "return": "string",
                "body": "tag('select', ...$args)",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "DOM form tags for button, input, select and textarea",
                    "advice": "button(), input(), select() and textarea() are tag() with the name filled in, so they take the same named arguments and the same escaping. They exist because these four appear so often that the name is noise.",
                    "package": "view",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "tag",
                    "tags": "form tags html view"
                },
                "file": "/srv/control/phlo/resources/tags.form.phlo",
                "line": 13,
                "source": "function"
            },
            "setting": {
                "args": "?string $key = null, $value = null",
                "return": "mixed",
                "body": "\t$store = %JSON('settings', assoc: true)\n\tif ($key === null) return $store->objData\n\tif (func_num_args() > 1){\n\t\t$store->$key = $value\n\t\t$store->objChanged && $store->objWrite($store->objData)\n\t}\n\treturn $store->$key",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Persistent app settings in data/settings.json: setting() lists everything, setting(key) reads one value or null, setting(key, value) writes",
                    "advice": "Three shapes in one function: setting() gives everything, setting(key) reads one value or null, setting(key, value) writes and saves at once. It is meant for the handful of choices an app keeps, in data/settings.json, not for data with a life of its own; anything you would want to query or relate belongs in a model.",
                    "package": "files",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@JSON",
                    "tags": "settings config storage json"
                },
                "file": "/srv/control/phlo/resources/settings.phlo",
                "line": 11,
                "source": "function"
            },
            "slug": {
                "args": "string $text",
                "return": "string",
                "body": "trim(preg_replace('/[^a-z0-9]+/', dash, strtolower(iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text))), dash)",
                "meta": {
                    "version": "1.0",
                    "summary": "Convert text to URL slug",
                    "advice": "Makes a URL slug: accents are folded to their ascii letter, everything else becomes a dash. Two different titles can produce the same slug, so check for a collision before you use one as an id.",
                    "package": "string",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "slug url string format"
                },
                "file": "/srv/control/phlo/resources/slug.phlo",
                "line": 9,
                "source": "function"
            },
            "stream": {
                "args": "$data = null, string $type = 'application/octet-stream', ?string $name = null",
                "return": "void",
                "body": "\t$res = %res\n\t$cli = %req->cli\n\t!$res->streaming && $res->done && error('Output already started, invalid stream()')\n\tif (!$res->streaming){\n\t\t$res->streaming = true\n\t\t$res->type = $type\n\t\t$res->header('Cache-Control', 'no-store')\n\t\t$res->header('X-Content-Type-Options', 'nosniff')\n\t\t$res->header('X-Accel-Buffering', 'no')\n\t\t$name === null || $res->header('Content-Disposition', 'attachment; filename=\"'.str_replace('\"', '', $name).'\"')\n\t\t$res->render()\n\t}\n\tif ($data === null) return\n\tforeach (is_iterable($data) ? $data : [$data] as $part){\n\t\tprint((string)$part)\n\t\t$cli || [@ob_flush(), flush()]\n\t}",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Raw data stream beside the JSON command channel: stream() emits text or binary chunks under any content type, app.stream() consumes them via fetch dispatching on the response type",
                    "advice": "The channel beside the command stream, for what is not JSON: an image, a PDF, a zip, a file to download. Give it a content type, and a name when it should arrive as a download rather than be shown. On the page app.stream() picks its handling from the type that came back, so a caller does not have to know in advance what it is getting.",
                    "package": "runtime",
                    "frontend": "true",
                    "backend": "true",
                    "provides": "app.stream",
                    "tags": "stream binary raw data download"
                },
                "file": "/srv/control/phlo/resources/stream.phlo",
                "line": 11,
                "source": "function"
            },
            "tag": {
                "args": "string $tagName, ?string $inner = null, ...$args",
                "return": "string",
                "body": "\"<$tagName\".loop(array_filter($args, fn($value) => !is_null($value)), fn($value, $key) => space.strtr($key, [us => dash]).($value === true ? void : '=\"'.esc($value).'\"'), void).'>'.(is_null($inner) ? void : \"$inner</$tagName>\")",
                "meta": {
                    "version": "1.0",
                    "summary": "Generate HTML tag string with attributes",
                    "advice": "One function for every element: attributes are named arguments, an underscore becomes a dash, so data_id turns into data-id, and true gives a bare attribute like required. Values are escaped, and an attribute with null is left out, which is what lets an optional attribute be written without a condition around it. Pass no inner content and you get a single tag without a closing one.",
                    "package": "view",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "esc",
                    "tags": "tag html render view"
                },
                "file": "/srv/control/phlo/resources/tag.phlo",
                "line": 10,
                "source": "function"
            },
            "textarea": {
                "args": "...$args",
                "return": "string",
                "body": "tag('textarea', ...$args)",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "DOM form tags for button, input, select and textarea",
                    "advice": "button(), input(), select() and textarea() are tag() with the name filled in, so they take the same named arguments and the same escaping. They exist because these four appear so often that the name is noise.",
                    "package": "view",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "tag",
                    "tags": "form tags html view"
                },
                "file": "/srv/control/phlo/resources/tags.form.phlo",
                "line": 14,
                "source": "function"
            },
            "time_human": {
                "args": "?int $time = null",
                "return": "string",
                "body": "\tstatic $labels\n\t$labels ??= last($labels = arr(seconds: 60, minutes: 60, hours: 24, days: 7, weeks: 4, months: 13, years: 1), defined('tsLabels') && $labels = array_combine(tsLabels, $labels), $labels)\n\t$age = time() - $time\n\tforeach ($labels AS $range => $multiplier){\n\t\tif ($age / $multiplier < 1.6583) break\n\t\t$age /= $multiplier\n\t}\n\treturn round($age).\" $range\"",
                "meta": {
                    "version": "1.0",
                    "summary": "Convert timestamp age to human label",
                    "advice": "Rounds a timestamp to one unit that reads well, so a moment ago and last year both come out short. Define tsLabels to say it in another language; the frontend has the same trick with app.tsLabels, so both sides can speak the visitor's language.",
                    "package": "time",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "time human age format"
                },
                "file": "/srv/control/phlo/resources/time.human.phlo",
                "line": 9,
                "source": "function"
            },
            "wsCast": {
                "args": "$wsTarget = 'all', $wsHost = host, $wsPort = daemon, $wsExcept = void, ...$data",
                "body": "HTTP (\n\t'http://127.0.0.1:'.$wsPort.'/message',\n\tJSON: true,\n\tPOST: arr (\n\t\thost: $wsHost,\n\t\ttarget: $wsTarget,\n\t\texcept: $wsExcept,\n\t\tdata: $data,\n\t),\n)",
                "meta": {
                    "version": "1.0",
                    "summary": "Broadcast a message to WebSocket clients via the daemon's cast bridge",
                    "advice": "Sends a message to connected clients from anywhere in the backend, without a socket of your own: it goes over the daemon's bridge. wsTarget picks who, and wsExcept leaves one connection out, which is how you avoid echoing an action back to the person who caused it. It needs a running daemon, so it does nothing on a plain web process.",
                    "package": "realtime",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "HTTP",
                    "tags": "websocket cast realtime http daemon"
                },
                "file": "/srv/control/phlo/resources/wsCast.phlo",
                "line": 10,
                "source": "function"
            }
        },
        "objs": {
            "cookies": {
                "file": "/srv/control/phlo/resources/cookies.phlo",
                "class": "cookies",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Cookies data object",
                    "advice": "Reads and writes a cookie as a property. Every cookie it sets is httponly, samesite Lax and secure on an https request, so a script cannot read it and it does not travel to another site. That also means a value you need in the browser does not belong here. lifetimeDays sets how long they last; unsetting the property removes the cookie on the visitor's side too.",
                    "package": "web",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "cookies session browser web"
                },
                "nodes": {
                    "controller": {
                        "node": "method",
                        "name": "controller",
                        "operator": "method",
                        "body": "$this->objData = $_COOKIE",
                        "line": 10
                    },
                    "lifetimeDays": {
                        "node": "prop",
                        "visibility": null,
                        "name": "lifetimeDays",
                        "args": null,
                        "type": "int",
                        "operator": "value",
                        "body": "180",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "objSet": {
                        "node": "method",
                        "visibility": null,
                        "name": "objSet",
                        "args": "$key, $value, array $options = []",
                        "type": "bool",
                        "operator": "method",
                        "body": "\t$this->objData[$key] = $value\n\t$_COOKIE[$key] = $value\n\t$defaults = ['expires' => time() + $this->lifetimeDays * 86400, 'path' => slash, 'secure' => %req->secure, 'httponly' => true, 'samesite' => 'Lax']\n\tsetcookie($key, $value, array_merge($defaults, $options))\n\treturn true",
                        "line": 14,
                        "bodyLine": 15
                    },
                    "__unset": {
                        "node": "method",
                        "visibility": null,
                        "name": "__unset",
                        "args": "$key",
                        "type": "void",
                        "operator": "method",
                        "body": "\tunset($this->objData[$key], $_COOKIE[$key])\n\t$options = ['expires' => time() - 86400, 'path' => slash, 'secure' => %req->secure, 'httponly' => true, 'samesite' => 'Lax']\n\tsetcookie($key, void, $options)",
                        "line": 22,
                        "bodyLine": 23
                    }
                },
                "functions": [],
                "assets": []
            },
            "lang": {
                "file": "/srv/control/phlo/resources/lang.phlo",
                "class": "lang",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Language and translation resource",
                    "package": "i18n",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@cookies @AI @INI phlo.async",
                    "advice": "%lang prints the current app language through its view, so it drops straight into a link or an attribute. Beyond that it is the translation layer behind nl() and en(): a phrase is hashed, looked up in langs/<lang>.ini, and translated by the AI in the background when it is missing, so the first visitor reads the source text and the next one reads the translation. Editing a source phrase changes its hash and orphans the old translation, so a rewrite costs a re-translation.",
                    "tags": "lang translation i18n locale ai"
                },
                "nodes": {
                    "asyncBatch": {
                        "node": "static",
                        "visibility": null,
                        "name": "asyncBatch",
                        "args": "$from, $to, $json",
                        "type": "void",
                        "operator": "method",
                        "body": "\t%app->lang = $to\n\t$texts = json_decode($json, true)\n\t$translations = $this->translateBatch($from, $to, $texts)\n\tif ($translations) $this->save($to, $translations)",
                        "line": 14,
                        "bodyLine": 15
                    },
                    "view": {
                        "node": "view",
                        "visibility": null,
                        "name": "view",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "%app->lang",
                        "line": 21,
                        "bodyLine": 21
                    },
                    "model": {
                        "node": "prop",
                        "visibility": null,
                        "name": "model",
                        "args": null,
                        "type": "string",
                        "operator": "value",
                        "body": "'gpt-4o-mini'",
                        "line": 23,
                        "bodyLine": 23
                    },
                    "instructions": {
                        "node": "prop",
                        "visibility": null,
                        "name": "instructions",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "void",
                        "line": 24,
                        "bodyLine": 24
                    },
                    "fileCache": {
                        "node": "static",
                        "visibility": null,
                        "name": "fileCache",
                        "args": null,
                        "type": "array",
                        "operator": "value",
                        "body": "[]",
                        "line": 25,
                        "bodyLine": 25
                    },
                    "file": {
                        "node": "method",
                        "visibility": null,
                        "name": "file",
                        "args": "$lang",
                        "type": "string",
                        "operator": "arrow",
                        "body": "langs.$lang.'.ini'",
                        "line": 27,
                        "bodyLine": 27
                    },
                    "escape": {
                        "node": "method",
                        "visibility": null,
                        "name": "escape",
                        "args": "$value",
                        "type": "string",
                        "operator": "arrow",
                        "body": "strtr((string)$value, [bs => bs.bs, dq => bs.dq, lf => '\\n'])",
                        "line": 29,
                        "bodyLine": 29
                    },
                    "unescape": {
                        "node": "method",
                        "visibility": null,
                        "name": "unescape",
                        "args": "$value",
                        "type": "string",
                        "operator": "arrow",
                        "body": "strtr(strtr($value, [bs.bs => \"\\x01\", bs.dq => dq, '\\n' => lf]), [\"\\x01\" => bs])",
                        "line": 30,
                        "bodyLine": 30
                    },
                    "lineValue": {
                        "node": "method",
                        "visibility": null,
                        "name": "lineValue",
                        "args": "$line, $eq",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$value = rtrim(substr($line, $eq + 3), cr.lf)\n\tif (strlen($value) > 1 && $value[0] === dq && substr($value, -1) === dq) $value = substr($value, 1, -1)\n\treturn $this->unescape($value)",
                        "line": 32,
                        "bodyLine": 33
                    },
                    "readAll": {
                        "node": "method",
                        "visibility": null,
                        "name": "readAll",
                        "args": "$file",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$items = []\n\tif (!is_file($file)) return $items\n\tforeach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] AS $line){\n\t\t$eq = strpos($line, ' = ')\n\t\tif ($eq === false) continue\n\t\t$items[substr($line, 0, $eq)] = $this->lineValue($line, $eq)\n\t}\n\treturn $items",
                        "line": 38,
                        "bodyLine": 39
                    },
                    "search": {
                        "node": "method",
                        "visibility": null,
                        "name": "search",
                        "args": "$file, $hash",
                        "type": "?string",
                        "operator": "method",
                        "body": "\t$size = (int)@filesize($file)\n\tif (!$size) return null\n\t$h = @fopen($file, 'rb')\n\tif (!$h) return null\n\t$lo = 0\n\t$hi = $size\n\twhile ($hi - $lo > 4096){\n\t\t$mid = intdiv($lo + $hi, 2)\n\t\tfseek($h, $mid)\n\t\tfgets($h)\n\t\t$pos = ftell($h)\n\t\tif ($pos >= $hi){\n\t\t\t$hi = $mid\n\t\t\tcontinue\n\t\t}\n\t\t$line = (string)fgets($h)\n\t\t$eq = strpos($line, ' = ')\n\t\tif ($eq === false){\n\t\t\t$hi = $mid\n\t\t\tcontinue\n\t\t}\n\t\t$cmp = strcmp(substr($line, 0, $eq), $hash)\n\t\tif ($cmp < 0) $lo = ftell($h)\n\t\telseif ($cmp > 0) $hi = $pos\n\t\telse {\n\t\t\tfclose($h)\n\t\t\treturn $this->lineValue($line, $eq)\n\t\t}\n\t}\n\tfseek($h, $lo)\n\t$value = null\n\twhile (ftell($h) < $hi && ($line = fgets($h)) !== false){\n\t\t$eq = strpos($line, ' = ')\n\t\tif ($eq === false) continue\n\t\t$cmp = strcmp(substr($line, 0, $eq), $hash)\n\t\tif ($cmp > 0) break\n\t\tif ($cmp === 0){\n\t\t\t$value = $this->lineValue($line, $eq)\n\t\t\tbreak\n\t\t}\n\t}\n\tfclose($h)\n\treturn $value",
                        "line": 54,
                        "comments": "Finds one line with a binary search over the raw bytes of the translation file.\nThe file is a sorted key = value list, which save() guarantees with ksort, so a lookup\ncosts a few seeks instead of reading a file that grows with every phrase in the app.\nIt only holds while the file stays sorted; lookup() falls back to readAll() when this\nreturns nothing, so a hand-edited file still resolves.",
                        "bodyLine": 55
                    },
                    "lookup": {
                        "node": "method",
                        "visibility": null,
                        "name": "lookup",
                        "args": "$hash",
                        "type": "?string",
                        "operator": "method",
                        "body": "\t$file = $this->file(%app->lang)\n\t$mtime = (int)@filemtime($file)\n\t$cache =& static::$fileCache[$file]\n\tif (!$cache || $cache['mtime'] !== $mtime) $cache = ['mtime' => $mtime, 'items' => []]\n\tif (array_key_exists($hash, $cache['items'])) return $cache['items'][$hash]\n\t$value = $this->search($file, $hash)\n\tif ($value === null && $mtime) $value = $this->readAll($file)[$hash] ?? null\n\treturn $cache['items'][$hash] = $value",
                        "line": 100,
                        "bodyLine": 101
                    },
                    "save": {
                        "node": "method",
                        "visibility": null,
                        "name": "save",
                        "args": "$lang, $pairs",
                        "type": "void",
                        "operator": "method",
                        "body": "\t$file = $this->file($lang)\n\t$items = $this->readAll($file)\n\tforeach ($pairs AS $hash => $value) $items[$hash] = $value\n\tksort($items, SORT_STRING)\n\t$out = void\n\tforeach ($items AS $hash => $value) $out .= $hash.' = '.dq.$this->escape($value).dq.lf\n\t$tmp = $file.'.'.getmypid().'.tmp'\n\tfile_put_contents($tmp, $out, LOCK_EX)\n\t@chmod($tmp, 0664)\n\trename($tmp, $file)\n\tunset(static::$fileCache[$file])",
                        "line": 115,
                        "comments": "Writes the whole file under a temporary name and renames it into place.\nA rename within one filesystem is atomic, so a reader never catches a half-written file,\nand the pid in the temporary name keeps two workers from sharing one. The in-memory\ncache for this file is dropped afterwards, because its mtime has just changed.",
                        "bodyLine": 116
                    },
                    "transContext": {
                        "node": "method",
                        "visibility": null,
                        "name": "transContext",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "($instr = trim($this->instructions ?? void)) !== void ? lf.'Context from the app author about purpose and domain: '.$instr : void",
                        "line": 129,
                        "bodyLine": 129
                    },
                    "browser": {
                        "node": "prop",
                        "visibility": null,
                        "name": "browser",
                        "args": null,
                        "type": "?string",
                        "operator": "arrow",
                        "body": "last($langs = array_filter(explode(comma, %req->acceptLanguage), fn($lang) => isset(%app->langs[substr($lang, 0, 2)])), $langs ? substr(current($langs), 0, 2) : null)",
                        "line": 131,
                        "bodyLine": 131
                    },
                    "cookie": {
                        "node": "method",
                        "visibility": null,
                        "name": "cookie",
                        "args": null,
                        "type": "?string",
                        "operator": "arrow",
                        "body": "($lang = %cookies->lang) && %app->langs[$lang] ? $lang : null",
                        "line": 132,
                        "bodyLine": 132
                    },
                    "detect": {
                        "node": "method",
                        "visibility": null,
                        "name": "detect",
                        "args": "$text, $fallback = 'en'",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$res = %AI->chat (\n\t\tmodel: $this->model,\n\t\tsystem: 'Analyse which language this text is in and return only the ISO 639-1 code of the language, no other data!',\n\t\tuser: $text.lf.lf.'The ISO 639-1 code of the language is: ',\n\t\ttemperature: 0,\n\t)->answer\n\treturn strlen($res) === 2 ? strtolower($res) : $fallback",
                        "line": 133,
                        "bodyLine": 134
                    },
                    "hash": {
                        "node": "method",
                        "visibility": null,
                        "name": "hash",
                        "args": "$from, $text",
                        "type": "string",
                        "operator": "arrow",
                        "body": "$from.($short = substr(implode(regex_all('/[A-Za-z0-9]+/', ucwords($text))[0]), 0, 8)).substr(md5($text), 0, 10 - strlen($short))",
                        "line": 142,
                        "bodyLine": 142
                    },
                    "translation": {
                        "node": "method",
                        "visibility": null,
                        "name": "translation",
                        "args": "$from, $text, ...$args",
                        "type": "string",
                        "operator": "method",
                        "body": "\tif ($from === %app->lang) $translation = strtr($text, ['\\n' => lf])\n\telse {\n\t\t$translation = []\n\t\t$missing = []\n\t\tforeach (explode(lf, $text) AS $line){\n\t\t\tif (trim($line)){\n\t\t\t\t$hash = $this->hash($from, $line)\n\t\t\t\t$item = $this->lookup($hash)\n\t\t\t\tif ($item === null) [$missing[$hash] = $item = $line, debug(%app->lang.': '.(strlen($line) > 20 ? substr($line, 0, 18).'...' : $line))]\n\t\t\t}\n\t\t\telse $item = void\n\t\t\t$translation[] = $item\n\t\t}\n\t\tif ($missing) phlo_async('lang::asyncBatch', $from, %app->lang, json_encode($missing))\n\t\t$translation = implode(lf, $translation)\n\t}\n\treturn $args ? sprintf($translation, ...$args) : $translation",
                        "line": 143,
                        "bodyLine": 144
                    },
                    "translate": {
                        "node": "method",
                        "visibility": null,
                        "name": "translate",
                        "args": "$from, $to, $text",
                        "type": "string",
                        "operator": "method",
                        "body": "\tif ($from === $to) return $text\n\treturn %AI->chat (\n\t\tmodel: $this->model,\n\t\tsystem: \"You will be provided with a word, sentence or (markdown) text in ISO 639-1 language $from, and your task is to translate this string into ISO 639-1 language $to. Respect markdown, missing interpunction and specific use of capitals. Give only the translation.\".$this->transContext(),\n\t\tuser: $text,\n\t\ttemperature: 0,\n\t)->answer",
                        "line": 162,
                        "bodyLine": 163
                    },
                    "translateBatch": {
                        "node": "method",
                        "visibility": null,
                        "name": "translateBatch",
                        "args": "$from, $to, $texts",
                        "type": "array",
                        "operator": "method",
                        "body": "\tif ($from === $to) return $texts\n\t$hashes = array_keys($texts)\n\t$numbered = implode(lf, array_map(fn($i, $t) => ($i + 1).'. '.$t, array_keys($values = array_values($texts)), $values))\n\t$answer = %AI->chat (\n\t\tmodel: $this->model,\n\t\tsystem: \"You will be provided with numbered lines in ISO 639-1 language $from. Translate each line into ISO 639-1 language $to. Return only the numbered translations in the same format. Respect markdown, missing interpunction and specific use of capitals.\".$this->transContext(),\n\t\tuser: $numbered,\n\t\ttemperature: 0,\n\t)->answer\n\t$result = []\n\tforeach (explode(lf, trim($answer)) AS $line){\n\t\tif (preg_match('/^(\\d+)\\.\\s*(.+)/', $line, $m))\n\t\t\t$result[$hashes[(int)$m[1] - 1]] = $m[2]\n\t}\n\treturn $result",
                        "line": 171,
                        "bodyLine": 172
                    }
                },
                "functions": {
                    "nl": {
                        "node": "function",
                        "name": "nl",
                        "args": "$text, ...$args",
                        "type": "string",
                        "operator": "arrow",
                        "body": "%lang->translation('nl', $text, ...$args)",
                        "line": 11,
                        "bodyLine": 11
                    },
                    "en": {
                        "node": "function",
                        "name": "en",
                        "args": "$text, ...$args",
                        "type": "string",
                        "operator": "arrow",
                        "body": "%lang->translation('en', $text, ...$args)",
                        "line": 12,
                        "bodyLine": 12
                    }
                },
                "assets": []
            },
            "lastmod": {
                "file": "/srv/control/phlo/resources/lastmod.phlo",
                "class": "lastmod",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Build-time stamp of when each page's source last changed, read at runtime for sitemap lastmod and for showing a date to the reader",
                    "package": "seo",
                    "frontend": "false",
                    "backend": "true",
                    "advice": "Call lastmod::stamp from a build hook, so the dates are read where the sources are. Resolving them at runtime does not work on a release node, which carries no .phlo sources, and a deploy rewrites every mtime, so every page would claim to have changed on the day it was deployed. Pages in %app->pages resolve by convention; declare prop %lastmod.sources as uri => file path for the rest. A page that resolves to nothing simply gets no lastmod, which is the right answer: a crawler that catches a site inventing them stops trusting the field domain-wide.",
                    "tags": "seo sitemap lastmod build stamp"
                },
                "nodes": {
                    "file": {
                        "node": "static",
                        "visibility": null,
                        "name": "file",
                        "args": "$dir = null",
                        "type": "string",
                        "operator": "arrow",
                        "body": "($dir ?: php).'lastmod.json'",
                        "line": 13,
                        "comments": "Where the map lives, beside the generated PHP so it travels with a release.\nThe release hook passes its own output directory: it runs through the dev\nentrypoint and would otherwise stamp the build it is not shipping.",
                        "bodyLine": 13
                    },
                    "sources": {
                        "node": "prop",
                        "visibility": null,
                        "name": "sources",
                        "args": null,
                        "type": "array",
                        "operator": "value",
                        "body": "[]",
                        "line": 17,
                        "comments": "Sources the convention cannot find, as a uri to file path map.\nDeclare them from your app with prop %lastmod.sources.",
                        "bodyLine": 17
                    },
                    "resolve": {
                        "node": "static",
                        "visibility": null,
                        "name": "resolve",
                        "args": "$uri",
                        "type": "?string",
                        "operator": "method",
                        "body": "\t$slug = trim((string)$uri, slash)\n\t$names = $slug === void ? ['home.phlo', 'page.home.phlo'] : [$slug.'.phlo', 'page.'.$slug.'.phlo', strtr($slug, [slash => dot]).'.phlo']\n\tforeach ($names AS $name) if (is_file($file = app.$name)) return $file\n\treturn null",
                        "line": 20,
                        "comments": "The file most likely to render this page.",
                        "bodyLine": 21
                    },
                    "day": {
                        "node": "static",
                        "visibility": null,
                        "name": "day",
                        "args": "$file",
                        "type": "?string",
                        "operator": "arrow",
                        "body": "$file && is_file($file) ? date('Y-m-d', filemtime($file)) : null",
                        "line": 27,
                        "bodyLine": 27
                    },
                    "uriOf": {
                        "node": "static",
                        "visibility": null,
                        "name": "uriOf",
                        "args": "$page",
                        "type": "string",
                        "operator": "arrow",
                        "body": "is_string($page) ? $page : (string)(is_array($page) ? ($page['uri'] ?? void) : ($page->uri ?? void))",
                        "line": 29,
                        "bodyLine": 29
                    },
                    "stamp": {
                        "node": "static",
                        "visibility": null,
                        "name": "stamp",
                        "args": "$dir = null",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$map = []\n\tforeach ((array)(%app->pages ?? []) AS $page) ($d = static::day(static::resolve(static::uriOf($page)))) && $map[static::uriOf($page)] = $d\n\tforeach ((array)%lastmod->sources AS $uri => $file) ($d = static::day($file)) && $map[$uri] = $d\n\tksort($map)\n\tfile_put_contents($target = static::file($dir), json_encode($map, jsonPretty))\n\treturn ['file' => $target, 'pages' => count($map)]",
                        "line": 31,
                        "bodyLine": 32
                    },
                    "map": {
                        "node": "prop",
                        "visibility": null,
                        "name": "map",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "is_file($file = static::file()) ? (array)json_decode((string)file_get_contents($file), true) : []",
                        "line": 40,
                        "bodyLine": 40
                    },
                    "for": {
                        "node": "method",
                        "visibility": null,
                        "name": "for",
                        "args": "$uri",
                        "type": "?string",
                        "operator": "arrow",
                        "body": "$this->map[$uri] ?? null",
                        "line": 42,
                        "bodyLine": 42
                    }
                },
                "functions": [],
                "assets": []
            },
            "manifest": {
                "file": "/srv/control/phlo/resources/manifest.phlo",
                "class": "manifest",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "PWA web app manifest: declare the body, get the manifest.json route, head link and correct serving",
                    "package": "web",
                    "frontend": "false",
                    "backend": "true",
                    "advice": "Set the body from your app (prop %manifest.body => arr(...)) and put %manifest->head in your head view, because that head view is the only thing that links the manifest from a page. %manifest itself is the manifest document, the way %seo is the sitemap. Apps with multiple manifest variants keep their own routes and serve each body through manifest::output().",
                    "tags": "manifest pwa webmanifest install standalone"
                },
                "nodes": {
                    "GETManifest": {
                        "node": "route",
                        "mode": null,
                        "method": "GET",
                        "path": "manifest.json",
                        "data": null,
                        "operator": "method",
                        "body": "\tif (!%manifest->body) return false\n\tmanifest::output(%manifest->body)",
                        "line": 10,
                        "bodyLine": 11,
                        "name": "GETManifest"
                    },
                    "body": {
                        "node": "prop",
                        "visibility": null,
                        "name": "body",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "null",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "maxAge": {
                        "node": "prop",
                        "visibility": null,
                        "name": "maxAge",
                        "args": null,
                        "type": "int",
                        "operator": "value",
                        "body": "60",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "encode": {
                        "node": "static",
                        "visibility": null,
                        "name": "encode",
                        "args": "$body",
                        "type": "string",
                        "operator": "arrow",
                        "body": "json_encode($body, jsonPretty)",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "output": {
                        "node": "static",
                        "visibility": null,
                        "name": "output",
                        "args": "$body, $maxAge = null",
                        "type": "void",
                        "operator": "method",
                        "body": "\t%res->header('Cache-Control', 'public, max-age='.($maxAge ?? %manifest->maxAge))\n\toutput(static::encode($body), type: 'application/manifest+json')",
                        "line": 20,
                        "bodyLine": 21
                    },
                    "view": {
                        "node": "view",
                        "visibility": null,
                        "name": "view",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "manifest::encode(%manifest->body)",
                        "line": 25,
                        "bodyLine": 25
                    },
                    "head": {
                        "node": "view",
                        "visibility": null,
                        "name": "head",
                        "args": null,
                        "type": null,
                        "operator": "view",
                        "body": "<link rel=manifest href=/manifest.json>",
                        "line": 27
                    }
                },
                "functions": [],
                "assets": []
            },
            "manual": {
                "file": "/srv/control/phlo/resources/manual.phlo",
                "class": "manual",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Self-writing manual at /manual: app description, source reflection and recent commits, plus an optional AI summary",
                    "package": "docs",
                    "frontend": "true",
                    "backend": "true",
                    "requires": "reflect @AI? creds:OpenAI?",
                    "advice": "Nothing to configure: include the resource and the page describes the app it runs in. It reads data/app.md, reflects the live source and reads git log, so it never goes stale. A product layer under paths.resources appears as its own section once it carries a layer.json in its repo root and a heading in its README; without that file a layer stays out of sight, which is how the framework itself stays out. The page is standalone (inline css, no javascript, no layout) and every render that changes the content is stored as data/manual.html without its session token, so the last state survives without the app. The markdown of data/app.md is rendered server-side, so the page needs no runtime and no namespace configuration. The AI summary is optional and keyed on data/app.md, so it costs nothing per visit; without the AI resource or a key the rest of the page still works. Put the route behind your auth gate and add manual to release.exclude, since a manual carrying the source does not belong on a customer server.",
                    "tags": "manual docs reflection ai"
                },
                "nodes": {
                    "instruction": {
                        "node": "static",
                        "visibility": null,
                        "name": "instruction",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'Summarise what this application does and what has changed recently. Write in the same language as the description, at most eight sentences, running text without lists or headings. Do not open with a sentence about what you are going to do.'",
                        "line": 11,
                        "bodyLine": 11
                    },
                    "translate": {
                        "node": "prop",
                        "visibility": null,
                        "name": "translate",
                        "args": null,
                        "type": "bool",
                        "operator": "arrow",
                        "body": "function_exists('en')",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "labels": {
                        "node": "prop",
                        "visibility": null,
                        "name": "labels",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "arr(\n\ttitle:    'Manual',\n\tlead:     'This page comes from the source itself: the description, what the code exposes and the latest changes.',\n\tstate:    'State',\n\tunknown:  'unknown',\n\tsummary:  'In short',\n\tabout:    'What this app is',\n\tfiles:    'Files',\n\troutes:   'Routes',\n\tchanges:  'Recent changes',\n\twhy:      'Why',\n\town:      'Codebase',\n\tshared:   'Shared with the sibling app',\n\tnoKey:    'No AI key is configured, so there is no summary. The rest of this page comes straight from the source and works without one.',\n\tnoAnswer: 'The model returned nothing.',\n\tnoSummary:'The summary could not be fetched: ',\n\tnoInfo:   'This app does not carry a data/app.md yet.',\n\tnoNodes:  'This file carries no nodes; it consists of markup or script.',\n)",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "label": {
                        "node": "method",
                        "visibility": null,
                        "name": "label",
                        "args": "$key",
                        "type": "string",
                        "operator": "arrow",
                        "body": "$this->translate ? en($this->labels[$key]) : $this->labels[$key]",
                        "line": 33,
                        "bodyLine": 33
                    },
                    "appInfo": {
                        "node": "static",
                        "visibility": null,
                        "name": "appInfo",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "(string)(reflect::appInfo() ?: void)",
                        "line": 35,
                        "bodyLine": 35
                    },
                    "commits": {
                        "node": "static",
                        "visibility": null,
                        "name": "commits",
                        "args": "string $path, int $limit = 12",
                        "type": "array",
                        "operator": "method",
                        "body": "\tif (!$path || !is_dir($path)) return []\n\t$field = perc.'x1f'\n\t$record = perc.'x1e'\n\t$format = perc.'h'.$field.perc.'ad'.$field.perc.'s'.$field.perc.'b'.$record\n\t$command = 'git -C '.escapeshellarg($path).' log -'.(int)$limit.' --no-merges --date=iso --format='.$format.' 2>/dev/null'\n\t$raw = (string)@shell_exec($command)\n\t$out = []\n\tforeach (array_filter(explode(\"\\x1e\", $raw), 'trim') AS $entry){\n\t\t$parts = explode(\"\\x1f\", trim($entry))\n\t\tif (count($parts) < 3) continue\n\t\t$when = strtotime($parts[1]) ?: 0\n\t\t$out[] = obj(\n\t\t\thash: $parts[0],\n\t\t\twhen: $when,\n\t\t\tdate: $when ? date('d-m-Y H:i', $when) : $parts[1],\n\t\t\tsubject: $parts[2],\n\t\t\tbody: trim((string)($parts[3] ?? void)),\n\t\t)\n\t}\n\treturn $out",
                        "line": 37,
                        "bodyLine": 38
                    },
                    "repos": {
                        "node": "static",
                        "visibility": null,
                        "name": "repos",
                        "args": null,
                        "type": "array",
                        "operator": "method",
                        "body": "\t$path = app\n\t$dirs = [%manual->label('own') => $path]\n\t$config = json_decode((string)@file_get_contents($path.'data/app.json'), true)\n\tforeach ((array)($config['paths']['resources'] ?? []) AS $dir){\n\t\t$dir = rtrim((string)$dir, slash).slash\n\t\t$label = is_dir($dir) ? static::dirLabel($dir) : void\n\t\tif ($label === void || isset($dirs[$label])) continue\n\t\t$dirs[$label] = $dir\n\t}\n\t$roots = []\n\t$out = []\n\tforeach ($dirs AS $label => $dir){\n\t\t$root = trim((string)@shell_exec('git -C '.escapeshellarg($dir).' rev-parse --show-toplevel 2>/dev/null'))\n\t\tif ($root === void || isset($roots[$root])) continue\n\t\t$roots[$root] = true\n\t\t$out[$label] = $dir\n\t}\n\treturn $out",
                        "line": 60,
                        "bodyLine": 61
                    },
                    "commitList": {
                        "node": "static",
                        "visibility": null,
                        "name": "commitList",
                        "args": "int $limit = 20",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$all = []\n\tforeach (static::repos() AS $label => $dir){\n\t\tforeach (static::commits($dir) AS $commit){\n\t\t\t$commit->origin = $label\n\t\t\t$all[] = $commit\n\t\t}\n\t}\n\tusort($all, fn($a, $b) => $b->when <=> $a->when)\n\treturn array_slice($all, 0, $limit)",
                        "line": 81,
                        "bodyLine": 82
                    },
                    "head": {
                        "node": "static",
                        "visibility": null,
                        "name": "head",
                        "args": null,
                        "type": "string",
                        "operator": "method",
                        "body": "\t$commits = static::commits(app, 1)\n\treturn (string)($commits[0]->hash ?? void)",
                        "line": 93,
                        "bodyLine": 94
                    },
                    "routes": {
                        "node": "static",
                        "visibility": null,
                        "name": "routes",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "(array)reflect::compactRoutes()",
                        "line": 98,
                        "bodyLine": 98
                    },
                    "routeGroups": {
                        "node": "static",
                        "visibility": null,
                        "name": "routeGroups",
                        "args": null,
                        "type": "array",
                        "operator": "method",
                        "body": "\t$groups = []\n\tforeach (static::routes() AS $route){\n\t\t$file = basename((string)($route['file'] ?? 'unknown'))\n\t\t$groups[$file][] = $route\n\t}\n\tksort($groups)\n\treturn $groups",
                        "line": 100,
                        "bodyLine": 101
                    },
                    "nodesByFile": {
                        "node": "static",
                        "visibility": null,
                        "name": "nodesByFile",
                        "args": null,
                        "type": "array",
                        "operator": "method",
                        "body": "\tif (isset(%req->docsNodes)) return (array)%req->docsNodes\n\t$found = []\n\tforeach (['route', 'view', 'static', 'method', 'prop'] AS $type){\n\t\tforeach ((array)reflect::find($type, null, true, 'all') AS $row){\n\t\t\t$row = (array)$row\n\t\t\t$file = basename((string)($row['file'] ?? void), '.phlo')\n\t\t\tif ($file === void) continue\n\t\t\t$found[$file][] = obj(\n\t\t\t\ttype: $type,\n\t\t\t\tname: (string)($row['name'] ?? void),\n\t\t\t\targs: (string)($row['args'] ?? void),\n\t\t\t\tret: (string)($row['type'] ?? void),\n\t\t\t\tsummary: (string)($row['summary'] ?? void),\n\t\t\t)\n\t\t}\n\t}\n\t%req->docsNodes = $found\n\treturn $found",
                        "line": 110,
                        "bodyLine": 111
                    },
                    "fileRow": {
                        "node": "static",
                        "visibility": null,
                        "name": "fileRow",
                        "args": "string $file",
                        "type": "obj",
                        "operator": "method",
                        "body": "\t$meta = (new build_file($file))->meta\n\t$key = basename($file, '.phlo')\n\treturn obj(\n\t\tname: basename($file),\n\t\tsummary: trim((string)($meta['summary'] ?? void)),\n\t\tadvice: trim((string)($meta['advice'] ?? void)),\n\t\tlines: count(file($file) ?: []),\n\t\tnodes: static::nodesByFile()[$key] ?? [],\n\t)",
                        "line": 131,
                        "bodyLine": 132
                    },
                    "sources": {
                        "node": "static",
                        "visibility": null,
                        "name": "sources",
                        "args": null,
                        "type": "array",
                        "operator": "method",
                        "body": "\t$path = app\n\t$own = %manual->label('own')\n\t$config = json_decode((string)@file_get_contents($path.'data/app.json'), true)\n\tif (!is_array($config)) return []\n\n\t$dirs = [$path => $own]\n\tforeach ((array)($config['paths']['resources'] ?? []) AS $dir){\n\t\t$dir = rtrim((string)$dir, slash).slash\n\t\tif (!is_dir($dir)) continue\n\t\t$label = static::dirLabel($dir)\n\t\tif ($label !== void) $dirs[$dir] = $label\n\t}\n\n\t$groups = []\n\tforeach ($dirs AS $dir => $label) $groups[$label] = []\n\tforeach (glob($path.'*.phlo') ?: [] AS $file) $groups[$own][] = static::fileRow($file)\n\tforeach ((array)($config['resources'] ?? []) AS $name){\n\t\tforeach ($dirs AS $dir => $label){\n\t\t\tif ($label === $own) continue\n\t\t\t$file = $dir.$name.'.phlo'\n\t\t\tif (!is_file($file)) continue\n\t\t\t$groups[$label][] = static::fileRow($file)\n\t\t\tbreak\n\t\t}\n\t}\n\treturn array_filter($groups)",
                        "line": 143,
                        "bodyLine": 144
                    },
                    "dirLabel": {
                        "node": "static",
                        "visibility": null,
                        "name": "dirLabel",
                        "args": "string $dir",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$root = trim((string)@shell_exec('git -C '.escapeshellarg($dir).' rev-parse --show-toplevel 2>/dev/null'))\n\t$marker = $root === void ? null : json_decode((string)@file_get_contents($root.slash.'layer.json'), true)\n\tif (is_array($marker) && ($marker['docs'] ?? true) !== false){\n\t\t$label = trim((string)($marker['label'] ?? void))\n\t\tif ($label !== void) return $label\n\t\tpreg_match('~^#\\s+(.+)~m', (string)@file_get_contents($root.slash.'README.md'), $match)\n\t\tif (($label = trim((string)($match[1] ?? void))) !== void) return $label\n\t}\n\tif (str_starts_with(rtrim($dir, slash), rtrim(dirname(rtrim(app, slash)), slash))) return %manual->label('shared')\n\treturn void",
                        "line": 172,
                        "bodyLine": 173
                    },
                    "cacheFile": {
                        "node": "static",
                        "visibility": null,
                        "name": "cacheFile",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "data.'manual.json'",
                        "line": 185,
                        "bodyLine": 185
                    },
                    "cached": {
                        "node": "static",
                        "visibility": null,
                        "name": "cached",
                        "args": null,
                        "type": "?obj",
                        "operator": "method",
                        "body": "\t$file = static::cacheFile()\n\tif (!is_file($file)) return null\n\t$row = json_decode((string)file_get_contents($file))\n\treturn $row instanceof \\stdClass ? obj(...(array)$row) : null",
                        "line": 187,
                        "bodyLine": 188
                    },
                    "configured": {
                        "node": "static",
                        "visibility": null,
                        "name": "configured",
                        "args": null,
                        "type": "bool",
                        "operator": "method",
                        "body": "\tif (!class_exists('AI') || !class_exists('creds')) return false\n\treturn (string)(%creds->OpenAI ?? void) !== void || (string)(%creds->Claude ?? void) !== void",
                        "line": 194,
                        "bodyLine": 195
                    },
                    "appName": {
                        "node": "static",
                        "visibility": null,
                        "name": "appName",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "defined('id') ? (string)id : (string)%app->title",
                        "line": 199,
                        "bodyLine": 199
                    },
                    "model": {
                        "node": "static",
                        "visibility": null,
                        "name": "model",
                        "args": null,
                        "type": "string",
                        "operator": "method",
                        "body": "\t$set = class_exists('setting') ? (string)(setting::value('docs.model') ?: void) : void\n\treturn $set ?: 'gpt-5.4-mini'",
                        "line": 201,
                        "bodyLine": 202
                    },
                    "summary": {
                        "node": "static",
                        "visibility": null,
                        "name": "summary",
                        "args": null,
                        "type": "obj",
                        "operator": "method",
                        "body": "\t$info = static::appInfo()\n\t$head = $info === void ? void : md5($info)\n\t$row = static::cached()\n\tif ($row && (string)$row->hash === $head && $head !== void) return $row\n\tif (!static::configured()) return obj(hash: $head, text: void, missing: true)\n\n\t$lines = ['This is the description of the app:']\n\t$lines[] = mb_substr(static::appInfo(), 0, 4000)\n\t$lines[] = 'And these are the latest changes:'\n\tforeach (static::commits(app) AS $commit) $lines[] = $commit->date.space.$commit->subject\n\ttry {\n\t\t$answer = phlo('AI')->chat(model: static::model(), system: static::$instruction, user: implode(lf, $lines))\n\t\t$text = trim((string)($answer->answer ?? void))\n\t}\n\tcatch (\\Throwable $e){\n\t\treturn obj(hash: $head, text: void, error: mb_substr($e->getMessage(), 0, 200))\n\t}\n\tif ($text === void) return obj(hash: $head, text: void, error: %manual->label('noAnswer'))\n\t@file_put_contents(static::cacheFile(), json_encode(['hash' => $head, 'text' => $text, 'written' => time()]))\n\treturn obj(hash: $head, text: $text)",
                        "line": 206,
                        "bodyLine": 207
                    },
                    "pageFile": {
                        "node": "static",
                        "visibility": null,
                        "name": "pageFile",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "data.'manual.html'",
                        "line": 229,
                        "bodyLine": 229
                    },
                    "store": {
                        "node": "static",
                        "visibility": null,
                        "name": "store",
                        "args": "string $body, string $page",
                        "type": "bool",
                        "operator": "method",
                        "body": "\t$mark = '<!-- manual '.md5($body).' -->'\n\t$file = static::pageFile()\n\tif (is_file($file) && str_contains((string)@file_get_contents($file), $mark)) return false\n\treturn (bool)@file_put_contents($file, preg_replace('~<meta name=\"csrf\"[^>]*>\\s*~', void, $page).lf.$mark.lf)",
                        "line": 231,
                        "bodyLine": 232
                    },
                    "mdBlocks": {
                        "node": "static",
                        "visibility": null,
                        "name": "mdBlocks",
                        "args": "string $md",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$lines = explode(lf, str_replace(cr, void, $md))\n\t$count = count($lines)\n\t$blocks = []\n\t$para = []\n\t$i = 0\n\twhile ($i < $count){\n\t\t$line = $lines[$i]\n\t\tif (trim($line) === void){\n\t\t\tstatic::mdPara($para, $blocks)\n\t\t\t$i++\n\t\t\tcontinue\n\t\t}\n\t\tif (preg_match('/^ {0,3}(`{3,}|~{3,})/', $line, $match)){\n\t\t\tstatic::mdPara($para, $blocks)\n\t\t\t$fence = '/^ {0,3}'.preg_quote($match[1][0], slash).'{3,}\\s*$/'\n\t\t\t$body = []\n\t\t\t$i++\n\t\t\twhile ($i < $count && !preg_match($fence, $lines[$i])){\n\t\t\t\t$body[] = $lines[$i]\n\t\t\t\t$i++\n\t\t\t}\n\t\t\t$i++\n\t\t\t$blocks[] = obj(type: 'code', text: implode(lf, $body))\n\t\t\tcontinue\n\t\t}\n\t\tif (preg_match('/^ {0,3}(#{1,6})\\s+(.*?)\\s*#*$/', $line, $match)){\n\t\t\tstatic::mdPara($para, $blocks)\n\t\t\t$blocks[] = obj(type: 'heading', depth: min(4, max(2, strlen($match[1]))), text: trim($match[2]))\n\t\t\t$i++\n\t\t\tcontinue\n\t\t}\n\t\tif (preg_match('/^ {0,3}([-*_])(?:\\s*\\1){2,}\\s*$/', $line)){\n\t\t\tstatic::mdPara($para, $blocks)\n\t\t\t$blocks[] = obj(type: 'hr')\n\t\t\t$i++\n\t\t\tcontinue\n\t\t}\n\t\tif (preg_match('/^ {0,3}>/', $line)){\n\t\t\tstatic::mdPara($para, $blocks)\n\t\t\t$body = []\n\t\t\twhile ($i < $count && preg_match('/^ {0,3}>\\s?(.*)$/', $lines[$i], $match)){\n\t\t\t\t$body[] = $match[1]\n\t\t\t\t$i++\n\t\t\t}\n\t\t\t$blocks[] = obj(type: 'quote', text: trim(implode(lf, $body)))\n\t\t\tcontinue\n\t\t}\n\t\tif (str_contains($line, pipe) && preg_match('/^ {0,3}\\|? *:?-+:? *(?:\\| *:?-+:? *)*\\|? *$/', (string)($lines[$i + 1] ?? void))){\n\t\t\tstatic::mdPara($para, $blocks)\n\t\t\t$head = static::mdCells($line)\n\t\t\t$rows = []\n\t\t\t$i += 2\n\t\t\twhile ($i < $count && str_contains($lines[$i], pipe)){\n\t\t\t\t$rows[] = static::mdCells($lines[$i])\n\t\t\t\t$i++\n\t\t\t}\n\t\t\t$blocks[] = obj(type: 'table', head: $head, rows: $rows)\n\t\t\tcontinue\n\t\t}\n\t\tif (($marker = static::mdMarker($line)) !== null){\n\t\t\tstatic::mdPara($para, $blocks)\n\t\t\t$raw = []\n\t\t\twhile ($i < $count){\n\t\t\t\tif (trim($lines[$i]) === void){\n\t\t\t\t\tif (static::mdBreaks(static::mdMarker((string)($lines[$i + 1] ?? void)), $marker)) break\n\t\t\t\t\t$raw[] = void\n\t\t\t\t\t$i++\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\t$here = static::mdMarker($lines[$i])\n\t\t\t\tif ($here === null && !preg_match('/^ {2,}\\S/', $lines[$i])) break\n\t\t\t\tif ($here !== null && static::mdBreaks($here, $marker)) break\n\t\t\t\t$raw[] = $lines[$i]\n\t\t\t\t$i++\n\t\t\t}\n\t\t\t$blocks[] = obj(type: 'list', ordered: $marker->ordered, items: static::mdItems($raw, $marker->indent))\n\t\t\tcontinue\n\t\t}\n\t\t$para[] = $line\n\t\t$i++\n\t}\n\tstatic::mdPara($para, $blocks)\n\treturn $blocks",
                        "line": 238,
                        "bodyLine": 239
                    },
                    "mdMarker": {
                        "node": "static",
                        "visibility": null,
                        "name": "mdMarker",
                        "args": "string $line",
                        "type": "?obj",
                        "operator": "method",
                        "body": "\tif (!preg_match('/^( *)([*+-]|\\d{1,9}\\.)\\s+/', $line, $match)) return null\n\treturn obj(indent: strlen($match[1]), ordered: ctype_digit($match[2][0]))",
                        "line": 324,
                        "bodyLine": 325
                    },
                    "mdBreaks": {
                        "node": "static",
                        "visibility": null,
                        "name": "mdBreaks",
                        "args": "?obj $here, obj $marker",
                        "type": "bool",
                        "operator": "arrow",
                        "body": "$here === null || ($here->indent <= $marker->indent && $here->ordered !== $marker->ordered)",
                        "line": 329,
                        "bodyLine": 329
                    },
                    "mdPara": {
                        "node": "static",
                        "visibility": null,
                        "name": "mdPara",
                        "args": "array &$para, array &$blocks",
                        "type": "void",
                        "operator": "method",
                        "body": "\t$text = trim(implode(lf, $para))\n\t$para = []\n\tif ($text !== void) $blocks[] = obj(type: 'para', text: $text)",
                        "line": 331,
                        "bodyLine": 332
                    },
                    "mdCells": {
                        "node": "static",
                        "visibility": null,
                        "name": "mdCells",
                        "args": "string $line",
                        "type": "array",
                        "operator": "arrow",
                        "body": "array_map('trim', explode(pipe, trim(trim($line), pipe)))",
                        "line": 337,
                        "bodyLine": 337
                    },
                    "mdItems": {
                        "node": "static",
                        "visibility": null,
                        "name": "mdItems",
                        "args": "array $lines, int $indent",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$groups = []\n\t$group = null\n\tforeach ($lines AS $line){\n\t\tif (preg_match('/^( *)([*+-]|\\d{1,9}\\.)\\s+(.*)$/', $line, $match) && strlen($match[1]) <= $indent){\n\t\t\tif ($group !== null) $groups[] = $group\n\t\t\t$group = [$match[3]]\n\t\t\tcontinue\n\t\t}\n\t\tif ($group === null) continue\n\t\t$group[] = preg_replace('/^ {1,'.($indent + 2).'}/', void, $line)\n\t}\n\tif ($group !== null) $groups[] = $group\n\t$items = []\n\tforeach ($groups AS $group){\n\t\t$raw = trim(implode(lf, $group))\n\t\t$checked = null\n\t\tif (preg_match('/^\\[([ xX])\\]\\s*(.*)$/s', $raw, $match)){\n\t\t\t$checked = strtolower($match[1]) === 'x'\n\t\t\t$raw = $match[2]\n\t\t}\n\t\t$sub = static::mdBlocks($raw)\n\t\t$text = void\n\t\tif ($sub && $sub[0]->type === 'para') $text = array_shift($sub)->text\n\t\t$items[] = obj(text: $text, checked: $checked, blocks: $sub)\n\t}\n\treturn $items",
                        "line": 339,
                        "bodyLine": 340
                    },
                    "mdInline": {
                        "node": "static",
                        "visibility": null,
                        "name": "mdInline",
                        "args": "string $text",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$out = void\n\tforeach (preg_split('/(`+[^`]*`+)/', $text, -1, PREG_SPLIT_DELIM_CAPTURE) AS $index => $part){\n\t\tif ($index % 2) $out .= '<code>'.esc(trim($part, bt)).'</code>'\n\t\telse $out .= static::mdFormat($part)\n\t}\n\treturn $out",
                        "line": 368,
                        "bodyLine": 369
                    },
                    "mdFormat": {
                        "node": "static",
                        "visibility": null,
                        "name": "mdFormat",
                        "args": "string $text",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$out = preg_replace('/\\*\\*(.+?)\\*\\*/s', '<strong>$1</strong>', esc($text))\n\t$out = preg_replace('/(?<![*\\w])\\*([^*\\n]+)\\*(?![*\\w])/', '<em>$1</em>', $out)\n\treturn preg_replace('/\\[([^\\]]+)\\]\\(\\s*((?:https?:|mailto:|[\\/#])\\S*?)\\s*\\)/', '<a href=\"$2\">$1</a>', $out)",
                        "line": 377,
                        "bodyLine": 378
                    },
                    "BothGETManual": {
                        "node": "route",
                        "mode": "both",
                        "method": "GET",
                        "path": "manual",
                        "data": null,
                        "operator": "method",
                        "body": "\t%app->title = static::appName()\n\t%app->css = []\n\t%app->js = []\n\t%app->defer = []\n\t$body = %manual->page()\n\t$page = view($body, title: %manual->label('title'), css: [], js: [], inline: true, ns: 'manual')\n\tstatic::store($body, $page)\n\treturn $page",
                        "line": 383,
                        "bodyLine": 384,
                        "name": "BothGETManual"
                    },
                    "page": {
                        "node": "view",
                        "visibility": null,
                        "name": "page",
                        "args": null,
                        "type": null,
                        "operator": "view",
                        "body": "<main.docs>\n\t<header.docs__head>\n\t\t<h1>{{ manual::appName() }}</h1>\n\t\t<p.docs__sub>{{ %manual->label('lead') }} {{ %manual->label('state') }} {{ static::head() ?: %manual->label('unknown') }}.</p>\n\t</header>\n\t<div#docs-summary>{{ %manual->summaryBlock }}</div>\n\t{{ %manual->infoBlock }}\n\t{{ %manual->filesBlock }}\n\t{{ %manual->routesBlock }}\n\t{{ %manual->commitsBlock }}\n</main>",
                        "line": 394
                    },
                    "summaryBlock": {
                        "node": "view",
                        "visibility": null,
                        "name": "summaryBlock",
                        "args": null,
                        "type": null,
                        "operator": "method",
                        "body": "\t$row = static::summary()\n\tif ($row->missing) return indentView(lf.'<section class=\"docs__box docs__box--quiet\"><p>'.esc(%manual->label('noKey')).'</p></section>'.lf)\n\tif ((string)($row->error ?? void) !== void) return indentView(lf.'<section class=\"docs__box docs__box--quiet\"><p>'.esc(%manual->label('noSummary')).esc((string)$row->error).'</p></section>'.lf)\n\treturn indentView(lf.$this->summaryText($row).lf)",
                        "line": 407,
                        "bodyLine": 408
                    },
                    "summaryText": {
                        "node": "view",
                        "visibility": null,
                        "name": "summaryText",
                        "args": "$row",
                        "type": null,
                        "operator": "view",
                        "body": "<section.docs__box>\n\t<h2>{{ %manual->label('summary') }}</h2>\n\t<p.docs__lead>{{ $row->text }}</p>\n</section>",
                        "line": 414
                    },
                    "infoBlock": {
                        "node": "view",
                        "visibility": null,
                        "name": "infoBlock",
                        "args": null,
                        "type": null,
                        "operator": "method",
                        "body": "\t$info = static::appInfo()\n\tif ($info === void) return indentView(lf.'<section class=\"docs__box\"><p>'.esc(%manual->label('noInfo')).'</p></section>'.lf)\n\treturn indentView(lf.$this->infoText(static::mdBlocks(preg_replace('~^#[^\\n]*\\n~', void, $info))).lf)",
                        "line": 420,
                        "bodyLine": 421
                    },
                    "infoText": {
                        "node": "view",
                        "visibility": null,
                        "name": "infoText",
                        "args": "array $blocks",
                        "type": null,
                        "operator": "view",
                        "body": "<details.docs__box open>\n\t<summary.docs__summary>{{ %manual->label('about') }}</summary>\n\t<div.docs__md>\n\t\t<foreach $blocks AS $block>\n\t\t\t{{ %manual->mdBlock($block) }}\n\t\t</foreach>\n\t</div>\n</details>",
                        "line": 426
                    },
                    "mdBlock": {
                        "node": "view",
                        "visibility": null,
                        "name": "mdBlock",
                        "args": "$block",
                        "type": null,
                        "operator": "view",
                        "body": "<if $block->type === 'heading' && $block->depth === 2>\n\t<h2>{{ %manual->mdInline($block->text) }}</h2>\n<elseif $block->type === 'heading' && $block->depth === 3>\n\t<h3>{{ %manual->mdInline($block->text) }}</h3>\n<elseif $block->type === 'heading'>\n\t<h4>{{ %manual->mdInline($block->text) }}</h4>\n<elseif $block->type === 'code'>\n\t<pre.docs__pre>{[ $block->text ]}</pre>\n<elseif $block->type === 'quote'>\n\t<blockquote>{{ %manual->mdInline($block->text) }}</blockquote>\n<elseif $block->type === 'hr'>\n\t<hr>\n<elseif $block->type === 'list'>\n\t{{ %manual->mdList($block) }}\n<elseif $block->type === 'table'>\n\t{{ %manual->mdTable($block) }}\n<else>\n\t<p>{{ %manual->mdInline($block->text) }}</p>\n</if>",
                        "line": 436
                    },
                    "mdList": {
                        "node": "view",
                        "visibility": null,
                        "name": "mdList",
                        "args": "$block",
                        "type": null,
                        "operator": "method",
                        "body": "\t$items = $block->items\n\treturn $block->ordered ? $this->mdOrdered($items) : $this->mdBullets($items)",
                        "line": 457,
                        "bodyLine": 458
                    },
                    "mdBullets": {
                        "node": "view",
                        "visibility": null,
                        "name": "mdBullets",
                        "args": "array $items",
                        "type": null,
                        "operator": "view",
                        "body": "<ul>\n\t<foreach $items AS $item>\n\t\t{{ %manual->mdItem($item) }}\n\t</foreach>\n</ul>",
                        "line": 462
                    },
                    "mdOrdered": {
                        "node": "view",
                        "visibility": null,
                        "name": "mdOrdered",
                        "args": "array $items",
                        "type": null,
                        "operator": "view",
                        "body": "<ol>\n\t<foreach $items AS $item>\n\t\t{{ %manual->mdItem($item) }}\n\t</foreach>\n</ol>",
                        "line": 469
                    },
                    "mdItem": {
                        "node": "view",
                        "visibility": null,
                        "name": "mdItem",
                        "args": "$item",
                        "type": null,
                        "operator": "view",
                        "body": "<if $item->checked === true>\n\t<li.docs__task.docs__task--done>{{ %manual->mdBody($item) }}</li>\n<elseif $item->checked === false>\n\t<li.docs__task>{{ %manual->mdBody($item) }}</li>\n<else>\n\t<li>{{ %manual->mdBody($item) }}</li>\n</if>",
                        "line": 476
                    },
                    "mdBody": {
                        "node": "view",
                        "visibility": null,
                        "name": "mdBody",
                        "args": "$item",
                        "type": null,
                        "operator": "method",
                        "body": "\t$out = static::mdInline((string)$item->text)\n\tforeach ($item->blocks AS $block) $out .= lf.$this->mdBlock($block)\n\treturn $out",
                        "line": 485,
                        "bodyLine": 486
                    },
                    "mdTable": {
                        "node": "view",
                        "visibility": null,
                        "name": "mdTable",
                        "args": "$block",
                        "type": null,
                        "operator": "view",
                        "body": "<table.docs__table>\n\t<thead>\n\t\t<tr>\n\t\t\t<foreach $block->head AS $cell>\n\t\t\t\t<th>{{ %manual->mdInline($cell) }}</th>\n\t\t\t</foreach>\n\t\t</tr>\n\t</thead>\n\t<tbody>\n\t\t<foreach $block->rows AS $row>\n\t\t\t<tr>\n\t\t\t\t<foreach $row AS $cell>\n\t\t\t\t\t<td>{{ %manual->mdInline($cell) }}</td>\n\t\t\t\t</foreach>\n\t\t\t</tr>\n\t\t</foreach>\n\t</tbody>\n</table>",
                        "line": 491
                    },
                    "filesBlock": {
                        "node": "view",
                        "visibility": null,
                        "name": "filesBlock",
                        "args": null,
                        "type": null,
                        "operator": "method",
                        "body": "\t$groups = static::sources()\n\tif (!$groups) return void\n\treturn indentView(lf.$this->filesTable($groups).lf)",
                        "line": 511,
                        "bodyLine": 512
                    },
                    "filesTable": {
                        "node": "view",
                        "visibility": null,
                        "name": "filesTable",
                        "args": "array $groups",
                        "type": null,
                        "operator": "view",
                        "body": "<details.docs__box>\n\t<summary.docs__summary>{{ %manual->label('files') }} ({{ array_sum(array_map('count', $groups)) }})</summary>\n\t<foreach $groups AS $label => $files>\n\t\t<h3>{{ $label }} <span.docs__num>{{ count($files) }}</span></h3>\n\t\t<foreach $files AS $file>\n\t\t\t{{ %manual->fileNodes($file) }}\n\t\t</foreach>\n\t</foreach>\n</details>",
                        "line": 517
                    },
                    "fileNodes": {
                        "node": "view",
                        "visibility": null,
                        "name": "fileNodes",
                        "args": "$file",
                        "type": null,
                        "operator": "view",
                        "body": "<details.docs__file>\n\t<summary.docs__summary>\n\t\t<span.docs__mono>{{ $file->name }}</span>\n\t\t<span.docs__filesum>{{ $file->summary ?: void }}</span>\n\t\t<span.docs__num>{{ count($file->nodes) }}</span>\n\t</summary>\n\t<if $file->advice !== void>\n\t\t<p.docs__meta>{{ $file->advice }}</p>\n\t</if>\n\t<if !$file->nodes>\n\t\t<p.docs__meta>{{ %manual->label('noNodes') }}</p>\n\t</if>\n\t<foreach $file->nodes AS $node>\n\t\t<div.docs__node>\n\t\t\t<span.docs__kind>{{ $node->type }}</span>\n\t\t\t<span.docs__mono>{{ $node->name }}{( $node->args !== void ? '('.$node->args.')' : void )}{( $node->ret !== void ? ': '.$node->ret : void )}</span>\n\t\t\t<if $node->summary !== void>\n\t\t\t\t<p.docs__meta>{{ mb_substr($node->summary, 0, 240) }}</p>\n\t\t\t</if>\n\t\t</div>\n\t</foreach>\n</details>",
                        "line": 528
                    },
                    "routesBlock": {
                        "node": "view",
                        "visibility": null,
                        "name": "routesBlock",
                        "args": null,
                        "type": null,
                        "operator": "method",
                        "body": "\t$groups = static::routeGroups()\n\tif (!$groups) return void\n\treturn indentView(lf.$this->routesTable($groups).lf)",
                        "line": 552,
                        "bodyLine": 553
                    },
                    "routesTable": {
                        "node": "view",
                        "visibility": null,
                        "name": "routesTable",
                        "args": "array $groups",
                        "type": null,
                        "operator": "view",
                        "body": "<details.docs__box>\n\t<summary.docs__summary>{{ %manual->label('routes') }} ({{ array_sum(array_map('count', $groups)) }})</summary>\n\t<foreach $groups AS $file => $routes>\n\t\t<h3>{{ $file }} <span.docs__num>{{ count($routes) }}</span></h3>\n\t\t<foreach $routes AS $route>\n\t\t\t{{ %manual->routeRow($route) }}\n\t\t</foreach>\n\t</foreach>\n</details>",
                        "line": 558
                    },
                    "routeRow": {
                        "node": "view",
                        "visibility": null,
                        "name": "routeRow",
                        "args": "array $route",
                        "type": null,
                        "operator": "method",
                        "body": "\t$whole = (string)($route['route'] ?? void)\n\t$space = strpos($whole, space)\n\t$method = $space === false ? void : substr($whole, 0, $space)\n\t$path = $space === false ? $whole : substr($whole, $space + 1)\n\treturn indentView(lf.$this->routeLine($method, $path, (string)($route['summary'] ?? void)).lf)",
                        "line": 569,
                        "bodyLine": 570
                    },
                    "routeLine": {
                        "node": "view",
                        "visibility": null,
                        "name": "routeLine",
                        "args": "string $method, string $path, string $summary",
                        "type": null,
                        "operator": "view",
                        "body": "<div.docs__route>\n\t<span.docs__origin>{{ $method ?: 'GET' }}</span>\n\t<span.docs__path>/{{ str_replace(space, slash, trim($path)) }}</span>\n\t<if $summary !== void>\n\t\t<p.docs__meta>{{ $summary }}</p>\n\t</if>\n</div>",
                        "line": 577
                    },
                    "commitsBlock": {
                        "node": "view",
                        "visibility": null,
                        "name": "commitsBlock",
                        "args": null,
                        "type": null,
                        "operator": "method",
                        "body": "\t$commits = static::commitList()\n\tif (!$commits) return void\n\treturn indentView(lf.$this->commitsList($commits).lf)",
                        "line": 586,
                        "bodyLine": 587
                    },
                    "commitsList": {
                        "node": "view",
                        "visibility": null,
                        "name": "commitsList",
                        "args": "array $commits",
                        "type": null,
                        "operator": "view",
                        "body": "<details.docs__box>\n\t<summary.docs__summary>{{ %manual->label('changes') }} ({{ count($commits) }})</summary>\n\t<foreach $commits AS $commit>\n\t\t<article.docs__commit>\n\t\t\t<h4>{{ $commit->subject }}</h4>\n\t\t\t<p.docs__meta>\n\t\t\t\t<span.docs__origin>{{ $commit->origin }}</span>\n\t\t\t\t{{ $commit->date }} &middot; <span.docs__mono>{{ $commit->hash }}</span>\n\t\t\t</p>\n\t\t\t<if $commit->body !== void>\n\t\t\t\t<details.docs__more>\n\t\t\t\t\t<summary.docs__summary>{{ %manual->label('why') }}</summary>\n\t\t\t\t\t<pre.docs__pre>{{ $commit->body }}</pre>\n\t\t\t\t</details>\n\t\t\t</if>\n\t\t</article>\n\t</foreach>\n</details>",
                        "line": 592
                    }
                },
                "functions": [],
                "assets": [
                    {
                        "node": "style",
                        "ns": "manual",
                        "line": 612,
                        "body": ".docs {\n\tmax-width: 54rem\n\tmargin: 0 auto\n\tpadding: 2rem 1.2rem 4rem\n\tfont: 16px/1.6 system-ui, sans-serif\n\tcolor: #1d2126\n}\n.docs__head {\n\tmargin-bottom: 1.4rem\n}\n.docs h1 {\n\tmargin: 0 0 .3rem\n\tfont-size: 1.5rem\n}\n.docs__sub {\n\tmargin: 0 0 .9rem\n\tcolor: #68727d\n\tfont-size: .92rem\n}\n.docs__box {\n\tmargin: 0 0 1rem\n\tpadding: 1rem 1.1rem\n\tborder: 1px solid #e2e6ea\n\tborder-radius: 10px\n\tbackground: #fff\n}\n.docs__box--quiet {\n\tbackground: #f6f8f9\n\tcolor: #68727d\n\tfont-size: .92rem\n}\n.docs__box h2 {\n\tmargin: 0 0 .5rem\n\tfont-size: 1.05rem\n}\n.docs__summary {\n\tcursor: pointer\n\tfont-weight: 600\n\tfont-size: .98rem\n\tdisplay: flex\n\talign-items: baseline\n\tgap: .5rem\n\tlist-style: none\n\t\\::-webkit-details-marker: display: none\n}\n.docs__summary::before {\n\tcontent: '\\25b8'\n\tcolor: #9aa4ae\n\tfont-weight: 400\n\ttransition: transform .12s ease\n}\n.docs__file[open] > .docs__summary::before {\n\ttransform: rotate(90deg)\n}\n.docs__lead {\n\tmargin: 0 0 .8rem\n}\n.docs__md h2 {\n\tmargin: 1.1rem 0 .4rem\n\tfont-size: 1rem\n}\n.docs__md p, .docs__md ul {\n\tmargin: 0 0 .7rem\n}\n.docs__task {\n\tlist-style: none\n\tmargin-left: -1.1rem\n}\n.docs__task::before {\n\tcontent: '\\25cb\\a0'\n\tcolor: #68727d\n}\n.docs__task--done::before {\n\tcontent: '\\25cf\\a0'\n\tcolor: #1d2126\n}\n.docs__table {\n\twidth: 100%\n\tborder-collapse: collapse\n\tfont-size: .9rem\n\tmargin: .4rem 0 1rem\n}\n.docs__table th, .docs__table td {\n\tpadding: .28rem .5rem .28rem 0\n\tborder-bottom: 1px solid #eef1f3\n\tvertical-align: top\n}\n.docs__table th {\n\ttext-align: left\n\tcolor: #68727d\n}\n.docs__mono {\n\tfont-family: ui-monospace, monospace\n\tfont-size: .86rem\n\twhite-space: nowrap\n}\n.docs__num {\n\ttext-align: right\n\tcolor: #68727d\n}\n.docs h3, .docs__md h4 {\n\tmargin: 1rem 0 .2rem\n\tfont-size: .9rem\n\tcolor: #68727d\n}\n.docs__file {\n\tmargin: .15rem 0\n\tpadding: .3rem .5rem\n\tborder: 1px solid #eef1f3\n\tborder-radius: 8px\n}\n.docs__filesum {\n\tcolor: #68727d\n\tfont-size: .86rem\n\tfont-weight: 400\n\tmargin-left: auto\n\ttext-align: right\n}\n.docs__node {\n\tpadding: .3rem 0 .3rem .8rem\n\tborder-left: 2px solid #eef1f3\n\tmargin: .3rem 0\n}\n.docs__kind {\n\tdisplay: inline-block\n\tmin-width: 4rem\n\tcolor: #68727d\n\tfont-size: .78rem\n\ttext-transform: uppercase\n}\n.docs__origin {\n\tdisplay: inline-block\n\tmargin-right: .4rem\n\tpadding: .05rem .4rem\n\tborder-radius: 4px\n\tbackground: #eef1f3\n\tcolor: #4a5560\n\tfont-size: .74rem\n\ttext-transform: uppercase\n\tletter-spacing: .02em\n}\n.docs__route {\n\tpadding: .3rem 0\n\tborder-bottom: 1px solid #eef1f3\n}\n.docs__path {\n\tfont-family: ui-monospace, monospace\n\tfont-size: .86rem\n\tword-break: break-word\n}\n.docs__commit {\n\tpadding: .6rem 0\n\tborder-bottom: 1px solid #eef1f3\n}\n.docs__commit h4 {\n\tmargin: 0 0 .15rem\n\tfont-size: .95rem\n}\n.docs__meta {\n\tmargin: 0\n\tcolor: #68727d\n\tfont-size: .84rem\n}\n.docs__pre {\n\tmargin: .4rem 0 0\n\tpadding: .6rem .7rem\n\tborder-radius: 8px\n\tbackground: #f6f8f9\n\tfont-family: ui-monospace, monospace\n\tfont-size: .84rem\n\twhite-space: pre-wrap\n}\n.docs__more {\n\tmargin-top: .3rem\n}\n.docs__btn {\n\tpadding: .35rem .8rem\n\tborder: 1px solid #d6dade\n\tborder-radius: 6px\n\tbackground: #fff\n\tfont-size: .88rem\n\tcursor: pointer\n}"
                    }
                ]
            },
            "payload": {
                "file": "/srv/control/phlo/resources/payload.phlo",
                "class": "payload",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "POST, PUT, PATCH, QUERY and file-upload data object",
                    "advice": "Everything a request carried in its body, whichever way it was sent: JSON, a form, or multipart with files, including PUT and PATCH, which PHP itself leaves for you to unpack. An uploaded file arrives as a file resource rather than a temp path. It is raw input from outside, so read it, never trust it: what you save belongs behind field validation.",
                    "package": "web",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@file",
                    "tags": "payload request upload post put patch query"
                },
                "nodes": {
                    "controller": {
                        "node": "method",
                        "name": "controller",
                        "operator": "method",
                        "body": "$contentType = %req->contentType\nif (in_array(phlo('req')->method, ['POST', 'PUT', 'PATCH', 'QUERY']) && str_starts_with($contentType, 'application/json')){\n\t$data = json_decode((string)file_get_contents('php://input'))\n\treturn $this->objData = is_object($data) ? get_object_vars($data) : (is_array($data) ? $data : [])\n}\nif ($_POST) loop($_POST, fn($value, $key) => $this->$key = $value)\nelseif (in_array(phlo('req')->method, ['PUT', 'PATCH', 'QUERY']) && str_starts_with($contentType, 'application/x-www-form-urlencoded')){\n\t$body = file_get_contents('php://input')\n\t$data = []\n\tparse_str($body, $data)\n\tif ($data) loop($data, fn($value, $key) => $this->$key = $value)\n}\nelseif (in_array(phlo('req')->method, ['PUT', 'PATCH', 'QUERY']) && str_starts_with($contentType, 'multipart/form-data')){\n\t$match = regex('/boundary=\"?([^\";]+)\"?/', $contentType)\n\tif (!$match) return\n\t$boundary = '--'.$match[1]\n\t$arrays = []\n\t$raw = file_get_contents('php://input')\n\tforeach (explode($boundary, $raw) AS $part){\n\t\tif (!trim($part) || $part === '--' || !str_contains($part, nl.nl)) continue\n\t\t$headers = []\n\t\t[$rawHeaders, $body] = explode(nl.nl, $part, 2)\n\t\tforeach (explode(nl, trim($rawHeaders)) AS $header){\n\t\t\tif (str_contains($header, colon)){\n\t\t\t\t[$key, $value] = explode(colon, $header, 2)\n\t\t\t\t$headers[strtolower(trim($key))] = trim($value)\n\t\t\t}\n\t\t}\n\t\tif (!isset($headers['content-disposition'])) continue\n\t\tif (!preg_match('/name=\"([^\"]+)\"/', $headers['content-disposition'], $match)) continue\n\t\t$name = $match[1]\n\t\t$body = rtrim($body, nl)\n\t\tif ($body === void) $body = null\n\t\t$base = $name\n\t\t$keys = []\n\t\t$hasEmptyIndex = false\n\t\tif (preg_match('/^([^\\[]+)((?:\\[[^\\]]*\\])*)$/', $name, $m)){\n\t\t\t$base = $m[1]\n\t\t\t$brackets = $m[2]\n\t\t\tif ($brackets){\n\t\t\t\tpreg_match_all('/\\[([^\\]]*)\\]/', $brackets, $mm)\n\t\t\t\t$keys = $mm[1]\n\t\t\t\t$hasEmptyIndex = in_array(void, $keys, true)\n\t\t\t}\n\t\t}\n\t\tif ($hasEmptyIndex) $arrays[] = $base\n\t\t$assign = function($value) use ($base, $keys){\n\t\t\tif ($keys){\n\t\t\t\tif (!isset($this->objData[$base]) || !is_array($this->objData[$base])) $this->objData[$base] = []\n\t\t\t\t$ref =& $this->objData[$base]\n\t\t\t\t$count = count($keys)\n\t\t\t\tforeach ($keys AS $i => $k){\n\t\t\t\t\t$last = $i === $count - 1\n\t\t\t\t\tif ($k === void){\n\t\t\t\t\t\tif ($last) $ref[] = $value\n\t\t\t\t\t\telse {\n\t\t\t\t\t\t\t$ref[] = []\n\t\t\t\t\t\t\tend($ref)\n\t\t\t\t\t\t\t$idx = key($ref)\n\t\t\t\t\t\t\t$ref =& $ref[$idx]\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\telse {\n\t\t\t\t\t\tif ($last) $ref[$k] = $value\n\t\t\t\t\t\telse {\n\t\t\t\t\t\t\tif (!isset($ref[$k]) || !is_array($ref[$k])) $ref[$k] = []\n\t\t\t\t\t\t\t$ref =& $ref[$k]\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\telse $this->objData[$base] = $value\n\t\t};\n\t\tif (preg_match('/filename=\"([^\"]*)\"/', $headers['content-disposition'], $f)){\n\t\t\tif ($f[1] === void || $body === null){\n\t\t\t\tif (!$hasEmptyIndex) $assign(null)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t$filename = $f[1]\n\t\t\t$file = %file(tempnam(sys_get_temp_dir(), 'phlo'), $filename, $body)\n\t\t\t$assign($file)\n\t\t}\n\t\telse $assign($body)\n\t}\n\tforeach ($this->objData AS $key => $val){\n\t\tif (str_ends_with($key, '[]')){\n\t\t\tunset($this->objData[$key])\n\t\t\t$this->objData[substr($key, 0, -2)] = is_array($val) ? array_values(array_filter($val, fn($v) => $v !== null)) : [$val]\n\t\t}\n\t\telseif (!is_array($val) && substr($key, -2) === '[]') $this->objData[$key] = [$val]\n\t}\n\tforeach (array_unique($arrays) AS $key) if (!isset($this->objData[$key])) $this->objData[$key] = []\n}\nif ($_FILES) loop($_FILES, fn($f, $key) => $this->$key = is_array($f['name']) ? loop(array_keys($f['name']), fn($i) => $f['error'][$i] ? null : %file($f['tmp_name'][$i], $f['name'][$i], mime: $f['type'][$i], size: $f['size'][$i])) : ($f['error'] ? null : %file($f['tmp_name'], $f['name'], mime: $f['type'], size: $f['size'])))",
                        "line": 11
                    }
                },
                "functions": [],
                "assets": []
            },
            "seo": {
                "file": "/srv/control/phlo/resources/seo.phlo",
                "class": "seo",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Multilingual SEO: sitemap.xml, robots.txt, hreflang + head meta (description/OG/Twitter/canonical)",
                    "advice": "Serves sitemap.xml and robots.txt itself and writes the head meta, so a page needs no SEO markup of its own. It reads from the app: pages for the sitemap, slugs for translated URLs, description, image and structuredData for the head, so filling those is the whole job. Every language of a page points at the others with hreflang, and a lastmod resource, when there is one, dates the sitemap.",
                    "package": "seo",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "output",
                    "tags": "seo sitemap robots hreflang opengraph multilingual"
                },
                "nodes": {
                    "GETSitemap": {
                        "node": "route",
                        "mode": null,
                        "method": "GET",
                        "path": "sitemap.xml",
                        "data": null,
                        "operator": "arrow",
                        "body": "output($this)",
                        "line": 11,
                        "bodyLine": 11,
                        "name": "GETSitemap"
                    },
                    "GETRobots": {
                        "node": "route",
                        "mode": null,
                        "method": "GET",
                        "path": "robots.txt",
                        "data": null,
                        "operator": "arrow",
                        "body": "output($this->robots(), type: 'text/plain; charset=utf-8')",
                        "line": 13,
                        "bodyLine": 13,
                        "name": "GETRobots"
                    },
                    "robots": {
                        "node": "method",
                        "visibility": null,
                        "name": "robots",
                        "args": null,
                        "type": "string",
                        "operator": "method",
                        "body": "\tif (!(defined('indexable') && indexable)) return 'User-agent: *'.lf.'Disallow: /'.lf\n\t$lines = ['User-agent: *', 'Allow: /']\n\tforeach ((array)(%app->robotsDisallow ?? []) AS $path) $lines[] = 'Disallow: '.$path\n\t$lines[] = 'Sitemap: '.%req->base.slash.'sitemap.xml'\n\treturn implode(lf, $lines).lf",
                        "line": 15,
                        "bodyLine": 16
                    },
                    "intl": {
                        "node": "method",
                        "visibility": null,
                        "name": "intl",
                        "args": "$uri",
                        "type": "string",
                        "operator": "arrow",
                        "body": "(%app->slugs ?? [])[$uri] ?? $uri",
                        "line": 23,
                        "bodyLine": 23
                    },
                    "uri": {
                        "node": "method",
                        "visibility": null,
                        "name": "uri",
                        "args": "$page",
                        "type": "string",
                        "operator": "arrow",
                        "body": "is_string($page) ? $page : (string)($this->field($page, 'uri') ?? void)",
                        "line": 28,
                        "comments": "A sitemap entry is a uri string, or an object carrying that uri plus more.\nKeeping it a list rather than a uri => value map leaves room for a second\nfact later without changing what an entry means.",
                        "bodyLine": 28
                    },
                    "field": {
                        "node": "method",
                        "visibility": null,
                        "name": "field",
                        "args": "$page, $key",
                        "type": "mixed",
                        "operator": "method",
                        "body": "\tif (is_string($page)) return null\n\treturn is_array($page) ? ($page[$key] ?? null) : ($page->$key ?? null)",
                        "line": 30,
                        "bodyLine": 31
                    },
                    "lastmod": {
                        "node": "method",
                        "visibility": null,
                        "name": "lastmod",
                        "args": "$page",
                        "type": "?string",
                        "operator": "method",
                        "body": "\t$mod = $this->field($page, 'lastmod')\n\t($mod === null || $mod === void) && class_exists('lastmod') && $mod = %lastmod->for($this->uri($page))\n\tif ($mod === null || $mod === void) return null\n\tif (is_string($mod) && preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $mod)) return $mod\n\t$ts = is_int($mod) ? $mod : strtotime((string)$mod)\n\treturn $ts ? date('c', $ts) : null",
                        "line": 40,
                        "comments": "The date for one entry, or nothing at all when it does not parse.\nA wrong lastmod is worse than none: a crawler that catches a site inventing\nthem stops trusting the field domain-wide. An entry without a date of its own\nfalls back to the lastmod resource, so an app that loads it needs no change\nhere for its pages to carry one.",
                        "bodyLine": 41
                    },
                    "locale": {
                        "node": "method",
                        "visibility": null,
                        "name": "locale",
                        "args": null,
                        "type": "string",
                        "operator": "method",
                        "body": "\t$lang = %app->lang ?? 'en'\n\t$map = ['nl' => 'nl_NL', 'en' => 'en_US', 'de' => 'de_DE', 'fr' => 'fr_FR', 'es' => 'es_ES', 'it' => 'it_IT', 'pt' => 'pt_PT', 'pl' => 'pl_PL', 'ru' => 'ru_RU', 'el' => 'el_GR', 'tr' => 'tr_TR', 'zh' => 'zh_CN']\n\treturn $map[$lang] ?? $lang",
                        "line": 49,
                        "bodyLine": 50
                    },
                    "ogTitle": {
                        "node": "prop",
                        "visibility": null,
                        "name": "ogTitle",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "title()",
                        "line": 55,
                        "bodyLine": 55
                    },
                    "ogDescr": {
                        "node": "prop",
                        "visibility": null,
                        "name": "ogDescr",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "%app->description ?? void",
                        "line": 56,
                        "bodyLine": 56
                    },
                    "ogImageFile": {
                        "node": "prop",
                        "visibility": null,
                        "name": "ogImageFile",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "(string)(%app->image ?? (is_file(www.'icon.webp') ? 'icon.webp' : void))",
                        "line": 57,
                        "bodyLine": 57
                    },
                    "ogImage": {
                        "node": "prop",
                        "visibility": null,
                        "name": "ogImage",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "$this->ogImageFile === void ? void : %req->base.slash.ltrim($this->ogImageFile, slash)",
                        "line": 58,
                        "bodyLine": 58
                    },
                    "canonical": {
                        "node": "prop",
                        "visibility": null,
                        "name": "canonical",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "%req->url",
                        "line": 59,
                        "bodyLine": 59
                    },
                    "ogType": {
                        "node": "prop",
                        "visibility": null,
                        "name": "ogType",
                        "args": null,
                        "type": "string",
                        "operator": "value",
                        "body": "'website'",
                        "line": 60,
                        "bodyLine": 60
                    },
                    "sitemapPages": {
                        "node": "prop",
                        "visibility": null,
                        "name": "sitemapPages",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "(array)(%app->pages ?? [''])",
                        "line": 61,
                        "bodyLine": 61
                    },
                    "sitemapLangs": {
                        "node": "prop",
                        "visibility": null,
                        "name": "sitemapLangs",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "array_keys((array)(%app->langs ?? []))",
                        "line": 62,
                        "bodyLine": 62
                    },
                    "siteName": {
                        "node": "prop",
                        "visibility": null,
                        "name": "siteName",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "%app->title ?? id",
                        "line": 63,
                        "bodyLine": 63
                    },
                    "twitterCard": {
                        "node": "prop",
                        "visibility": null,
                        "name": "twitterCard",
                        "args": null,
                        "type": "bool",
                        "operator": "value",
                        "body": "false",
                        "line": 64,
                        "bodyLine": 64
                    },
                    "structuredData": {
                        "node": "prop",
                        "visibility": null,
                        "name": "structuredData",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "%app->structuredData ?? null",
                        "line": 69,
                        "comments": "Schema.org data for this page, as '@type' plus what only your app knows.\nThe url, language, publisher, description and image are filled in from what\nthis resource already resolves for the og tags, so a page cannot describe\nitself two different ways.",
                        "bodyLine": 69
                    },
                    "schemaData": {
                        "node": "method",
                        "visibility": null,
                        "name": "schemaData",
                        "args": null,
                        "type": "?array",
                        "operator": "method",
                        "body": "\tif (!$this->structuredData) return null\n\t$base = [\n\t\t'@context' => 'https://schema.org',\n\t\t'url' => $this->canonical,\n\t\t'inLanguage' => %app->lang ?? 'en',\n\t\t'mainEntityOfPage' => ['@type' => 'WebPage', '@id' => $this->canonical],\n\t\t'publisher' => ['@type' => 'Organization', 'name' => $this->siteName, 'url' => %req->base],\n\t]\n\t$this->ogDescr && $base['description'] = $this->ogDescr\n\t$this->ogImage && $base['image'] = $this->ogImage\n\treturn array_replace($base, (array)$this->structuredData)",
                        "line": 71,
                        "bodyLine": 72
                    },
                    "noIndex": {
                        "node": "prop",
                        "visibility": null,
                        "name": "noIndex",
                        "args": null,
                        "type": "bool",
                        "operator": "arrow",
                        "body": "%app->noLink ?? %app->noIndex ?? false",
                        "line": 84,
                        "bodyLine": 84
                    },
                    "view": {
                        "node": "view",
                        "visibility": null,
                        "name": "view",
                        "args": null,
                        "type": null,
                        "operator": "view",
                        "body": "<?xml version=1.0 encoding=\"UTF-8\"?>\n<urlset xmlns=http://www.sitemaps.org/schemas/sitemap/0.9 xmlns:xhtml=http://www.w3.org/1999/xhtml xmlns:xsi=http://www.w3.org/2001/XMLSchema-instance xsi:schemaLocation=http://www.sitemaps.org/schemas/sitemap/0.9+http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd>\n\t<foreach $this->sitemapPages AS $page>\n\t\t{{ $this->page($page) }}\n\t</foreach>\n</urlset>",
                        "line": 86
                    },
                    "page": {
                        "node": "view",
                        "visibility": null,
                        "name": "page",
                        "args": "$page",
                        "type": null,
                        "operator": "view",
                        "body": "<url>\n\t<loc>%req->base{( ($uri = $this->uri($page)) ?: slash )}</loc>\n\t<if $stamp = $this->lastmod($page)>\n\t\t<lastmod>$stamp</lastmod>\n\t</if>\n\t<foreach $this->sitemapLangs AS $lang>\n\t\t<if $lang === %app->lang>\n\t\t\t{{ $this->xlink('x-default', $uri ?: slash) }}\n\t\t</if>\n\t\t{{ $this->xlink($lang, $lang === %app->lang ? ($uri ?: slash) : \"/$lang\".($this->intl($uri) ?: void)) }}\n\t</foreach>\n</url>",
                        "line": 94
                    },
                    "xlink": {
                        "node": "view",
                        "visibility": null,
                        "name": "xlink",
                        "args": "$lang, $uri",
                        "type": null,
                        "operator": "view",
                        "body": "<xhtml:link rel=alternate hreflang=\"$lang\" href=\"%req->base$uri\"{{ slash }}>",
                        "line": 108
                    },
                    "link": {
                        "node": "view",
                        "visibility": null,
                        "name": "link",
                        "args": "$lang, $uri",
                        "type": null,
                        "operator": "view",
                        "body": "<link rel=alternate hreflang=\"$lang\" href=\"%req->base$uri\">",
                        "line": 109
                    },
                    "head": {
                        "node": "view",
                        "visibility": null,
                        "name": "head",
                        "args": null,
                        "type": null,
                        "operator": "view",
                        "body": "<if $this->noIndex>\n\t<meta name=robots content=noindex,follow>\n</if>\n<if $this->ogDescr>\n\t<meta name=description content=\"$this->ogDescr\">\n</if>\n<meta property=og:site_name content=\"$this->siteName\">\n<meta property=og:title content=\"{[ $this->ogTitle ]}\">\n<meta property=og:description content=\"$this->ogDescr\">\n<meta property=og:type content=\"$this->ogType\">\n<meta property=og:url content=\"{[ $this->canonical ]}\">\n<if $this->ogImage>\n\t<meta property=og:image content=\"$this->ogImage\">\n</if>\n<meta property=og:locale content=\"$this->locale\">\n<if $this->twitterCard>\n\t<meta name=twitter:card content=summary_large_image>\n\t<meta name=twitter:title content=\"{[ $this->ogTitle ]}\">\n\t<meta name=twitter:description content=\"$this->ogDescr\">\n\t<if $this->ogImage>\n\t\t<meta name=twitter:image content=\"$this->ogImage\">\n\t</if>\n</if>\n<if $data = $this->schemaData>\n\t<script type=\"application/ld+json\">{{ json_encode($data, JSON_UNESCAPED_UNICODE) }}</script>\n</if>\n<if !$this->noIndex>\n\t<link rel=canonical href=\"{[ $this->canonical ]}\">\n</if>",
                        "line": 111
                    }
                },
                "functions": [],
                "assets": []
            },
            "session": {
                "file": "/srv/control/phlo/resources/session.phlo",
                "class": "session",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Session data object",
                    "advice": "The session as an object: read a key, write a key, done. Call objRegenerateId() the moment someone logs in or changes role, so a token from before cannot be reused. Sessions are per browser, so anything that must survive a device belongs in a model. Override options to steer the cookie: a flow that returns to you with a cross-site POST, such as an OIDC provider posting its callback, needs cookie_samesite None with cookie_secure true, because a Lax cookie is not sent on that request and the state you stored is then unreadable.",
                    "package": "web",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "session web state"
                },
                "nodes": {
                    "options": {
                        "node": "static",
                        "visibility": null,
                        "name": "options",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[]",
                        "line": 10,
                        "bodyLine": 10
                    },
                    "controller": {
                        "node": "method",
                        "name": "controller",
                        "operator": "method",
                        "body": "session_start(static::options())\n$this->objData = $_SESSION",
                        "line": 12
                    },
                    "__set": {
                        "node": "method",
                        "visibility": null,
                        "name": "__set",
                        "args": "$key, $value",
                        "type": null,
                        "operator": "arrow",
                        "body": "$_SESSION[$key] = $this->objData[$key] = $value",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "__unset": {
                        "node": "method",
                        "visibility": null,
                        "name": "__unset",
                        "args": "$key",
                        "type": null,
                        "operator": "arrow",
                        "body": "unset($this->objData[$key], $_SESSION[$key])",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "__isset": {
                        "node": "method",
                        "visibility": null,
                        "name": "__isset",
                        "args": "$key",
                        "type": "bool",
                        "operator": "arrow",
                        "body": "isset($this->objData[$key])",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "objRegenerateId": {
                        "node": "method",
                        "visibility": null,
                        "name": "objRegenerateId",
                        "args": "$deleteOld = true",
                        "type": "void",
                        "operator": "method",
                        "body": "\tsession_regenerate_id($deleteOld)\n\t$this->objData = $_SESSION",
                        "line": 19,
                        "bodyLine": 20
                    }
                },
                "functions": [],
                "assets": []
            },
            "stream": {
                "file": "/srv/control/phlo/resources/stream.phlo",
                "class": "stream",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Raw data stream beside the JSON command channel: stream() emits text or binary chunks under any content type, app.stream() consumes them via fetch dispatching on the response type",
                    "advice": "The channel beside the command stream, for what is not JSON: an image, a PDF, a zip, a file to download. Give it a content type, and a name when it should arrive as a download rather than be shown. On the page app.stream() picks its handling from the type that came back, so a caller does not have to know in advance what it is getting.",
                    "package": "runtime",
                    "frontend": "true",
                    "backend": "true",
                    "provides": "app.stream",
                    "tags": "stream binary raw data download"
                },
                "nodes": [],
                "functions": {
                    "stream": {
                        "node": "function",
                        "name": "stream",
                        "args": "$data = null, string $type = 'application/octet-stream', ?string $name = null",
                        "type": "void",
                        "operator": "method",
                        "body": "\t$res = %res\n\t$cli = %req->cli\n\t!$res->streaming && $res->done && error('Output already started, invalid stream()')\n\tif (!$res->streaming){\n\t\t$res->streaming = true\n\t\t$res->type = $type\n\t\t$res->header('Cache-Control', 'no-store')\n\t\t$res->header('X-Content-Type-Options', 'nosniff')\n\t\t$res->header('X-Accel-Buffering', 'no')\n\t\t$name === null || $res->header('Content-Disposition', 'attachment; filename=\"'.str_replace('\"', '', $name).'\"')\n\t\t$res->render()\n\t}\n\tif ($data === null) return\n\tforeach (is_iterable($data) ? $data : [$data] as $part){\n\t\tprint((string)$part)\n\t\t$cli || [@ob_flush(), flush()]\n\t}",
                        "line": 11,
                        "bodyLine": 12
                    }
                },
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 31,
                        "body": "const streamFilename = disposition => {\n\tconst extended = disposition.match(/(?:^|;)\\s*filename\\*\\s*=\\s*UTF-8'[^']*'([^;]+)/i)\n\tconst plain = disposition.match(/(?:^|;)\\s*filename\\s*=\\s*(?:\"((?:\\\\.|[^\"])*)\"|([^;]+))/i)\n\tconst value = extended?.[1] ?? plain?.[1] ?? plain?.[2]\n\tif (!value) return null\n\tconst name = value.trim().replace(/\\\\(.)/g, '$1')\n\ttry { return decodeURIComponent(name) }\n\tcatch (_) { return name }\n}\n\napp.stream = async (path, onData = null, async = false, type = null, data = null) => {\n\tconst url = `${location.origin}/${path}`\n\tconst headers = {}\n\tconst csrf = obj('meta[name=\"csrf\"]')?.content\n\tcsrf && (headers['X-CSRF-Token'] = csrf)\n\tasync && (headers['X-Requested-With'] = 'phlo')\n\tlet body = data\n\tif (body !== null && !(body instanceof FormData) && !(body instanceof Blob)) [body = JSON.stringify(body), headers['Content-Type'] = 'application/json']\n\tphlo.log(`⇣ APP.STREAM ${url}`)\n\tconst res = await fetch(url, {method: body === null ? 'GET' : 'POST', credentials: 'same-origin', headers, body})\n\tif (!res.ok) throw new Error(`stream ${url} 🔴 ${res.status}`)\n\tconst mime = (res.headers.get('content-type') ?? '').split(';')[0].trim()\n\tconst kind = type ?? (mime === 'application/x-ndjson' ? 'ndjson' : mime === 'application/json' ? 'json' : mime === 'text/event-stream' ? 'sse' : mime.startsWith('text/') ? 'text' : 'raw')\n\tconst emit = value => onData && onData(value)\n\tif (kind === 'json'){\n\t\tconst value = await res.json()\n\t\temit(value)\n\t\treturn value\n\t}\n\tif (kind === 'blob'){\n\t\tconst blob = await res.blob()\n\t\tconst name = streamFilename(res.headers.get('content-disposition') ?? '')\n\t\tconst value = name ? new File([blob], name) : blob\n\t\temit(value)\n\t\treturn value\n\t}\n\tconst reader = res.body.getReader()\n\tconst decoder = new TextDecoder()\n\tconst out = []\n\tconst parts = []\n\tlet text = ''\n\tlet buffer = ''\n\tconst line = raw => {\n\t\tif (!raw) return\n\t\tconst value = JSON.parse(raw)\n\t\tout.push(value)\n\t\temit(value)\n\t}\n\tconst frame = raw => {\n\t\tif (!raw.trim()) return\n\t\tlet event = 'message'\n\t\tconst data = []\n\t\traw.split('\\n').forEach(l => l.startsWith('event:') ? event = l.slice(6).trim() : l.startsWith('data:') && data.push(l.slice(5).replace(/^ /, '')))\n\t\tconst value = {event, data: data.join('\\n')}\n\t\tout.push(value)\n\t\temit(value)\n\t}\n\twhile (true){\n\t\tconst {done, value} = await reader.read()\n\t\tif (done) break\n\t\tif (kind === 'raw'){\n\t\t\tparts.push(value)\n\t\t\temit(value)\n\t\t\tcontinue\n\t\t}\n\t\tconst piece = decoder.decode(value, {stream: true})\n\t\tif (kind === 'text'){\n\t\t\ttext += piece\n\t\t\temit(piece)\n\t\t\tcontinue\n\t\t}\n\t\tbuffer += piece\n\t\tif (kind === 'sse'){\n\t\t\tconst hold = buffer.endsWith('\\r') ? '\\r' : ''\n\t\t\tbuffer = buffer.slice(0, buffer.length - hold.length).replace(/\\r\\n?/g, '\\n') + hold\n\t\t}\n\t\tconst pieces = buffer.split(kind === 'sse' ? '\\n\\n' : '\\n')\n\t\tbuffer = pieces.pop()\n\t\tpieces.forEach(kind === 'sse' ? frame : line)\n\t}\n\tif (kind === 'raw') return new Blob(parts, {type: mime || 'application/octet-stream'})\n\tconst tail = decoder.decode()\n\tif (kind === 'text'){\n\t\ttail && [text += tail, emit(tail)]\n\t\treturn text\n\t}\n\tbuffer += tail\n\tbuffer && (kind === 'sse' ? frame(buffer.replace(/\\r\\n?/g, '\\n')) : line(buffer))\n\treturn out\n}"
                    }
                ]
            },
            "tasks": {
                "file": "/srv/control/phlo/resources/tasks.phlo",
                "class": "tasks",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Cron runner for %app->tasks. One cron entry per app triggers this every minute.",
                    "advice": "Everything scheduled in one place: %app->tasks holds what runs and when, and one cron entry per app triggers this every minute. A task takes a lock, so a run that lasts longer than its interval does not start over itself. Timing is per minute, so anything finer belongs in a daemon rather than here.",
                    "package": "scheduling",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "cron schedule tasks scheduler"
                },
                "nodes": {
                    "dir": {
                        "node": "static",
                        "visibility": null,
                        "name": "dir",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "data.'tasks/'",
                        "line": 10,
                        "bodyLine": 10
                    },
                    "run": {
                        "node": "static",
                        "visibility": null,
                        "name": "run",
                        "args": null,
                        "type": "void",
                        "operator": "method",
                        "body": "\tis_dir(static::dir()) || mkdir(static::dir(), 0755, true)\n\t$now = time()\n\tforeach (%app->tasks ?? [] AS $name => $task){\n\t\t$task = (object)$task\n\t\tif (!static::due($name, $task, $now)) continue\n\t\tif (!static::lock($name)) continue\n\t\t$schedule = array_intersect_key((array)$task, array_flip(['every', 'daily', 'weekly']))\n\t\t$do = is_string($task->do) ? $task->do : null\n\t\tstatic::saveRun($name, $do, $schedule, static::fire($task->do))\n\t\tstatic::markRun($name, $now)\n\t\tstatic::unlock($name)\n\t}",
                        "line": 12,
                        "bodyLine": 13
                    },
                    "saveRun": {
                        "node": "static",
                        "visibility": null,
                        "name": "saveRun",
                        "args": "$name, $do, $schedule, $return",
                        "type": null,
                        "operator": "arrow",
                        "body": "json_write(static::dir().$name.'.json', arr(do: $do, schedule: $schedule, return: $return))",
                        "line": 27,
                        "bodyLine": 27
                    },
                    "due": {
                        "node": "static",
                        "visibility": null,
                        "name": "due",
                        "args": "$name, $task, $now",
                        "type": "bool",
                        "operator": "method",
                        "body": "\t$last = static::lastRun($name)\n\tif (isset($task->every)){\n\t\t$every = preg_match('/^\\d/', $task->every) ? $task->every : '1 '.$task->every\n\t\t$seconds = strtotime(\"+$every\", 0) ?: 0\n\t\treturn $seconds > 0 && ($now - $last) >= $seconds\n\t}\n\tif (isset($task->daily)){\n\t\tif (date('H:i', $now) !== $task->daily) return false\n\t\treturn $last < strtotime('today 00:00', $now)\n\t}\n\tif (isset($task->weekly)){\n\t\tif (date('D H:i', $now) !== date('D H:i', strtotime($task->weekly, $now))) return false\n\t\treturn $last < strtotime('monday this week', $now)\n\t}\n\treturn false",
                        "line": 33,
                        "comments": "daily and weekly match the exact minute the task names.\nThe runner fires once a minute, so a minute that is missed, by a reboot or by a previous\nrun still holding the lock, means the task does not run that day at all. every counts\nfrom the last run instead, so it catches up by itself.",
                        "bodyLine": 34
                    },
                    "fire": {
                        "node": "static",
                        "visibility": null,
                        "name": "fire",
                        "args": "$do",
                        "type": null,
                        "operator": "method",
                        "body": "\tif ($do instanceof \\Closure) return $do()\n\tif (is_string($do) && str_contains($do, '::')){\n\t\t[$class, $method] = explode('::', $do, 2)\n\t\treturn $class::$method()\n\t}\n\tif (is_string($do)) return phlo($do)\n\terror('Task do must be Closure, \"Class::method\" string, or resource-name string')",
                        "line": 51,
                        "bodyLine": 52
                    },
                    "lastRun": {
                        "node": "static",
                        "visibility": null,
                        "name": "lastRun",
                        "args": "$name",
                        "type": "int",
                        "operator": "method",
                        "body": "\t$file = static::dir().$name.'.last'\n\treturn is_file($file) ? (int)file_get_contents($file) : 0",
                        "line": 61,
                        "bodyLine": 62
                    },
                    "markRun": {
                        "node": "static",
                        "visibility": null,
                        "name": "markRun",
                        "args": "$name, $ts",
                        "type": "int|false",
                        "operator": "arrow",
                        "body": "file_put_contents(static::dir().$name.'.last', (string)$ts, LOCK_EX)",
                        "line": 66,
                        "bodyLine": 66
                    },
                    "lock": {
                        "node": "static",
                        "visibility": null,
                        "name": "lock",
                        "args": "$name",
                        "type": "bool",
                        "operator": "method",
                        "body": "\t$file = static::dir().$name.'.lock'\n\tif (is_file($file) && (time() - filemtime($file)) < 3600) return false\n\ttouch($file)\n\treturn true",
                        "line": 68,
                        "bodyLine": 69
                    },
                    "unlock": {
                        "node": "static",
                        "visibility": null,
                        "name": "unlock",
                        "args": "$name",
                        "type": "bool",
                        "operator": "arrow",
                        "body": "@unlink(static::dir().$name.'.lock')",
                        "line": 75,
                        "bodyLine": 75
                    }
                },
                "functions": [],
                "assets": []
            },
            "useragent": {
                "file": "/srv/control/phlo/resources/useragent.phlo",
                "class": "useragent",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "User agent information",
                    "advice": "Reads operating system, browser and device out of the user agent string. That string is a claim, not a fact: it can be turned off, faked or shortened, so use it for statistics and never as a condition for something that matters. What a browser can do is worth asking the browser itself.",
                    "package": "web",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "useragent browser os device web"
                },
                "nodes": {
                    "source": {
                        "node": "prop",
                        "visibility": null,
                        "name": "source",
                        "args": null,
                        "type": "?string",
                        "operator": "arrow",
                        "body": "%req->userAgent ?: null",
                        "line": 10,
                        "bodyLine": 10
                    },
                    "os": {
                        "node": "prop",
                        "visibility": null,
                        "name": "os",
                        "args": null,
                        "type": "string",
                        "operator": "method",
                        "body": "\tif (!$this->source) return 'Unknown'\n\t$list = [\n\t\t'Android' => '/Android/i',\n\t\t'iPadOS' => '/iPad.*OS/i',\n\t\t'iOS' => '/iPhone|iPod/i',\n\t\t'Windows' => '/Windows NT/i',\n\t\t'macOS' => '/Mac OS X/i',\n\t\t'ChromeOS' => '/CrOS/i',\n\t\t'Linux' => '/Linux/i',\n\t]\n\tforeach ($list AS $n => $r) if (preg_match($r, $this->source)) return $n\n\tif (preg_match('/iPad/i',$this->source) && preg_match('/Mac OS X/i',$this->source)) return 'iPadOS'\n\treturn 'Unknown'",
                        "line": 12,
                        "bodyLine": 13
                    },
                    "osV": {
                        "node": "prop",
                        "visibility": null,
                        "name": "osV",
                        "args": null,
                        "type": "string",
                        "operator": "method",
                        "body": "\tif (!$this->source) return void\n\tif (preg_match('/(?:Android|OS X|OS|Windows NT)\\s*([0-9._]+)/i', $this->source, $m)){\n\t\t$v = strtr($m[1], [us => dot])\n\t\t$v = preg_replace('/[^0-9.].*/', void, $v)\n\t\t$v = preg_replace('/(?:\\.0)+$/', void, $v)\n\t\treturn $v\n\t}\n\treturn void",
                        "line": 28,
                        "bodyLine": 29
                    },
                    "osFull": {
                        "node": "prop",
                        "visibility": null,
                        "name": "osFull",
                        "args": null,
                        "type": "string",
                        "operator": "method",
                        "body": "\tif (!$this->OS) return 'Unknown'\n\tif ($this->OS === 'Windows') return 'Windows'\n\t$v = $this->osV\n\tif (!$v) return $this->OS\n\t$short = preg_replace('/^(\\d+\\.\\d+).*/','$1',$v)\n\tif (preg_match('/\\.0$/',$short)) $short = preg_replace('/\\.0$/', void, $short)\n\treturn trim($this->OS.space.$short)",
                        "line": 39,
                        "bodyLine": 40
                    },
                    "name": {
                        "node": "prop",
                        "visibility": null,
                        "name": "name",
                        "args": null,
                        "type": "string",
                        "operator": "method",
                        "body": "\tif (!$this->source) return 'Unknown'\n\tif (preg_match('/\\bwv\\b/',$this->source) || (preg_match('/Version\\/\\d+\\.\\d+/',$this->source) && strpos($this->source,'Chrome/')!==false && strpos($this->source,'Safari/')!==false && strpos($this->source,' Mobile ')!==false)) return 'Android WebView'\n\tif (preg_match('/CriOS\\/([0-9.]+)/',$this->source)) return 'Chrome'\n\tif (preg_match('/FxiOS\\/([0-9.]+)/',$this->source)) return 'Firefox'\n\t$list = [\n\t\t'Edge' => '/Edg\\/([0-9.]+)/',\n\t\t'Opera' => '/OPR\\/([0-9.]+)/',\n\t\t'Samsung Internet' => '/SamsungBrowser\\/([0-9.]+)/i',\n\t\t'Chrome' => '/Chrome\\/([0-9.]+)/',\n\t\t'Firefox' => '/Firefox\\/([0-9.]+)/',\n\t\t'Safari' => '/Version\\/([0-9.]+).*Safari/i',\n\t]\n\tforeach ($list AS $n => $r) if (preg_match($r, $this->source)) return $n\n\treturn 'Unknown'",
                        "line": 49,
                        "bodyLine": 50
                    },
                    "version": {
                        "node": "prop",
                        "visibility": null,
                        "name": "version",
                        "args": null,
                        "type": "string",
                        "operator": "method",
                        "body": "\tif (!$this->source) return void\n\tif (preg_match('/(?:Edg|OPR|Chrome|Firefox|Version|CriOS|FxiOS|SamsungBrowser)\\/([0-9.]+)/', $this->source, $m)){\n\t\t$v = $m[1]\n\t\t$v = preg_replace('/[^0-9.].*/', void, $v)\n\t\t$v = preg_replace('/(?:\\.0)+$/', void, $v)\n\t\treturn $v\n\t}\n\treturn void",
                        "line": 66,
                        "bodyLine": 67
                    },
                    "full": {
                        "node": "prop",
                        "visibility": null,
                        "name": "full",
                        "args": null,
                        "type": "string",
                        "operator": "method",
                        "body": "\tif (!$this->name) return 'Unknown'\n\t$v = $this->version\n\tif (!$v) return $this->name\n\t$short = preg_replace('/^(\\d+\\.\\d+).*/','$1',$v)\n\tif (preg_match('/\\.0$/',$short)) $short = preg_replace('/\\.0$/', void, $short)\n\treturn rtrim($this->name.space.$short)",
                        "line": 77,
                        "bodyLine": 78
                    },
                    "device": {
                        "node": "prop",
                        "visibility": null,
                        "name": "device",
                        "args": null,
                        "type": "string",
                        "operator": "method",
                        "body": "\tif (!$this->source) return 'Unknown'\n\tif (preg_match('/iPad|Tablet|Tab|SM-T|Nexus 7|Nexus 10/i', $this->source)) return 'Tablet'\n\tif (preg_match('/Mobile|iPhone|Android.*Mobile|SM-G|Pixel [0-9]/i', $this->source)) return 'Phone'\n\treturn 'Desktop'",
                        "line": 86,
                        "bodyLine": 87
                    }
                },
                "functions": [],
                "assets": []
            },
            "visitors": {
                "file": "/srv/control/phlo/resources/visitors.phlo",
                "class": "visitors",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Visitor tracking via heartbeat",
                    "advice": "Counts visitors from the page itself with a heartbeat, so time on page and who is online now are real rather than guessed from page loads. It keeps a token instead of an IP for recognition, and bots are filtered out before anything is written. A heartbeat every few seconds is a write per visitor, so watch the table on a busy site and prune it.",
                    "extends": "model",
                    "package": "analytics",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@payload @model token useragent",
                    "tags": "visitors analytics heartbeat tracking"
                },
                "nodes": {
                    "table": {
                        "node": "static",
                        "visibility": null,
                        "name": "table",
                        "args": null,
                        "type": "string",
                        "operator": "value",
                        "body": "'visitors'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "columns": {
                        "node": "static",
                        "visibility": null,
                        "name": "columns",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'id,token,host,page,lang,IP,browser,os,device,active_seconds,state,width,height,referrer,created,changed'",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "history": {
                        "node": "static",
                        "visibility": null,
                        "name": "history",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "static::records(columns: 'FROM_UNIXTIME(changed, \"%Y-%m-%d\") AS date,COUNT(DISTINCT token) AS visitors,COUNT(id) AS visits', group: 'date', order: 'date DESC')",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "online": {
                        "node": "static",
                        "visibility": null,
                        "name": "online",
                        "args": null,
                        "type": "int",
                        "operator": "arrow",
                        "body": "static::item(columns: 'COUNT(DISTINCT token)', where: 'changed >= (UNIX_TIMESTAMP() - 9)')",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "lastHour": {
                        "node": "static",
                        "visibility": null,
                        "name": "lastHour",
                        "args": null,
                        "type": "int",
                        "operator": "arrow",
                        "body": "static::item(columns: 'COUNT(DISTINCT token)', where: 'changed >= (UNIX_TIMESTAMP() - 3600)')",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "isBot": {
                        "node": "static",
                        "visibility": null,
                        "name": "isBot",
                        "args": "?string $ua",
                        "type": "bool",
                        "operator": "method",
                        "body": "\tif (!$ua) return false\n\treturn (bool)preg_match('/bot|crawl|spider|slurp|baiduspider|facebookexternalhit|twitterbot|linkedinbot|curl|wget|python-requests|go-http-client|java\\//i', $ua)",
                        "line": 19,
                        "bodyLine": 20
                    },
                    "parseReferrer": {
                        "node": "static",
                        "visibility": null,
                        "name": "parseReferrer",
                        "args": "string $url",
                        "type": "string",
                        "operator": "method",
                        "body": "\tstatic $engines = ['google' => 'Google', 'bing' => 'Bing', 'duckduckgo' => 'DuckDuckGo', 'yahoo' => 'Yahoo', 'baidu' => 'Baidu', 'yandex' => 'Yandex', 'ecosia' => 'Ecosia', 'startpage' => 'Startpage', 'brave' => 'Brave', 'kagi' => 'Kagi']\n\t$host = strtolower(preg_replace('/^www\\./', void, (string)(parse_url($url, PHP_URL_HOST) ?? void)))\n\tforeach ($engines AS $key => $name) if (str_contains($host, $key)) return 'search:'.$name\n\treturn $host ?: substr($url, 0, 100)",
                        "line": 24,
                        "bodyLine": 25
                    },
                    "PUTHeartbeatNVLUWHAPRCSPpPs": {
                        "node": "route",
                        "mode": null,
                        "method": "PUT",
                        "path": "heartbeat",
                        "data": "n,v,l,u,w,h,a,p,r,c,s,pp,ps",
                        "operator": "method",
                        "body": "\tif (static::isBot(%useragent->source)) return\n\t$consent = (bool)%payload->c\n\t$n = strlen(%payload->n) === 8 ? %payload->n : date('Ymd')\n\t$id = $consent ? token(20, $n.space.%cookies->token.space.%useragent->source) : token(20, $n.space.date('Ymd').space.%cookies->token.space.%req->ip)\n\t$delta = max(0, min(120, (int)%payload->s))\n\t$prevDelta = max(0, min(120, (int)%payload->ps))\n\t$lang = strlen(%payload->l) === 2 ? %payload->l : %app->lang ?? 'en'\n\t$referrer = ($r = (string)%payload->r) && !str_contains($r, host) ? static::parseReferrer($r) : null\n\tstatic::DB()->query('INSERT INTO '.static::$table.' (id, token, host, page, lang, IP, browser, os, device, active_seconds, state, width, height, referrer, created, changed) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE token = VALUES(token), host = VALUES(host), page = VALUES(page), lang = VALUES(lang), IP = VALUES(IP), browser = VALUES(browser), os = VALUES(os), device = VALUES(device), active_seconds = active_seconds + VALUES(active_seconds), state = VALUES(state), width = VALUES(width), height = VALUES(height), referrer = IF(referrer IS NULL OR referrer = '.sq.sq.', VALUES(referrer), referrer), changed = VALUES(changed)', $id, token(20, (string)(%cookies->token ?? %payload->n)), host, %payload->u, $lang, $consent ? %req->ip : void, $consent ? %useragent->full.(%payload->a ? ' App' : void) : substr(md5((string)%useragent->source), 0, 8), $consent ? %useragent->osFull : void, $consent ? %useragent->device : void, $delta + $prevDelta, %payload->v, %payload->w, %payload->h, $referrer, time(), time())\n\t$pages = preg_replace('/visitors$/', 'visitor_pages', (string)static::$table)\n\tif (strlen((string)%payload->p) >= 8){\n\t\t$pv = token(20, $id.space.%payload->p)\n\t\tstatic::DB()->query('INSERT INTO '.$pages.' (id, visitor, host, page, lang, active_seconds, beats, created, changed) VALUES (?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE active_seconds = active_seconds + VALUES(active_seconds), beats = beats + 1, changed = VALUES(changed)', $pv, $id, host, %payload->u, $lang, $delta, 1, time(), time())\n\t}\n\tif (strlen((string)%payload->pp) >= 8){\n\t\t$ppv = token(20, $id.space.%payload->pp)\n\t\tstatic::DB()->query('UPDATE '.$pages.' SET active_seconds = active_seconds + ?, beats = beats + 1, changed = ? WHERE id = ?', $prevDelta, time(), $ppv)\n\t}",
                        "line": 31,
                        "bodyLine": 32,
                        "name": "PUTHeartbeatNVLUWHAPRCSPpPs"
                    }
                },
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 52,
                        "body": "let curpath = app.path\nlet pv = phlo.token(12)\nlet hbTimer, activeMs = 0, activeAt = null, prev = null\nconst visible = () => document.visibilityState === 'visible'\nconst accrue = () => {\n\tif (activeAt === null) return\n\tactiveMs += performance.now() - activeAt\n\tactiveAt = visible() ? performance.now() : null\n}\nconst flush = beacon => {\n\taccrue()\n\tconst consent = document.cookie.includes('cookieChoice=all')\n\twindow.name ||= phlo.token(8)\n\tconst s = Math.floor(activeMs / 1000)\n\tactiveMs -= s * 1000\n\tconst body = JSON.stringify({n: window.name, v: app.state, l: obj('html').lang ?? 'en', u: app.path, w: innerWidth, h: innerHeight, a: app.mode, p: pv, r: (r = document.referrer) ? (r === `${location.origin}/` ? null : r) : null, c: consent ? 1 : 0, s, pp: prev ? prev.p : null, ps: prev ? prev.s : null})\n\tprev = null\n\tfetch('/heartbeat', {method: 'PUT', headers: {'Content-Type': 'application/json'}, body, keepalive: !!beacon})\n}\nconst heartbeat = () => delay('heartbeat', 333, () => {\n\tclearTimeout(hbTimer)\n\tflush()\n\tif (visible()) hbTimer = setTimeout(heartbeat, 20000)\n})\ndocument.addEventListener('visibilitychange', () => {\n\tif (!visible()) return flush(true)\n\tactiveAt = performance.now()\n\theartbeat()\n})\naddEventListener('resize', heartbeat)\naddEventListener('pagehide', () => flush(true))\napp.updates.push(() => {\n\tif (curpath === app.path) return\n\tif (prev) flush()\n\taccrue()\n\tprev = {p: pv, s: Math.floor(activeMs / 1000)}\n\tpv = phlo.token(12)\n\tactiveMs = 0\n\tcurpath = app.path\n\theartbeat()\n})\nif (visible()) activeAt = performance.now()\nheartbeat()"
                    }
                ]
            },
            "websocket": {
                "file": "/srv/control/phlo/resources/websocket.phlo",
                "class": "websocket",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Server-side WebSocket handler via phloWS",
                    "advice": "Enable this class only when websockets are configured for the host",
                    "package": "realtime",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "websocket realtime ws server"
                },
                "nodes": {
                    "connect": {
                        "node": "static",
                        "visibility": null,
                        "name": "connect",
                        "args": "$wsHost, $wsToken, $wsSocket",
                        "type": "bool",
                        "operator": "arrow",
                        "body": "!function_exists('wsConnect') || wsConnect($wsHost, $wsToken, $wsSocket)",
                        "line": 10,
                        "bodyLine": 10
                    },
                    "auth": {
                        "node": "static",
                        "visibility": null,
                        "name": "auth",
                        "args": "$wsHost, $wsToken, $wsSocket",
                        "type": "bool",
                        "operator": "arrow",
                        "body": "!function_exists('wsAuth') || wsAuth($wsHost, $wsToken, $wsSocket)",
                        "line": 11,
                        "bodyLine": 11
                    },
                    "receive": {
                        "node": "static",
                        "visibility": null,
                        "name": "receive",
                        "args": "$wsHost, $wsToken, $wsSocket, $data",
                        "type": "bool",
                        "operator": "arrow",
                        "body": "function_exists('wsReceive') && wsReceive($wsHost, $wsToken, $wsSocket, ...json_decode($data, true))",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "close": {
                        "node": "static",
                        "visibility": null,
                        "name": "close",
                        "args": "$wsHost, $wsToken, $wsSocket",
                        "type": "bool",
                        "operator": "arrow",
                        "body": "function_exists('wsClose') && wsClose($wsHost, $wsToken, $wsSocket)",
                        "line": 13,
                        "bodyLine": 13
                    }
                },
                "functions": [],
                "assets": []
            },
            "WhatsApp": {
                "file": "/srv/control/phlo/resources/WhatsApp.phlo",
                "class": "WhatsApp",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "WhatsApp client for phloWA using whatsapp-web.js",
                    "advice": "Speaks to a phloWA instance, which holds the actual WhatsApp session, so this resource is a client and not a connection: without that service running, nothing is sent. A contact is a full WhatsApp address, and one ending in @g is a group, which number() and isGroup() sort out. It is an unofficial route, so treat volume and content with the care an account you cannot replace deserves.",
                    "package": "messaging",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "HTTP",
                    "tags": "whatsapp messaging api"
                },
                "nodes": {
                    "__construct": {
                        "node": "method",
                        "visibility": null,
                        "name": "__construct",
                        "args": "public string $url, public string $secret",
                        "type": null,
                        "operator": "arrow",
                        "body": "$this->url = rtrim($url, slash).slash",
                        "line": 11,
                        "bodyLine": 11
                    },
                    "channel": {
                        "node": "static",
                        "visibility": null,
                        "name": "channel",
                        "args": "$channel",
                        "type": "static",
                        "operator": "arrow",
                        "body": "new static($channel->configData->url ?? 'http://localhost:8081', $channel->secretData->secret ?? void)",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "number": {
                        "node": "method",
                        "visibility": null,
                        "name": "number",
                        "args": "$contact",
                        "type": "string",
                        "operator": "arrow",
                        "body": "($pos = strpos($contact, '@')) ? substr($contact, 0, $pos) : error('Invalid contact: '.esc($contact))",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "isGroup": {
                        "node": "method",
                        "visibility": null,
                        "name": "isGroup",
                        "args": "$contact",
                        "type": "bool",
                        "operator": "arrow",
                        "body": "last($this->number($contact), (bool)strpos($contact, '@g'))",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "status": {
                        "node": "method",
                        "visibility": null,
                        "name": "status",
                        "args": null,
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('status', GET: true)",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "health": {
                        "node": "method",
                        "visibility": null,
                        "name": "health",
                        "args": null,
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('health', GET: true)",
                        "line": 19,
                        "bodyLine": 19
                    },
                    "qr": {
                        "node": "method",
                        "visibility": null,
                        "name": "qr",
                        "args": null,
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('qr', GET: true)",
                        "line": 20,
                        "bodyLine": 20
                    },
                    "disconnect": {
                        "node": "method",
                        "visibility": null,
                        "name": "disconnect",
                        "args": null,
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('disconnect')",
                        "line": 21,
                        "bodyLine": 21
                    },
                    "read": {
                        "node": "method",
                        "visibility": null,
                        "name": "read",
                        "args": "$chat",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('read', chat: $chat)",
                        "line": 23,
                        "bodyLine": 23
                    },
                    "reaction": {
                        "node": "method",
                        "visibility": null,
                        "name": "reaction",
                        "args": "$msg, $emoji",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('reaction', msg: $msg, emoji: $emoji)",
                        "line": 24,
                        "bodyLine": 24
                    },
                    "text": {
                        "node": "method",
                        "visibility": null,
                        "name": "text",
                        "args": "$to, $text",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('text', to: $to, text: $text)",
                        "line": 26,
                        "bodyLine": 26
                    },
                    "image": {
                        "node": "method",
                        "visibility": null,
                        "name": "image",
                        "args": "$to, file $file, $text = void",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('image', to: $to, filename: $file->name, image: $file->src, text: $text)",
                        "line": 27,
                        "bodyLine": 27
                    },
                    "location": {
                        "node": "method",
                        "visibility": null,
                        "name": "location",
                        "args": "$to, $lat, $lon, $text",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('location', to: $to, lat: $lat, lon: $lon, text: $text)",
                        "line": 28,
                        "bodyLine": 28
                    },
                    "document": {
                        "node": "method",
                        "visibility": null,
                        "name": "document",
                        "args": "$to, file $file, $text = void",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('document', to: $to, filename: $file->name, document: $file->src, text: $text)",
                        "line": 29,
                        "bodyLine": 29
                    },
                    "audio": {
                        "node": "method",
                        "visibility": null,
                        "name": "audio",
                        "args": "$to, file $file",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('audio', to: $to, audio: $file->src)",
                        "line": 31,
                        "bodyLine": 31
                    },
                    "voice": {
                        "node": "method",
                        "visibility": null,
                        "name": "voice",
                        "args": "$to, file $file",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('voice', to: $to, audio: $file->src)",
                        "line": 32,
                        "bodyLine": 32
                    },
                    "poll": {
                        "node": "method",
                        "visibility": null,
                        "name": "poll",
                        "args": "$to, $name, array $options, bool $multi = false",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('poll', to: $to, name: $name, options: $options, multi: $multi)",
                        "line": 34,
                        "bodyLine": 34
                    },
                    "startTyping": {
                        "node": "method",
                        "visibility": null,
                        "name": "startTyping",
                        "args": "$to",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('typing/start', to: $to)",
                        "line": 36,
                        "bodyLine": 36
                    },
                    "stopTyping": {
                        "node": "method",
                        "visibility": null,
                        "name": "stopTyping",
                        "args": "$to",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('typing/stop', to: $to)",
                        "line": 37,
                        "bodyLine": 37
                    },
                    "request": {
                        "node": "method",
                        "visibility": null,
                        "name": "request",
                        "args": "$action, ...$data",
                        "type": "obj",
                        "operator": "method",
                        "body": "\t$get = $data['GET'] ?? false\n\tunset($data['GET'])\n\t$raw = trim((string)HTTP($this->url.$action, ['secret: '.$this->secret], true, $get ? null : $data))\n\tif (strtolower($raw) === 'ok') return obj(ok: true)\n\t$res = json_decode($raw)\n\tif (!$res && $raw) return obj(ok: false, error: $raw)\n\treturn $res ?: obj(ok: false, error: 'Empty WhatsApp response')",
                        "line": 39,
                        "bodyLine": 40
                    }
                },
                "functions": [],
                "assets": []
            }
        }
    },
    "AI": {
        "objs": {
            "AI": {
                "file": "/srv/control/phlo/resources/AI/AI.phlo",
                "class": "AI",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Unified AI facade with engine auto-detect",
                    "advice": "One door for every engine: %AI->chat reads the engine from the model name, so gpt goes to OpenAI, claude to Claude and gemini to Gemini, while via names one outright. Every engine answers in the same shape, with answer, model, finish and a token count, so swapping models is a one-word change. A model no rule matches falls back to OpenAI, and each engine still needs credentials of its own.",
                    "package": "ai",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "HTTP @OpenAI? @Claude? @Gemini? @DeepSeek? @Grok?",
                    "tags": "ai facade llm streaming tools embeddings"
                },
                "nodes": {
                    "model": {
                        "node": "prop",
                        "visibility": null,
                        "name": "model",
                        "args": null,
                        "type": "string",
                        "operator": "value",
                        "body": "'gpt-5.4-mini'",
                        "line": 11,
                        "bodyLine": 11
                    },
                    "engines": {
                        "node": "const",
                        "visibility": null,
                        "name": "engines",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "['claude' => 'Claude', 'gpt' => 'OpenAI', 'chatgpt' => 'OpenAI', 'o1' => 'OpenAI', 'o3' => 'OpenAI', 'o4' => 'OpenAI', 'deepseek' => 'DeepSeek', 'gemini' => 'Gemini', 'grok' => 'Grok']",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "http": {
                        "node": "static",
                        "visibility": null,
                        "name": "http",
                        "args": "string $url, array $headers, bool $json = true, mixed $post = null",
                        "type": "string",
                        "operator": "arrow",
                        "body": "HTTP($url, $headers, $json, $post, timeout: 300)",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "resolve": {
                        "node": "method",
                        "visibility": null,
                        "name": "resolve",
                        "args": "...$args",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$via = $args['via'] ?? void\n\tunset($args['via'])\n\t$args['model'] ??= $this->model\n\tif ($via) $via = static::engines[strtolower($via)] ?? $via\n\telseif (isset($args['model'])) $via = static::engines[strtolower(explode(dash, $args['model'])[0])] ?? void\n\treturn [$via ?: 'OpenAI', $args]",
                        "line": 14,
                        "bodyLine": 15
                    },
                    "chat": {
                        "node": "method",
                        "visibility": null,
                        "name": "chat",
                        "args": "...$args",
                        "type": "obj",
                        "operator": "method",
                        "body": "\t[$engine, $args] = $this->resolve(...$args)\n\treturn phlo($engine)->chat(...$args)",
                        "line": 22,
                        "bodyLine": 23
                    },
                    "stream": {
                        "node": "method",
                        "visibility": null,
                        "name": "stream",
                        "args": "...$args",
                        "type": "Generator",
                        "operator": "method",
                        "body": "\t[$engine, $args] = $this->resolve(...$args)\n\treturn phlo($engine)->stream(...$args)",
                        "line": 26,
                        "bodyLine": 27
                    },
                    "embedding": {
                        "node": "method",
                        "visibility": null,
                        "name": "embedding",
                        "args": "...$args",
                        "type": "array",
                        "operator": "method",
                        "body": "\t[$engine, $args] = $this->resolve(...$args)\n\treturn phlo($engine)->embedding(...$args)",
                        "line": 30,
                        "bodyLine": 31
                    },
                    "vision": {
                        "node": "method",
                        "visibility": null,
                        "name": "vision",
                        "args": "...$args",
                        "type": "obj|Generator",
                        "operator": "method",
                        "body": "\t[$engine, $args] = $this->resolve(...$args)\n\treturn phlo($engine)->vision(...$args)",
                        "line": 34,
                        "bodyLine": 35
                    },
                    "transcribe": {
                        "node": "method",
                        "visibility": null,
                        "name": "transcribe",
                        "args": "...$args",
                        "type": "obj",
                        "operator": "method",
                        "body": "\t[$engine, $args] = $this->resolve(...$args)\n\treturn phlo($engine)->transcribe(...$args)",
                        "line": 38,
                        "bodyLine": 39
                    }
                },
                "functions": [],
                "assets": []
            },
            "Claude": {
                "file": "/srv/control/phlo/resources/AI/Claude.phlo",
                "class": "Claude",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Anthropic Claude API",
                    "advice": "Anthropic has no embedding endpoint, so embedding() quietly goes out through OpenAI and needs that key too. system travels as its own field here rather than as a first message, which context() settles, so the same call works on either engine. vision() fetches an image URL itself and sends it inline as base64, so a large photo becomes a large request.",
                    "package": "ai",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "creds:Claude @AI",
                    "tags": "ai claude anthropic chat vision embeddings"
                },
                "nodes": {
                    "model": {
                        "node": "const",
                        "visibility": null,
                        "name": "model",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'claude-opus-4-8'",
                        "line": 11,
                        "bodyLine": 11
                    },
                    "context": {
                        "node": "static",
                        "visibility": null,
                        "name": "context",
                        "args": "...$args",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$args['messages'] ??= []\n\tif (isset($args['system'])){\n\t\t$args['system'] = [['type' => 'text', 'text' => $args['system']]]\n\t}\n\tif (isset($args['assistant']) && array_push($args['messages'], ['role' => 'assistant', 'content' => $args['assistant']])) unset($args['assistant'])\n\tif (isset($args['user']) && array_push($args['messages'], ['role' => 'user', 'content' => $args['user']])) unset($args['user'])\n\treturn $args",
                        "line": 13,
                        "bodyLine": 14
                    },
                    "tool": {
                        "node": "static",
                        "visibility": null,
                        "name": "tool",
                        "args": "$tool",
                        "type": "array",
                        "operator": "arrow",
                        "body": "[\n\t'name' => $tool->name,\n\t'description' => $tool->desc,\n\t'input_schema' => [\n\t\t'type' => 'object',\n\t\t'properties' => loop($tool->args, fn($data, $arg) => array_filter($data, fn($key) => in_array($key, ['type', 'enum', 'description']), ARRAY_FILTER_USE_KEY)),\n\t\t'required' => array_keys($tool->args),\n\t],\n]",
                        "line": 23,
                        "bodyLine": 23
                    },
                    "embedding": {
                        "node": "method",
                        "visibility": null,
                        "name": "embedding",
                        "args": "$input, $model = 'text-embedding-3-small'",
                        "type": "array",
                        "operator": "arrow",
                        "body": "%OpenAI->embedding($input, $model)",
                        "line": 33,
                        "bodyLine": 33
                    },
                    "vision": {
                        "node": "method",
                        "visibility": null,
                        "name": "vision",
                        "args": "$text, $image, $stream = false, ...$args",
                        "type": "obj|Generator",
                        "operator": "method",
                        "body": "\t$data = is_string($image) && str_starts_with($image, 'http') ? file_get_contents($image) : $image\n\t$messages = [['role' => 'user', 'content' => [['type' => 'text', 'text' => $text], ['type' => 'image', 'source' => ['type' => 'base64', 'media_type' => 'image/jpeg', 'data' => base64_encode($data)]]]]]\n\tif ($stream) return $this->stream(...$args, messages: $messages)\n\telse return $this->chat(...$args, messages: $messages)",
                        "line": 35,
                        "bodyLine": 36
                    },
                    "chat": {
                        "node": "method",
                        "visibility": null,
                        "name": "chat",
                        "args": "...$args",
                        "type": "obj",
                        "operator": "method",
                        "body": "\t$args['model'] ??= static::model\n\t$args['max_tokens'] ??= 4096\n\t$token = $args['token'] ?? null\n\tunset($args['token'])\n\t$args = static::context(...$args)\n\t$res = $this->request('messages', token: $token, POST: $args)\n\t$return = new obj(answer: void, model: $res->model, finish: $res->stop_reason, tokens: ($res->usage->input_tokens ?? 0) + ($res->usage->output_tokens ?? 0), tokens_in: $res->usage->input_tokens ?? 0, tokens_out: $res->usage->output_tokens ?? 0)\n\t$tools = []\n\tforeach ($res->content AS $block){\n\t\tif ($block->type === 'text') $return->answer = $block->text\n\t\telseif ($block->type === 'tool_use') $tools[] = new obj(name: $block->name, args: (array)$block->input)\n\t}\n\tif ($tools) $return->tools = $tools\n\treturn $return",
                        "line": 42,
                        "bodyLine": 43
                    },
                    "parseSSE": {
                        "node": "method",
                        "visibility": null,
                        "name": "parseSSE",
                        "args": "string $url, array $headers, array $payload",
                        "type": "Generator",
                        "operator": "method",
                        "body": "\t$json = json_encode($payload, jsonFlat)\n\t$ctx = stream_context_create(['http' => ['method' => 'POST', 'header' => implode(nl, [...$headers, 'Content-Type: application/json', 'Content-Length: '.strlen($json)]), 'content' => $json, 'timeout' => 300, 'ignore_errors' => true]])\n\t$stream = fopen($url, 'r', false, $ctx)\n\t$stream || error('SSE connection failed')\n\t$status = (int)explode(space, $http_response_header[0] ?? 'HTTP/1.1 200')[1]\n\tif ($status >= 400){\n\t\t$err = json_decode((string)stream_get_contents($stream))\n\t\tfclose($stream)\n\t\terror('Claude error '.$status.': '.($err->error->message ?? 'unknown'))\n\t}\n\t$finish = null\n\t$usage = null\n\twhile (!feof($stream)){\n\t\t$line = fgets($stream)\n\t\tif ($line === false) break\n\t\t$line = rtrim($line, nl)\n\t\tif (!str_starts_with($line, 'data:')) continue\n\t\t$data = ltrim(substr($line, 5))\n\t\tif ($data === void) continue\n\t\t$p = json_decode($data)\n\t\tif (!$p) continue\n\t\tif (isset($p->error)) error('Claude stream error: '.$p->error->message)\n\t\tif (($p->type ?? null) === 'message_delta'){\n\t\t\t$finish = $p->delta->stop_reason ?? $finish\n\t\t\tif (isset($p->usage)) $usage = $p->usage\n\t\t}\n\t\tif (($p->type ?? null) === 'content_block_delta' && ($p->delta->type ?? null) === 'text_delta' && isset($p->delta->text)) yield obj(text: $p->delta->text)\n\t}\n\tfclose($stream)\n\tyield obj(done: true, finish: $finish, tokens_in: $usage?->input_tokens, tokens_out: $usage?->output_tokens)",
                        "line": 59,
                        "bodyLine": 60
                    },
                    "stream": {
                        "node": "method",
                        "visibility": null,
                        "name": "stream",
                        "args": "...$args",
                        "type": "Generator",
                        "operator": "method",
                        "body": "\t%res->streaming = true\n\t$args['model'] ??= static::model\n\t$args['max_tokens'] ??= 4096\n\t$token = $args['token'] ?? null\n\tunset($args['token'], $args['cb'])\n\t$args = static::context(...$args)\n\t$args['stream'] = true\n\tif ($token) $headers = ['anthropic-version: 2023-06-01', 'anthropic-beta: oauth-2025-04-20', 'Authorization: Bearer '.$token]\n\telse $headers = ['anthropic-version: 2023-06-01', 'x-api-key: '.%creds->Claude]\n\treturn $this->parseSSE('https://api.anthropic.com/v1/messages', $headers, $args)",
                        "line": 91,
                        "bodyLine": 92
                    },
                    "request": {
                        "node": "method",
                        "visibility": null,
                        "name": "request",
                        "args": "$uri, ...$args",
                        "type": null,
                        "operator": "method",
                        "body": "\t$token = $args['token'] ?? null\n\tif ($token) $headers = ['anthropic-version: 2023-06-01', 'anthropic-beta: oauth-2025-04-20', 'Authorization: Bearer '.$token]\n\telse $headers = ['anthropic-version: 2023-06-01', 'x-api-key: '.%creds->Claude]\n\t$res = json_decode(AI::http(\"https://api.anthropic.com/v1/$uri\", $headers, true, $args['POST'] ?? null))\n\tif (isset($res->error)) error('Claude Request error: '.$res->error->message)\n\treturn $res",
                        "line": 104,
                        "bodyLine": 105
                    }
                },
                "functions": [],
                "assets": []
            },
            "DeepSeek": {
                "file": "/srv/control/phlo/resources/AI/DeepSeek.phlo",
                "class": "DeepSeek",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "DeepSeek API (OpenAI-compatible, extends OpenAI)",
                    "advice": "OpenAI-compatible, so it inherits the whole OpenAI resource and only swaps endpoint, model and credentials; anything that works there works here unless DeepSeek itself lacks it. Embeddings are such a gap: they are routed to OpenAI, so that key has to be present as well.",
                    "extends": "OpenAI",
                    "package": "ai",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "creds:DeepSeek @OpenAI @AI",
                    "tags": "ai deepseek chat embeddings"
                },
                "nodes": {
                    "model": {
                        "node": "const",
                        "visibility": null,
                        "name": "model",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'deepseek-chat'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "endpoint": {
                        "node": "const",
                        "visibility": null,
                        "name": "endpoint",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'https://api.deepseek.com/v1/'",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "cred": {
                        "node": "const",
                        "visibility": null,
                        "name": "cred",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'DeepSeek'",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "label": {
                        "node": "const",
                        "visibility": null,
                        "name": "label",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'DeepSeek'",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "embedding": {
                        "node": "method",
                        "visibility": null,
                        "name": "embedding",
                        "args": "$input, $model = 'text-embedding-3-small'",
                        "type": "array",
                        "operator": "arrow",
                        "body": "%OpenAI->embedding($input, $model)",
                        "line": 17,
                        "bodyLine": 17
                    }
                },
                "functions": [],
                "assets": []
            },
            "Gemini": {
                "file": "/srv/control/phlo/resources/AI/Gemini.phlo",
                "class": "Gemini",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Google Gemini API",
                    "advice": "Google puts the model in the path rather than in the body, so a wrong model name reads as a wrong URL. Its embeddings come from text-embedding-004 with a different vector length than OpenAI's, so a collection filled by one engine cannot be searched with the other.",
                    "package": "ai",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "creds:Gemini @AI",
                    "tags": "ai gemini google chat vision embeddings"
                },
                "nodes": {
                    "model": {
                        "node": "const",
                        "visibility": null,
                        "name": "model",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'gemini-2.0-flash'",
                        "line": 11,
                        "bodyLine": 11
                    },
                    "endpoint": {
                        "node": "const",
                        "visibility": null,
                        "name": "endpoint",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'https://generativelanguage.googleapis.com/v1beta/models/'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "context": {
                        "node": "static",
                        "visibility": null,
                        "name": "context",
                        "args": "...$args",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$args['contents'] ??= []\n\tif (isset($args['system'])){\n\t\t$args['systemInstruction'] = ['parts' => [['text' => $args['system']]]]\n\t\tunset($args['system'])\n\t}\n\tif (isset($args['assistant']) && array_push($args['contents'], ['role' => 'model', 'parts' => [['text' => $args['assistant']]]])) unset($args['assistant'])\n\tif (isset($args['user']) && array_push($args['contents'], ['role' => 'user', 'parts' => [['text' => $args['user']]]])) unset($args['user'])\n\treturn $args",
                        "line": 14,
                        "bodyLine": 15
                    },
                    "tool": {
                        "node": "static",
                        "visibility": null,
                        "name": "tool",
                        "args": "$tool",
                        "type": "array",
                        "operator": "arrow",
                        "body": "[\n\t'name' => $tool->name,\n\t'description' => $tool->desc,\n\t'parameters' => [\n\t\t'type' => 'object',\n\t\t'properties' => loop($tool->args, fn($data, $arg) => array_filter($data, fn($key) => in_array($key, ['type', 'enum', 'description']), ARRAY_FILTER_USE_KEY)),\n\t\t'required' => array_keys($tool->args),\n\t],\n]",
                        "line": 25,
                        "bodyLine": 25
                    },
                    "embedding": {
                        "node": "method",
                        "visibility": null,
                        "name": "embedding",
                        "args": "$input, $model = 'text-embedding-004'",
                        "type": "array",
                        "operator": "arrow",
                        "body": "$this->request($model.':embedContent', POST: ['content' => ['parts' => [['text' => $input]]]])->embedding->values",
                        "line": 35,
                        "bodyLine": 35
                    },
                    "vision": {
                        "node": "method",
                        "visibility": null,
                        "name": "vision",
                        "args": "$text, $image, $stream = false, ...$args",
                        "type": "obj|Generator",
                        "operator": "method",
                        "body": "\t$model = $args['model'] ?? static::model\n\tunset($args['model'])\n\t$contents = [['role' => 'user', 'parts' => [['text' => $text], ['inline_data' => ['mime_type' => 'image/jpeg', 'data' => base64_encode(is_string($image) && str_starts_with($image, 'http') ? file_get_contents($image) : $image)]]]]]\n\tif ($stream) return $this->stream(...$args, model: $model, contents: $contents)\n\telse return $this->chat(...$args, model: $model, contents: $contents)",
                        "line": 37,
                        "bodyLine": 38
                    },
                    "chat": {
                        "node": "method",
                        "visibility": null,
                        "name": "chat",
                        "args": "...$args",
                        "type": "obj",
                        "operator": "method",
                        "body": "\t$model = $args['model'] ?? static::model\n\tunset($args['model'])\n\t$args = static::context(...$args)\n\t$res = $this->request($model.':generateContent', POST: $args)\n\t$return = new obj(answer: void, model: $model, finish: $res->candidates[0]->finishReason ?? void, tokens: ($res->usageMetadata->promptTokenCount ?? 0) + ($res->usageMetadata->candidatesTokenCount ?? 0), tokens_in: $res->usageMetadata->promptTokenCount ?? 0, tokens_out: $res->usageMetadata->candidatesTokenCount ?? 0)\n\t$tools = []\n\tforeach ($res->candidates[0]->content->parts ?? [] AS $part){\n\t\tif (isset($part->text)) $return->answer = $part->text\n\t\telseif (isset($part->functionCall)) $tools[] = new obj(name: $part->functionCall->name, args: (array)$part->functionCall->args)\n\t}\n\tif ($tools) $return->tools = $tools\n\treturn $return",
                        "line": 45,
                        "bodyLine": 46
                    },
                    "parseSSE": {
                        "node": "method",
                        "visibility": null,
                        "name": "parseSSE",
                        "args": "string $url, array $headers, array $payload",
                        "type": "Generator",
                        "operator": "method",
                        "body": "\t$json = json_encode($payload, jsonFlat)\n\t$ctx = stream_context_create(['http' => ['method' => 'POST', 'header' => implode(nl, [...$headers, 'Content-Type: application/json', 'Content-Length: '.strlen($json)]), 'content' => $json, 'timeout' => 300, 'ignore_errors' => true]])\n\t$stream = fopen($url, 'r', false, $ctx)\n\t$stream || error('SSE connection failed')\n\t$status = (int)explode(space, $http_response_header[0] ?? 'HTTP/1.1 200')[1]\n\tif ($status >= 400){\n\t\t$err = json_decode((string)stream_get_contents($stream))\n\t\tfclose($stream)\n\t\terror('Gemini error '.$status.': '.($err->error->message ?? 'unknown'))\n\t}\n\t$finish = null\n\t$usage = null\n\twhile (!feof($stream)){\n\t\t$line = fgets($stream)\n\t\tif ($line === false) break\n\t\t$line = rtrim($line, nl)\n\t\tif (!str_starts_with($line, 'data:')) continue\n\t\t$data = ltrim(substr($line, 5))\n\t\tif ($data === void) continue\n\t\t$p = json_decode($data)\n\t\tif (!$p) continue\n\t\tif (isset($p->error)) error('Gemini stream error: '.$p->error->message)\n\t\tif (isset($p->usageMetadata)) $usage = $p->usageMetadata\n\t\tif ($p->candidates[0]->finishReason ?? null) $finish = $p->candidates[0]->finishReason\n\t\t$text = $p->candidates[0]->content->parts[0]->text ?? null\n\t\tif (!is_null($text)) yield obj(text: $text)\n\t}\n\tfclose($stream)\n\tyield obj(done: true, finish: $finish, tokens_in: $usage?->promptTokenCount, tokens_out: $usage?->candidatesTokenCount)",
                        "line": 60,
                        "bodyLine": 61
                    },
                    "stream": {
                        "node": "method",
                        "visibility": null,
                        "name": "stream",
                        "args": "...$args",
                        "type": "Generator",
                        "operator": "method",
                        "body": "\t%res->streaming = true\n\t$model = $args['model'] ?? static::model\n\tunset($args['model'], $args['cb'])\n\t$args = static::context(...$args)\n\treturn $this->parseSSE(static::endpoint.$model.':streamGenerateContent?alt=sse', ['x-goog-api-key: '.%creds->Gemini], $args)",
                        "line": 91,
                        "bodyLine": 92
                    },
                    "request": {
                        "node": "method",
                        "visibility": null,
                        "name": "request",
                        "args": "$path, ...$args",
                        "type": null,
                        "operator": "method",
                        "body": "\t$res = json_decode(AI::http(static::endpoint.$path, ['x-goog-api-key: '.%creds->Gemini], true, $args['POST'] ?? null))\n\tif (isset($res->error)) error('Gemini Request error: '.$res->error->message)\n\treturn $res",
                        "line": 99,
                        "bodyLine": 100
                    }
                },
                "functions": [],
                "assets": []
            },
            "Grok": {
                "file": "/srv/control/phlo/resources/AI/Grok.phlo",
                "class": "Grok",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "xAI Grok API (OpenAI-compatible, extends OpenAI)",
                    "advice": "OpenAI-compatible, so it inherits the whole OpenAI resource and only swaps endpoint, model and credentials. Embeddings are not part of the deal and go out through OpenAI, so that key has to be present as well.",
                    "extends": "OpenAI",
                    "package": "ai",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "creds:Grok @OpenAI @AI",
                    "tags": "ai grok xai chat vision embeddings"
                },
                "nodes": {
                    "model": {
                        "node": "const",
                        "visibility": null,
                        "name": "model",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'grok-4'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "endpoint": {
                        "node": "const",
                        "visibility": null,
                        "name": "endpoint",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'https://api.x.ai/v1/'",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "cred": {
                        "node": "const",
                        "visibility": null,
                        "name": "cred",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'Grok'",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "label": {
                        "node": "const",
                        "visibility": null,
                        "name": "label",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'Grok'",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "embedding": {
                        "node": "method",
                        "visibility": null,
                        "name": "embedding",
                        "args": "$input, $model = 'text-embedding-3-small'",
                        "type": "array",
                        "operator": "arrow",
                        "body": "%OpenAI->embedding($input, $model)",
                        "line": 17,
                        "bodyLine": 17
                    }
                },
                "functions": [],
                "assets": []
            },
            "OpenAI": {
                "file": "/srv/control/phlo/resources/AI/OpenAI.phlo",
                "class": "OpenAI",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Basic OpenAI functions",
                    "advice": "Write a conversation as system, user and assistant instead of assembling messages yourself; context() folds them into the right order. Each answer carries tokens_in and tokens_out, so a run can be metered or capped without reading the raw response. A refused request is raised as an error rather than returned, which is the opposite of how the connectors behave, so wrap a call you cannot afford to lose. Pass token to use a key other than the configured one.",
                    "package": "ai",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "creds:OpenAI @AI",
                    "tags": "ai openai llm chat embeddings audio vision"
                },
                "nodes": {
                    "model": {
                        "node": "const",
                        "visibility": null,
                        "name": "model",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'gpt-5.4-mini'",
                        "line": 11,
                        "bodyLine": 11
                    },
                    "endpoint": {
                        "node": "const",
                        "visibility": null,
                        "name": "endpoint",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'https://api.openai.com/v1/'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "cred": {
                        "node": "const",
                        "visibility": null,
                        "name": "cred",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'OpenAI'",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "label": {
                        "node": "const",
                        "visibility": null,
                        "name": "label",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'OpenAI'",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "voices": {
                        "node": "const",
                        "visibility": null,
                        "name": "voices",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "context": {
                        "node": "static",
                        "visibility": null,
                        "name": "context",
                        "args": "...$args",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$args['messages'] ??= []\n\tif (isset($args['system']) && array_unshift($args['messages'], ['role' => 'system', 'content' => $args['system']])) unset($args['system'])\n\tif (isset($args['assistant']) && array_push($args['messages'], ['role' => 'assistant', 'content' => $args['assistant']])) unset($args['assistant'])\n\tif (isset($args['user']) && array_push($args['messages'], ['role' => 'user', 'content' => $args['user']])) unset($args['user'])\n\treturn $args",
                        "line": 16,
                        "bodyLine": 17
                    },
                    "tool": {
                        "node": "static",
                        "visibility": null,
                        "name": "tool",
                        "args": "$tool",
                        "type": "array",
                        "operator": "arrow",
                        "body": "[\n\t'type' => 'function',\n\t'function' => [\n\t\t'name' => $tool->name,\n\t\t'description' => $tool->desc,\n\t\t'parameters' => [\n\t\t\t'type' => 'object',\n\t\t\t'properties' => loop($tool->args, fn($data, $arg) => array_filter($data, fn($key) => in_array($key, ['type', 'enum', 'desc']), ARRAY_FILTER_USE_KEY)),\n\t\t\t'additionalProperties' => false,\n\t\t\t'required' => array_keys($tool->args),\n\t\t],\n\t\t'strict' => true,\n\t],\n]",
                        "line": 23,
                        "bodyLine": 23
                    },
                    "chat": {
                        "node": "method",
                        "visibility": null,
                        "name": "chat",
                        "args": "...$args",
                        "type": "obj",
                        "operator": "method",
                        "body": "\t$args['model'] ??= static::model\n\t$token = $args['token'] ?? null\n\tunset($args['token'])\n\t$args = static::context(...$args)\n\t$res = $this->request('chat/completions', token: $token, POST: $args)\n\t$return = new obj(answer: $res->choices[0]->message->content, model: $res->model, finish: $res->choices[0]->finish_reason, tokens: $res->usage->total_tokens, tokens_in: $res->usage->prompt_tokens, tokens_out: $res->usage->completion_tokens)\n\tif (isset($res->choices[0]->message->tool_calls)) $return->tools = array_map(fn($tool) => new obj(name: $tool->function->name, args: json_decode($tool->function->arguments, true)), (array)$res->choices[0]->message->tool_calls)\n\treturn $return",
                        "line": 37,
                        "bodyLine": 38
                    },
                    "embedding": {
                        "node": "method",
                        "visibility": null,
                        "name": "embedding",
                        "args": "$input, $model = 'text-embedding-3-small'",
                        "type": "array",
                        "operator": "arrow",
                        "body": "$this->request('embeddings', POST: ['input' => $input, 'model' => $model])->data[0]->embedding",
                        "line": 47,
                        "bodyLine": 47
                    },
                    "parseSSE": {
                        "node": "method",
                        "visibility": null,
                        "name": "parseSSE",
                        "args": "string $url, array $headers, array $payload",
                        "type": "Generator",
                        "operator": "method",
                        "body": "\t$json = json_encode($payload, jsonFlat)\n\t$ctx = stream_context_create(['http' => ['method' => 'POST', 'header' => implode(nl, [...$headers, 'Content-Type: application/json', 'Content-Length: '.strlen($json)]), 'content' => $json, 'timeout' => 300, 'ignore_errors' => true]])\n\t$stream = fopen($url, 'r', false, $ctx)\n\t$stream || error('SSE connection failed')\n\t$status = (int)explode(space, $http_response_header[0] ?? 'HTTP/1.1 200')[1]\n\tif ($status >= 400){\n\t\t$err = json_decode((string)stream_get_contents($stream))\n\t\tfclose($stream)\n\t\terror(static::label.' error '.$status.': '.($err->error->message ?? 'unknown'))\n\t}\n\t$finish = null\n\t$usage = null\n\twhile (!feof($stream)){\n\t\t$line = fgets($stream)\n\t\tif ($line === false) break\n\t\t$line = rtrim($line, nl)\n\t\tif (!str_starts_with($line, 'data:')) continue\n\t\t$data = ltrim(substr($line, 5))\n\t\tif ($data === '[DONE]' || $data === void) continue\n\t\t$p = json_decode($data)\n\t\tif (!$p) continue\n\t\tif (isset($p->error)) error(static::label.' stream error: '.$p->error->message)\n\t\tif (isset($p->usage)) $usage = $p->usage\n\t\t$text = $p->choices[0]->delta->content ?? null\n\t\tif (!is_null($text)) yield obj(text: $text)\n\t\tif ($p->choices[0]->finish_reason ?? null) $finish = $p->choices[0]->finish_reason\n\t}\n\tfclose($stream)\n\tyield obj(done: true, finish: $finish, tokens_in: $usage?->prompt_tokens, tokens_out: $usage?->completion_tokens)",
                        "line": 48,
                        "bodyLine": 49
                    },
                    "stream": {
                        "node": "method",
                        "visibility": null,
                        "name": "stream",
                        "args": "...$args",
                        "type": "Generator",
                        "operator": "method",
                        "body": "\t%res->streaming = true\n\t$args['model'] ??= static::model\n\t$token = $args['token'] ?? null\n\tunset($args['token'], $args['cb'])\n\t$args = static::context(...$args)\n\t$args['stream'] = true\n\t$bearer = $token ?? %creds->{static::cred};\n\treturn $this->parseSSE(static::endpoint.'chat/completions', ['Authorization: Bearer '.$bearer], $args)",
                        "line": 79,
                        "bodyLine": 80
                    },
                    "transcribe": {
                        "node": "method",
                        "visibility": null,
                        "name": "transcribe",
                        "args": "$file, $model = 'whisper-1', ...$args",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif (is_string($file)) $file = new CURLFile($file)\n\telseif (is_a($file, 'file')) $file = $file->curl\n\t$res = $this->request('audio/transcriptions', false, POST: [...$args, 'model' => $model, 'file' => $file, 'response_format' => 'verbose_json'])\n\treturn obj (\n\t\tmodel: $model,\n\t\tduration: $res->duration,\n\t\tlang: $res->language,\n\t\ttext: $res->text,\n\t)",
                        "line": 89,
                        "bodyLine": 90
                    },
                    "vision": {
                        "node": "method",
                        "visibility": null,
                        "name": "vision",
                        "args": "$text, $image, $stream = false, ...$args",
                        "type": "obj",
                        "operator": "method",
                        "body": "\t$args['model'] ??= static::model\n\t$messages = [['role' => 'user', 'content' => [['type' => 'text', 'text' => $text], ['type' => 'image_url', 'image_url' => ['url' => $image]]]]]\n\tif ($stream) return $this->stream(...$args, messages: $messages)\n\telse return $this->chat(...$args, messages: $messages)",
                        "line": 100,
                        "bodyLine": 101
                    },
                    "request": {
                        "node": "method",
                        "visibility": null,
                        "name": "request",
                        "args": "$uri, $JSON = true, $token = null, ...$args",
                        "type": null,
                        "operator": "method",
                        "body": "\t$bearer = $token ?? %creds->{static::cred};\n\t$res = json_decode(AI::http(static::endpoint.$uri, ['Authorization: Bearer '.$bearer], $JSON, $args['POST'] ?? null))\n\tif (isset($res->error)) error(static::label.' Request error: '.$res->error->message)\n\treturn $res",
                        "line": 106,
                        "bodyLine": 107
                    }
                },
                "functions": [],
                "assets": []
            }
        },
        "functions": {
            "answer": {
                "args": "$question, ...$options",
                "return": "?string",
                "body": "\t$prompt = 'You are an AI answer machine. '\n\t$prompt .= 'You give short, direct answers without repeating the subject. '\n\t$prompt .= 'You add no explanation, no extra text, and no quotation marks. '\n\t$prompt .= 'Always answer in the same language as the question.'.lf\n\tif ($options){\n\t\t$prompt .= 'The user asks a question. You choose exactly one of the options below as the answer. '\n\t\t$prompt .= 'Your answer matches exactly one of the options, with no extra words or punctuation. '\n\t\t$prompt .= 'If none of the options apply, answer with \"-\".'.lf.lf\n\t\t$prompt .= 'Question:'.lf.$question.lf.lf\n\t\t$prompt .= 'Options:'.lf\n\t\t$prompt .= implode(lf, $options)\n\t}\n\telse {\n\t\t$prompt .= 'Give a short and precise answer to the question. '\n\t\t$prompt .= 'No introduction, no explanation, no list, and no final period.'.lf.lf\n\t\t$prompt .= 'Question:'.lf.$question\n\t}\n\t$answer = %AI->chat(user: $prompt, temperature: .1)->answer\n\treturn $answer === dash ? null : $answer",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Simple AI answering helper",
                    "advice": "For a question with one short answer rather than a conversation: answer('...') gives a bare line and answer('...', 'yes', 'no') forces the reply to be exactly one of the options. Nothing fitting gives null instead of an invented answer, so treat null as a real outcome. It runs at temperature .1, so the same question tends to give the same answer.",
                    "package": "ai",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@AI",
                    "tags": "ai answer question llm"
                },
                "file": "/srv/control/phlo/resources/AI/answer.phlo",
                "line": 11,
                "source": "function"
            }
        }
    },
    "connectors": {
        "objs": {
            "Connector": {
                "file": "/srv/control/phlo/resources/connectors/Connector.phlo",
                "class": "Connector",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Base class for API connectors: credentials, JSON requests, retries, pagination and a normalized result contract",
                    "advice": "Make one with Connector::make(); it reads its own section from %creds, so keys live in data/creds.ini or in PHLO__Section__key in the environment and never in your code. Every call answers in the same shape, ok with status and data or ok false with error, and nothing is thrown, so test ->ok rather than catching. Raise retries above zero to let GET, HEAD and QUERY back off and try again on 429 and 5xx; writes are never retried, because a repeated POST would book twice.",
                    "package": "connectors",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "creds HTTP",
                    "tags": "api connector http rest base"
                },
                "nodes": {
                    "section": {
                        "node": "const",
                        "visibility": null,
                        "name": "section",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "void",
                        "line": 11,
                        "bodyLine": 11
                    },
                    "api": {
                        "node": "const",
                        "visibility": null,
                        "name": "api",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "void",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "__construct": {
                        "node": "method",
                        "visibility": null,
                        "name": "__construct",
                        "args": "?array $config = null",
                        "type": null,
                        "operator": "method",
                        "body": "\tif ($config === null){\n\t\t$section = static::section\n\t\t$creds = $section ? %creds->{$section} : null\n\t\t$config = $creds ? (array)$creds->toArray : []\n\t}\n\t$this->config = $config\n\t$this->timeout = 15\n\t$this->retries = 0",
                        "line": 14,
                        "bodyLine": 15
                    },
                    "make": {
                        "node": "static",
                        "visibility": null,
                        "name": "make",
                        "args": "?array $config = null",
                        "type": "static",
                        "operator": "arrow",
                        "body": "new static($config)",
                        "line": 25,
                        "bodyLine": 25
                    },
                    "base": {
                        "node": "method",
                        "visibility": null,
                        "name": "base",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "static::api",
                        "line": 27,
                        "bodyLine": 27
                    },
                    "headers": {
                        "node": "method",
                        "visibility": null,
                        "name": "headers",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[]",
                        "line": 28,
                        "bodyLine": 28
                    },
                    "fields": {
                        "node": "static",
                        "visibility": null,
                        "name": "fields",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[]",
                        "line": 30,
                        "bodyLine": 30
                    },
                    "configured": {
                        "node": "method",
                        "visibility": null,
                        "name": "configured",
                        "args": "...$keys",
                        "type": "bool",
                        "operator": "method",
                        "body": "\tforeach ($keys AS $key){\n\t\tif (($this->config[$key] ?? void) === void) return false\n\t}\n\treturn true",
                        "line": 32,
                        "bodyLine": 33
                    },
                    "missing": {
                        "node": "method",
                        "visibility": null,
                        "name": "missing",
                        "args": "...$keys",
                        "type": "?obj",
                        "operator": "method",
                        "body": "\treturn $this->configured(...$keys) ? null : static::fail(static::section.' credentials not configured ('.implode(', ', $keys).')')",
                        "line": 39,
                        "bodyLine": 40
                    },
                    "bearer": {
                        "node": "static",
                        "visibility": null,
                        "name": "bearer",
                        "args": "$token",
                        "type": "string",
                        "operator": "arrow",
                        "body": "'Authorization: Bearer '.$token",
                        "line": 43,
                        "bodyLine": 43
                    },
                    "basic": {
                        "node": "static",
                        "visibility": null,
                        "name": "basic",
                        "args": "$user, $pass",
                        "type": "string",
                        "operator": "arrow",
                        "body": "'Authorization: Basic '.base64_encode($user.colon.$pass)",
                        "line": 44,
                        "bodyLine": 44
                    },
                    "build": {
                        "node": "static",
                        "visibility": null,
                        "name": "build",
                        "args": "string $method, string $url, ?array $query = null, array $headers = [], mixed $json = null, mixed $form = null",
                        "type": "array",
                        "operator": "method",
                        "body": "\tif ($query) $url .= (str_contains($url, qm) ? '&' : qm).http_build_query($query)\n\t$body = null\n\tif ($json !== null){\n\t\t$body = is_string($json) ? $json : json_encode($json, jsonFlat)\n\t\t$headers[] = 'Content-Type: application/json'\n\t}\n\telseif ($form !== null){\n\t\t$body = is_string($form) ? $form : http_build_query($form)\n\t\t$headers[] = 'Content-Type: application/x-www-form-urlencoded'\n\t}\n\t$headers[] = 'Accept: application/json'\n\treturn ['method' => strtoupper($method), 'url' => $url, 'headers' => $headers, 'body' => $body]",
                        "line": 46,
                        "bodyLine": 47
                    },
                    "ok": {
                        "node": "static",
                        "visibility": null,
                        "name": "ok",
                        "args": "$data, int $status = 200",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "obj(ok: true, status: $status, data: $data)",
                        "line": 61,
                        "bodyLine": 61
                    },
                    "fail": {
                        "node": "static",
                        "visibility": null,
                        "name": "fail",
                        "args": "$error, int $status = 0",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "obj(ok: false, status: $status, error: $error)",
                        "line": 62,
                        "bodyLine": 62
                    },
                    "errorMessage": {
                        "node": "static",
                        "visibility": null,
                        "name": "errorMessage",
                        "args": "$data, string $raw, int $status",
                        "type": "string",
                        "operator": "method",
                        "body": "\tif (is_object($data)){\n\t\tif (isset($data->error->message)) return (string)$data->error->message\n\t\tif (isset($data->error) && is_string($data->error)) return $data->error\n\t\tif (isset($data->message)) return (string)$data->message\n\t\tif (isset($data->errors)){\n\t\t\t$errors = $data->errors\n\t\t\tif (is_string($errors)) return $errors\n\t\t\tif (is_array($errors)) return is_string($errors[0] ?? null) ? $errors[0] : json_encode($errors)\n\t\t\tif (is_object($errors)) return json_encode($errors)\n\t\t}\n\t}\n\treturn $raw !== void ? $raw : 'HTTP '.$status",
                        "line": 64,
                        "bodyLine": 65
                    },
                    "parse": {
                        "node": "static",
                        "visibility": null,
                        "name": "parse",
                        "args": "$raw, int $status = 200",
                        "type": "obj",
                        "operator": "method",
                        "body": "\t$raw = (string)$raw\n\t$data = $raw === void ? null : json_decode($raw)\n\tif ($status < 200 || $status >= 300) return static::fail(static::errorMessage($data, $raw, $status), $status)\n\treturn obj(ok: true, status: $status, data: $data ?? $raw)",
                        "line": 79,
                        "bodyLine": 80
                    },
                    "retryable": {
                        "node": "static",
                        "visibility": null,
                        "name": "retryable",
                        "args": "$method, int $status",
                        "type": "bool",
                        "operator": "arrow",
                        "body": "in_array($method, ['GET', 'HEAD', 'QUERY']) && ($status === 429 || $status >= 500)",
                        "line": 86,
                        "bodyLine": 86
                    },
                    "backoff": {
                        "node": "static",
                        "visibility": null,
                        "name": "backoff",
                        "args": "int $attempt, $response",
                        "type": "int",
                        "operator": "method",
                        "body": "\t$after = (int)($response->headers['retry-after'] ?? 0)\n\treturn $after > 0 ? min($after, 30) * 1000000 : 200000 * $attempt",
                        "line": 88,
                        "bodyLine": 89
                    },
                    "dispatch": {
                        "node": "method",
                        "visibility": null,
                        "name": "dispatch",
                        "args": "array $req",
                        "type": "obj",
                        "operator": "method",
                        "body": "\t$method = $req['method']\n\t$body = $req['body']\n\t$attempt = 0\n\t$response = null\n\twhile (true){\n\t\ttry {\n\t\t\tif ($method === 'GET') $raw = HTTP($req['url'], $req['headers'], cookies: false, timeout: $this->timeout, response: $response)\n\t\t\telseif ($method === 'DELETE') $raw = HTTP($req['url'], $req['headers'], DELETE: true, cookies: false, timeout: $this->timeout, response: $response)\n\t\t\telseif ($method === 'PUT') $raw = HTTP($req['url'], $req['headers'], PUT: $body ?? void, cookies: false, timeout: $this->timeout, response: $response)\n\t\t\telseif ($method === 'PATCH') $raw = HTTP($req['url'], $req['headers'], PATCH: $body ?? void, cookies: false, timeout: $this->timeout, response: $response)\n\t\t\telseif ($method === 'QUERY') $raw = HTTP($req['url'], $req['headers'], QUERY: $body ?? void, cookies: false, timeout: $this->timeout, response: $response)\n\t\t\telse $raw = HTTP($req['url'], $req['headers'], POST: $body ?? void, cookies: false, timeout: $this->timeout, response: $response)\n\t\t}\n\t\tcatch (\\Throwable $e){\n\t\t\treturn static::fail($e->getMessage(), 0)\n\t\t}\n\t\t$status = $response->status ?? 0\n\t\tif (($status >= 200 && $status < 300) || !static::retryable($method, $status) || $attempt >= $this->retries) break\n\t\tusleep(static::backoff(++$attempt, $response))\n\t}\n\t$result = static::parse($raw, $status)\n\t$result->headers = $response->headers ?? []\n\treturn $result",
                        "line": 97,
                        "comments": "Retries only what retryable() allows, and waits as long as the server asked for.\nA Retry-After header is honoured up to thirty seconds; without one the wait grows with\nthe attempt. Everything else comes back as it arrived, so a write that may already have\nbooked something is never sent a second time.",
                        "bodyLine": 98
                    },
                    "request": {
                        "node": "method",
                        "visibility": null,
                        "name": "request",
                        "args": "string $method, string $url, ?array $query = null, array $headers = [], mixed $json = null, mixed $form = null",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif (!str_starts_with($url, 'http')) $url = rtrim((string)$this->base, slash).slash.ltrim($url, slash)\n\t$headers = array_merge((array)$this->headers, $headers)\n\treturn $this->dispatch(static::build($method, $url, $query, $headers, $json, $form))",
                        "line": 123,
                        "bodyLine": 124
                    },
                    "get": {
                        "node": "method",
                        "visibility": null,
                        "name": "get",
                        "args": "string $url, ?array $query = null, array $headers = []",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('GET', $url, query: $query, headers: $headers)",
                        "line": 129,
                        "bodyLine": 129
                    },
                    "post": {
                        "node": "method",
                        "visibility": null,
                        "name": "post",
                        "args": "string $url, mixed $json = null, array $headers = []",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('POST', $url, headers: $headers, json: $json)",
                        "line": 130,
                        "bodyLine": 130
                    },
                    "put": {
                        "node": "method",
                        "visibility": null,
                        "name": "put",
                        "args": "string $url, mixed $json = null, array $headers = []",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('PUT', $url, headers: $headers, json: $json)",
                        "line": 131,
                        "bodyLine": 131
                    },
                    "patch": {
                        "node": "method",
                        "visibility": null,
                        "name": "patch",
                        "args": "string $url, mixed $json = null, array $headers = []",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('PATCH', $url, headers: $headers, json: $json)",
                        "line": 132,
                        "bodyLine": 132
                    },
                    "query": {
                        "node": "method",
                        "visibility": null,
                        "name": "query",
                        "args": "string $url, mixed $json = null, array $headers = []",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('QUERY', $url, headers: $headers, json: $json)",
                        "line": 133,
                        "bodyLine": 133
                    },
                    "del": {
                        "node": "method",
                        "visibility": null,
                        "name": "del",
                        "args": "string $url, array $headers = []",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('DELETE', $url, headers: $headers)",
                        "line": 134,
                        "bodyLine": 134
                    },
                    "form": {
                        "node": "method",
                        "visibility": null,
                        "name": "form",
                        "args": "string $url, array $fields, array $headers = []",
                        "type": "obj",
                        "operator": "arrow",
                        "body": "$this->request('POST', $url, headers: $headers, form: $fields)",
                        "line": 135,
                        "bodyLine": 135
                    },
                    "paginate": {
                        "node": "method",
                        "visibility": null,
                        "name": "paginate",
                        "args": "string $url, callable $extract, ?array $query = null, string $param = 'page', int $start = 1, int $max = 0",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$items = []\n\t$page = $start\n\twhile (true){\n\t\t$res = $this->get($url, ($query ?? []) + [$param => $page])\n\t\tif (!$res->ok) break\n\t\t$batch = $extract($res->data)\n\t\tif (!$batch) break\n\t\tforeach ($batch AS $item) $items[] = $item\n\t\tif ($max && count($items) >= $max) break\n\t\t$page++\n\t}\n\treturn $items",
                        "line": 137,
                        "bodyLine": 138
                    }
                },
                "functions": [],
                "assets": []
            },
            "EBoekhouden": {
                "file": "/srv/control/phlo/resources/connectors/finance/EBoekhouden.phlo",
                "class": "EBoekhouden",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "e-Boekhouden.nl connector: session auth from an API token, relations and sales invoices",
                    "advice": "Authenticates with a session rather than a token per call: the first call trades api_token for one, later calls in the same request reuse it, and a new request starts over. Sessions are limited, so gather the work you need per request instead of making a connector per call.",
                    "extends": "Connector",
                    "package": "connectors",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@Connector creds:EBoekhouden",
                    "tags": "eboekhouden accounting invoices relations connector"
                },
                "nodes": {
                    "section": {
                        "node": "const",
                        "visibility": null,
                        "name": "section",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'EBoekhouden'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "api": {
                        "node": "const",
                        "visibility": null,
                        "name": "api",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'https://api.e-boekhouden.nl/v1'",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "sessionToken": {
                        "node": "prop",
                        "visibility": null,
                        "name": "sessionToken",
                        "args": null,
                        "type": "string",
                        "operator": "value",
                        "body": "void",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "headers": {
                        "node": "method",
                        "visibility": null,
                        "name": "headers",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "$this->sessionToken !== void ? ['Authorization: '.$this->sessionToken] : []",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "fields": {
                        "node": "static",
                        "visibility": null,
                        "name": "fields",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "arr(\n\tsection: 'EBoekhouden',\n\tconfig: arr(source: 'Source label shown in the e-Boekhouden audit trail (optional)'),\n\tsecret: arr(api_token: 'API token (Beheer > Instellingen > API)'),\n)",
                        "line": 19,
                        "bodyLine": 19
                    },
                    "session": {
                        "node": "method",
                        "visibility": null,
                        "name": "session",
                        "args": null,
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('api_token')) return $m\n\tif ($this->sessionToken !== void) return static::ok($this->sessionToken)\n\t$res = $this->post('session', ['accessToken' => trim((string)$this->config['api_token']), 'source' => (string)($this->config['source'] ?? 'Phlo')])\n\tif (!$res->ok) return $res\n\t$token = (string)($res->data->token ?? void)\n\tif ($token === void) return static::fail('e-Boekhouden session token missing in response', $res->status)\n\t$this->sessionToken = $token\n\treturn static::ok($token)",
                        "line": 28,
                        "comments": "Returns the session token, fetching one on first use.\nThe API takes a session rather than a token per call, so the first call exchanges the\nAPI token for one and every later call in the same request reuses it.",
                        "bodyLine": 29
                    },
                    "guard": {
                        "node": "method",
                        "visibility": null,
                        "name": "guard",
                        "args": null,
                        "type": "?obj",
                        "operator": "method",
                        "body": "\t$res = $this->session\n\treturn $res->ok ? null : $res",
                        "line": 39,
                        "bodyLine": 40
                    },
                    "relations": {
                        "node": "method",
                        "visibility": null,
                        "name": "relations",
                        "args": "array $query = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->guard) return $m\n\treturn $this->get('relation', $query)",
                        "line": 44,
                        "bodyLine": 45
                    },
                    "createRelation": {
                        "node": "method",
                        "visibility": null,
                        "name": "createRelation",
                        "args": "array $relation",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->guard) return $m\n\treturn $this->post('relation', $relation)",
                        "line": 49,
                        "bodyLine": 50
                    },
                    "invoices": {
                        "node": "method",
                        "visibility": null,
                        "name": "invoices",
                        "args": "array $query = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->guard) return $m\n\treturn $this->get('invoice', $query)",
                        "line": 54,
                        "bodyLine": 55
                    },
                    "createInvoice": {
                        "node": "method",
                        "visibility": null,
                        "name": "createInvoice",
                        "args": "array $invoice",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->guard) return $m\n\treturn $this->post('invoice', $invoice)",
                        "line": 59,
                        "bodyLine": 60
                    }
                },
                "functions": [],
                "assets": []
            },
            "ExactOnline": {
                "file": "/srv/control/phlo/resources/connectors/finance/ExactOnline.phlo",
                "class": "ExactOnline",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Exact Online connector (OAuth2): read sales invoices and accounts, create sales invoices",
                    "advice": "The division number sits in the base URL, so a connector speaks to exactly one administration and a second administration needs a second connector with its own config. Exact rotates the refresh token on every refresh, which is why the token store locks; running two apps on one refresh token logs both out.",
                    "extends": "OAuthConnector",
                    "package": "connectors",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@OAuthConnector creds:ExactOnline",
                    "tags": "exact exactonline accounting invoices oauth connector"
                },
                "nodes": {
                    "section": {
                        "node": "const",
                        "visibility": null,
                        "name": "section",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'ExactOnline'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "tokenUrl": {
                        "node": "const",
                        "visibility": null,
                        "name": "tokenUrl",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'https://start.exactonline.nl/api/oauth2/token'",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "base": {
                        "node": "method",
                        "visibility": null,
                        "name": "base",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "'https://start.exactonline.nl/api/v1/'.($this->config['division'] ?? void)",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "fields": {
                        "node": "static",
                        "visibility": null,
                        "name": "fields",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "arr(\n\tsection: 'ExactOnline',\n\tconfig: arr(division: 'Division (administration) number'),\n\tsecret: arr(\n\t\tclient_id: 'OAuth client ID',\n\t\tclient_secret: 'OAuth client secret',\n\t\trefresh_token: 'OAuth refresh token (managed and rotated after first authorization)',\n\t),\n\tscopes: 'OAuth2 authorization code flow; token endpoint refreshes automatically',\n)",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "guard": {
                        "node": "method",
                        "visibility": null,
                        "name": "guard",
                        "args": null,
                        "type": "?obj",
                        "operator": "arrow",
                        "body": "$this->missing('division', 'client_id', 'client_secret', 'refresh_token')",
                        "line": 28,
                        "bodyLine": 28
                    },
                    "invoices": {
                        "node": "method",
                        "visibility": null,
                        "name": "invoices",
                        "args": "array $query = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->guard) return $m\n\treturn $this->get('salesinvoice/SalesInvoices', $query)",
                        "line": 30,
                        "bodyLine": 31
                    },
                    "accounts": {
                        "node": "method",
                        "visibility": null,
                        "name": "accounts",
                        "args": "array $query = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->guard) return $m\n\treturn $this->get('crm/Accounts', $query)",
                        "line": 35,
                        "bodyLine": 36
                    },
                    "createInvoice": {
                        "node": "method",
                        "visibility": null,
                        "name": "createInvoice",
                        "args": "array $invoice",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->guard) return $m\n\treturn $this->post('salesinvoice/SalesInvoices', $invoice)",
                        "line": 40,
                        "bodyLine": 41
                    }
                },
                "functions": [],
                "assets": []
            },
            "GoogleCalendar": {
                "file": "/srv/control/phlo/resources/connectors/cloud/GoogleCalendar.phlo",
                "class": "GoogleCalendar",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Google Calendar connector (OAuth2): read events and create events",
                    "advice": "Shares the Google section with Sheets, so one refresh token has to carry both scopes. calendarId defaults to primary, which is the mailbox of the account that authorized, not a shared agenda; give a calendar address for those. Google returns times as RFC3339 with an offset, so keep the timezone rather than casting to a local timestamp.",
                    "extends": "OAuthConnector",
                    "package": "connectors",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@OAuthConnector creds:Google",
                    "tags": "google calendar events oauth connector"
                },
                "nodes": {
                    "section": {
                        "node": "const",
                        "visibility": null,
                        "name": "section",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'Google'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "tokenUrl": {
                        "node": "const",
                        "visibility": null,
                        "name": "tokenUrl",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'https://oauth2.googleapis.com/token'",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "base": {
                        "node": "method",
                        "visibility": null,
                        "name": "base",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "'https://www.googleapis.com/calendar/v3'",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "fields": {
                        "node": "static",
                        "visibility": null,
                        "name": "fields",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "arr(\n\tsection: 'Google',\n\tsecret: arr(\n\t\tclient_id: 'OAuth client ID',\n\t\tclient_secret: 'OAuth client secret',\n\t\trefresh_token: 'OAuth refresh token (scopes: calendar, spreadsheets)',\n\t),\n\tscopes: 'https://www.googleapis.com/auth/calendar, https://www.googleapis.com/auth/spreadsheets',\n)",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "guard": {
                        "node": "method",
                        "visibility": null,
                        "name": "guard",
                        "args": null,
                        "type": "?obj",
                        "operator": "arrow",
                        "body": "$this->missing('client_id', 'client_secret', 'refresh_token')",
                        "line": 27,
                        "bodyLine": 27
                    },
                    "events": {
                        "node": "method",
                        "visibility": null,
                        "name": "events",
                        "args": "string $calendarId = 'primary', array $query = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->guard) return $m\n\treturn $this->get('calendars/'.rawurlencode($calendarId).'/events', $query)",
                        "line": 29,
                        "bodyLine": 30
                    },
                    "createEvent": {
                        "node": "method",
                        "visibility": null,
                        "name": "createEvent",
                        "args": "array $event, string $calendarId = 'primary'",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->guard) return $m\n\treturn $this->post('calendars/'.rawurlencode($calendarId).'/events', $event)",
                        "line": 34,
                        "bodyLine": 35
                    }
                },
                "functions": [],
                "assets": []
            },
            "GoogleSheets": {
                "file": "/srv/control/phlo/resources/connectors/cloud/GoogleSheets.phlo",
                "class": "GoogleSheets",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Google Sheets connector (OAuth2): read ranges and append rows",
                    "advice": "Shares the Google section with Calendar, so one refresh token has to carry both scopes. A range is A1 notation including the tab name, e.g. Sheet1!A:D. append() writes with USER_ENTERED, so the sheet parses what you send just as a typist would and a leading zero or a date-like string is reinterpreted; pass RAW when the value has to stay untouched.",
                    "extends": "OAuthConnector",
                    "package": "connectors",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@OAuthConnector creds:Google",
                    "tags": "google sheets spreadsheet oauth connector"
                },
                "nodes": {
                    "section": {
                        "node": "const",
                        "visibility": null,
                        "name": "section",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'Google'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "tokenUrl": {
                        "node": "const",
                        "visibility": null,
                        "name": "tokenUrl",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'https://oauth2.googleapis.com/token'",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "base": {
                        "node": "method",
                        "visibility": null,
                        "name": "base",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "'https://sheets.googleapis.com/v4/spreadsheets'",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "fields": {
                        "node": "static",
                        "visibility": null,
                        "name": "fields",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "arr(\n\tsection: 'Google',\n\tsecret: arr(\n\t\tclient_id: 'OAuth client ID',\n\t\tclient_secret: 'OAuth client secret',\n\t\trefresh_token: 'OAuth refresh token (scopes: calendar, spreadsheets)',\n\t),\n\tscopes: 'https://www.googleapis.com/auth/spreadsheets',\n)",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "guard": {
                        "node": "method",
                        "visibility": null,
                        "name": "guard",
                        "args": null,
                        "type": "?obj",
                        "operator": "arrow",
                        "body": "$this->missing('client_id', 'client_secret', 'refresh_token')",
                        "line": 27,
                        "bodyLine": 27
                    },
                    "values": {
                        "node": "method",
                        "visibility": null,
                        "name": "values",
                        "args": "$spreadsheetId, string $range",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->guard) return $m\n\treturn $this->get($spreadsheetId.'/values/'.rawurlencode($range))",
                        "line": 29,
                        "bodyLine": 30
                    },
                    "append": {
                        "node": "method",
                        "visibility": null,
                        "name": "append",
                        "args": "$spreadsheetId, string $range, array $rows, string $valueInputOption = 'USER_ENTERED'",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->guard) return $m\n\treturn $this->post($spreadsheetId.'/values/'.rawurlencode($range).':append?valueInputOption='.$valueInputOption, ['values' => $rows])",
                        "line": 34,
                        "bodyLine": 35
                    }
                },
                "functions": [],
                "assets": []
            },
            "Lightspeed": {
                "file": "/srv/control/phlo/resources/connectors/shops/Lightspeed.phlo",
                "class": "Lightspeed",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Lightspeed Retail (V3) connector: read customers and sales, create customers",
                    "advice": "Retail V3, so cluster_id names the account and the key and secret authenticate. Lightspeed rate limits per account with a leaky bucket, so raise retries rather than firing calls in a loop. findCustomer() picks its search field from what you pass: an address with an at sign searches on email, anything else on phone.",
                    "extends": "Connector",
                    "package": "connectors",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@Connector creds:Lightspeed",
                    "tags": "lightspeed webshop retail pos customers connector"
                },
                "nodes": {
                    "section": {
                        "node": "const",
                        "visibility": null,
                        "name": "section",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'Lightspeed'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "base": {
                        "node": "method",
                        "visibility": null,
                        "name": "base",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "'https://api.lightspeedapp.com/API/V3/Account/'.($this->config['cluster_id'] ?? void)",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "headers": {
                        "node": "method",
                        "visibility": null,
                        "name": "headers",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[static::basic($this->config['api_key'] ?? void, $this->config['api_secret'] ?? void)]",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "fields": {
                        "node": "static",
                        "visibility": null,
                        "name": "fields",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "arr(\n\tsection: 'Lightspeed',\n\tconfig: arr(\n\t\tcluster_id: 'Account / cluster ID',\n\t\tlanguage: 'Language (optional, default nl)',\n\t),\n\tsecret: arr(\n\t\tapi_key: 'API key',\n\t\tapi_secret: 'API secret',\n\t),\n\tscopes: 'Customer read/write, Sale read',\n)",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "customers": {
                        "node": "method",
                        "visibility": null,
                        "name": "customers",
                        "args": "array $query = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('cluster_id', 'api_key', 'api_secret')) return $m\n\treturn $this->get('Customer.json', $query)",
                        "line": 31,
                        "bodyLine": 32
                    },
                    "findCustomer": {
                        "node": "method",
                        "visibility": null,
                        "name": "findCustomer",
                        "args": "$participant",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('cluster_id', 'api_key', 'api_secret')) return $m\n\t$field = str_contains((string)$participant, '@') ? 'Email' : 'Phone'\n\treturn $this->get('Customer.json', [$field => $participant, 'limit' => 1])",
                        "line": 36,
                        "bodyLine": 37
                    },
                    "customer": {
                        "node": "method",
                        "visibility": null,
                        "name": "customer",
                        "args": "$id",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('cluster_id', 'api_key', 'api_secret')) return $m\n\treturn $this->get('Customer/'.$id.'.json')",
                        "line": 42,
                        "bodyLine": 43
                    },
                    "sales": {
                        "node": "method",
                        "visibility": null,
                        "name": "sales",
                        "args": "array $query = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('cluster_id', 'api_key', 'api_secret')) return $m\n\treturn $this->get('Sale.json', $query)",
                        "line": 47,
                        "bodyLine": 48
                    },
                    "createCustomer": {
                        "node": "method",
                        "visibility": null,
                        "name": "createCustomer",
                        "args": "array $customer",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('cluster_id', 'api_key', 'api_secret')) return $m\n\treturn $this->post('Customer.json', $customer)",
                        "line": 52,
                        "bodyLine": 53
                    }
                },
                "functions": [],
                "assets": []
            },
            "MessageBird": {
                "file": "/srv/control/phlo/resources/connectors/chat/MessageBird.phlo",
                "class": "MessageBird",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "MessageBird connector: send SMS",
                    "advice": "Needs access_key and an originator, the sender shown to the recipient; some countries refuse an alphanumeric originator, so a real number is the safer choice. Pass an array as the recipient to send one message to several numbers at once.",
                    "extends": "Connector",
                    "package": "connectors",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@Connector creds:MessageBird",
                    "tags": "messagebird sms messaging connector"
                },
                "nodes": {
                    "section": {
                        "node": "const",
                        "visibility": null,
                        "name": "section",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'MessageBird'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "base": {
                        "node": "method",
                        "visibility": null,
                        "name": "base",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "'https://rest.messagebird.com'",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "headers": {
                        "node": "method",
                        "visibility": null,
                        "name": "headers",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "['Authorization: AccessKey '.($this->config['access_key'] ?? void)]",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "fields": {
                        "node": "static",
                        "visibility": null,
                        "name": "fields",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "arr(\n\tsection: 'MessageBird',\n\tconfig: arr(originator: 'Originator (sender number or name)'),\n\tsecret: arr(access_key: 'Access key'),\n)",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "errorMessage": {
                        "node": "static",
                        "visibility": null,
                        "name": "errorMessage",
                        "args": "$data, string $raw, int $status",
                        "type": "string",
                        "operator": "method",
                        "body": "\tif (is_object($data) && isset($data->errors[0]->description)) return (string)$data->errors[0]->description\n\treturn parent::errorMessage($data, $raw, $status)",
                        "line": 24,
                        "bodyLine": 25
                    },
                    "sms": {
                        "node": "method",
                        "visibility": null,
                        "name": "sms",
                        "args": "$to, $body, array $extra = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('access_key', 'originator')) return $m\n\t$recipients = is_array($to) ? $to : [$to]\n\treturn $this->post('messages', ['originator' => $this->config['originator'], 'recipients' => $recipients, 'body' => $body] + $extra)",
                        "line": 29,
                        "bodyLine": 30
                    }
                },
                "functions": [],
                "assets": []
            },
            "MicrosoftGraph": {
                "file": "/srv/control/phlo/resources/connectors/cloud/MicrosoftGraph.phlo",
                "class": "MicrosoftGraph",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Microsoft Graph connector (app-only client credentials): read users and calendars, send mail, create events",
                    "advice": "This is the app-only flow: the token belongs to the registration, not to a person, so it needs admin-consented application permissions and every call names the mailbox it acts on. Tokens are cached in APCu for their lifetime, so a run without APCu fetches one per request. Set mailbox to spare yourself passing a user each time.",
                    "extends": "Connector",
                    "package": "connectors",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@Connector creds:Microsoft",
                    "tags": "microsoft graph office365 calendar mail connector"
                },
                "nodes": {
                    "section": {
                        "node": "const",
                        "visibility": null,
                        "name": "section",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'Microsoft'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "base": {
                        "node": "method",
                        "visibility": null,
                        "name": "base",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "'https://graph.microsoft.com/v1.0'",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "headers": {
                        "node": "method",
                        "visibility": null,
                        "name": "headers",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[static::bearer((string)$this->token)]",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "fields": {
                        "node": "static",
                        "visibility": null,
                        "name": "fields",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "arr(\n\tsection: 'Microsoft',\n\tconfig: arr(\n\t\ttenant_id: 'Azure AD tenant ID',\n\t\tclient_id: 'App registration client ID',\n\t\tmailbox: 'Default mailbox / user UPN for calendar and mail (optional)',\n\t),\n\tsecret: arr(client_secret: 'App registration client secret'),\n\tscopes: 'Application permissions: User.Read.All, Calendars.ReadWrite, Mail.Send',\n)",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "token": {
                        "node": "prop",
                        "visibility": null,
                        "name": "token",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "$this->fetchToken()",
                        "line": 29,
                        "bodyLine": 29
                    },
                    "fetchToken": {
                        "node": "method",
                        "visibility": null,
                        "name": "fetchToken",
                        "args": null,
                        "type": "string",
                        "operator": "method",
                        "body": "\t$tenant = $this->config['tenant_id'] ?? void\n\t$id = $this->config['client_id'] ?? void\n\t$secret = $this->config['client_secret'] ?? void\n\tif ($tenant === void || $id === void || $secret === void) return void\n\t$key = 'phlo:graph:'.$tenant.colon.$id\n\tif (function_exists('apcu_fetch')){\n\t\t$cached = apcu_fetch($key)\n\t\tif ($cached) return $cached\n\t}\n\t$res = $this->dispatch(static::build('POST', 'https://login.microsoftonline.com/'.$tenant.'/oauth2/v2.0/token', null, [], null, ['grant_type' => 'client_credentials', 'client_id' => $id, 'client_secret' => $secret, 'scope' => 'https://graph.microsoft.com/.default']))\n\tif (!$res->ok) return void\n\t$token = $res->data->access_token ?? void\n\t$expires = (int)($res->data->expires_in ?? 3600)\n\tif ($token !== void && function_exists('apcu_store')) apcu_store($key, $token, max(60, $expires - 60))\n\treturn $token",
                        "line": 35,
                        "comments": "Caches the app-only token in APCu until a minute before it expires.\nThe key carries tenant and client, so a burst of requests spends one token fetch instead\nof one each. Without APCu it simply fetches per request, which works but costs a round\ntrip every time.",
                        "bodyLine": 36
                    },
                    "mailbox": {
                        "node": "method",
                        "visibility": null,
                        "name": "mailbox",
                        "args": "$user = null",
                        "type": "string",
                        "operator": "arrow",
                        "body": "$user ?? ($this->config['mailbox'] ?? void)",
                        "line": 53,
                        "bodyLine": 53
                    },
                    "users": {
                        "node": "method",
                        "visibility": null,
                        "name": "users",
                        "args": "array $query = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('tenant_id', 'client_id', 'client_secret')) return $m\n\treturn $this->get('users', $query)",
                        "line": 55,
                        "bodyLine": 56
                    },
                    "user": {
                        "node": "method",
                        "visibility": null,
                        "name": "user",
                        "args": "$id",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('tenant_id', 'client_id', 'client_secret')) return $m\n\treturn $this->get('users/'.rawurlencode((string)$id))",
                        "line": 60,
                        "bodyLine": 61
                    },
                    "events": {
                        "node": "method",
                        "visibility": null,
                        "name": "events",
                        "args": "$user = null, array $query = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('tenant_id', 'client_id', 'client_secret')) return $m\n\t$mailbox = $this->mailbox($user)\n\tif ($mailbox === void) return static::fail('Microsoft mailbox required')\n\treturn $this->get('users/'.rawurlencode((string)$mailbox).'/events', $query)",
                        "line": 65,
                        "bodyLine": 66
                    },
                    "sendMail": {
                        "node": "method",
                        "visibility": null,
                        "name": "sendMail",
                        "args": "$message, $user = null, bool $save = true",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('tenant_id', 'client_id', 'client_secret')) return $m\n\t$mailbox = $this->mailbox($user)\n\tif ($mailbox === void) return static::fail('Microsoft mailbox required')\n\treturn $this->post('users/'.rawurlencode((string)$mailbox).'/sendMail', ['message' => $message, 'saveToSentItems' => $save])",
                        "line": 72,
                        "bodyLine": 73
                    },
                    "createEvent": {
                        "node": "method",
                        "visibility": null,
                        "name": "createEvent",
                        "args": "array $event, $user = null",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('tenant_id', 'client_id', 'client_secret')) return $m\n\t$mailbox = $this->mailbox($user)\n\tif ($mailbox === void) return static::fail('Microsoft mailbox required')\n\treturn $this->post('users/'.rawurlencode((string)$mailbox).'/events', $event)",
                        "line": 79,
                        "bodyLine": 80
                    }
                },
                "functions": [],
                "assets": []
            },
            "Moneybird": {
                "file": "/srv/control/phlo/resources/connectors/finance/Moneybird.phlo",
                "class": "Moneybird",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Moneybird connector: read contacts and invoices, create sales invoices",
                    "advice": "Needs an administration_id and a personal access token with rights for contacts and invoices. findContact() searches on one match, so use it to look up rather than to list. An invoice is created as a draft: sending or booking it is a separate step in Moneybird.",
                    "extends": "Connector",
                    "package": "connectors",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@Connector creds:Moneybird",
                    "tags": "moneybird accounting invoices contacts connector"
                },
                "nodes": {
                    "section": {
                        "node": "const",
                        "visibility": null,
                        "name": "section",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'Moneybird'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "base": {
                        "node": "method",
                        "visibility": null,
                        "name": "base",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "'https://moneybird.com/api/v2/'.($this->config['administration_id'] ?? void)",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "headers": {
                        "node": "method",
                        "visibility": null,
                        "name": "headers",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[static::bearer($this->config['access_token'] ?? void)]",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "fields": {
                        "node": "static",
                        "visibility": null,
                        "name": "fields",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "arr(\n\tsection: 'Moneybird',\n\tconfig: arr(administration_id: 'Administration ID'),\n\tsecret: arr(access_token: 'Personal access token with read/write for contacts and invoices'),\n)",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "contacts": {
                        "node": "method",
                        "visibility": null,
                        "name": "contacts",
                        "args": "array $query = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('administration_id', 'access_token')) return $m\n\treturn $this->get('contacts.json', $query)",
                        "line": 24,
                        "bodyLine": 25
                    },
                    "findContact": {
                        "node": "method",
                        "visibility": null,
                        "name": "findContact",
                        "args": "$query",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('administration_id', 'access_token')) return $m\n\treturn $this->get('contacts.json', ['query' => $query, 'per_page' => 1])",
                        "line": 29,
                        "bodyLine": 30
                    },
                    "contact": {
                        "node": "method",
                        "visibility": null,
                        "name": "contact",
                        "args": "$id",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('administration_id', 'access_token')) return $m\n\treturn $this->get('contacts/'.$id.'.json')",
                        "line": 34,
                        "bodyLine": 35
                    },
                    "invoices": {
                        "node": "method",
                        "visibility": null,
                        "name": "invoices",
                        "args": "array $query = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('administration_id', 'access_token')) return $m\n\treturn $this->get('sales_invoices.json', $query)",
                        "line": 39,
                        "bodyLine": 40
                    },
                    "createContact": {
                        "node": "method",
                        "visibility": null,
                        "name": "createContact",
                        "args": "array $contact",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('administration_id', 'access_token')) return $m\n\treturn $this->post('contacts.json', ['contact' => $contact])",
                        "line": 44,
                        "bodyLine": 45
                    },
                    "createInvoice": {
                        "node": "method",
                        "visibility": null,
                        "name": "createInvoice",
                        "args": "array $invoice",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('administration_id', 'access_token')) return $m\n\treturn $this->post('sales_invoices.json', ['sales_invoice' => $invoice])",
                        "line": 49,
                        "bodyLine": 50
                    }
                },
                "functions": [],
                "assets": []
            },
            "OAuthConnector": {
                "file": "/srv/control/phlo/resources/connectors/OAuthConnector.phlo",
                "class": "OAuthConnector",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Base class for OAuth2 connectors: stored, auto-refreshed bearer access tokens via TokenStore, on the OAuth2 primitive",
                    "advice": "Use this instead of Connector when the provider hands out access tokens that expire. Give it client_id, client_secret and a refresh_token and TokenStore keeps the access token and renews it in time; a subclass only names its section and tokenUrl.",
                    "extends": "Connector",
                    "package": "connectors",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@Connector TokenStore",
                    "tags": "oauth oauth2 connector base token refresh"
                },
                "nodes": {
                    "tokenUrl": {
                        "node": "const",
                        "visibility": null,
                        "name": "tokenUrl",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "void",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "oauthKey": {
                        "node": "method",
                        "visibility": null,
                        "name": "oauthKey",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "static::section",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "token": {
                        "node": "prop",
                        "visibility": null,
                        "name": "token",
                        "args": null,
                        "type": "?string",
                        "operator": "arrow",
                        "body": "TokenStore::access($this->oauthKey, static::tokenUrl, $this->config['client_id'] ?? void, $this->config['client_secret'] ?? void, ['refresh_token' => $this->config['refresh_token'] ?? null])",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "headers": {
                        "node": "method",
                        "visibility": null,
                        "name": "headers",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[static::bearer((string)$this->token)]",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "authed": {
                        "node": "method",
                        "visibility": null,
                        "name": "authed",
                        "args": null,
                        "type": "bool",
                        "operator": "arrow",
                        "body": "(string)$this->token !== void",
                        "line": 20,
                        "bodyLine": 20
                    }
                },
                "functions": [],
                "assets": []
            },
            "Resend": {
                "file": "/srv/control/phlo/resources/connectors/chat/Resend.phlo",
                "class": "Resend",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Resend connector: send transactional email via the HTTP API",
                    "advice": "Needs api_key and a from_email on a domain you verified with Resend, otherwise the send is refused. send() takes HTML; leave it out for a plain text mail and add anything else Resend accepts through extra.",
                    "extends": "Connector",
                    "package": "connectors",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@Connector creds:Resend",
                    "tags": "resend email transactional messaging connector"
                },
                "nodes": {
                    "section": {
                        "node": "const",
                        "visibility": null,
                        "name": "section",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'Resend'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "base": {
                        "node": "method",
                        "visibility": null,
                        "name": "base",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "'https://api.resend.com'",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "headers": {
                        "node": "method",
                        "visibility": null,
                        "name": "headers",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[static::bearer($this->config['api_key'] ?? void)]",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "fields": {
                        "node": "static",
                        "visibility": null,
                        "name": "fields",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "arr(\n\tsection: 'Resend',\n\tconfig: arr(from_email: 'Default sender, e.g. \"App <noreply@yourdomain.com>\"'),\n\tsecret: arr(api_key: 'API key (re_...)'),\n)",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "send": {
                        "node": "method",
                        "visibility": null,
                        "name": "send",
                        "args": "$to, $subject, $html = void, array $extra = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('api_key', 'from_email')) return $m\n\t$email = ['from' => $this->config['from_email'], 'to' => is_array($to) ? $to : [$to], 'subject' => $subject]\n\tif ($html !== void) $email['html'] = $html\n\treturn $this->post('emails', $email + $extra)",
                        "line": 24,
                        "bodyLine": 25
                    }
                },
                "functions": [],
                "assets": []
            },
            "Shopify": {
                "file": "/srv/control/phlo/resources/connectors/shops/Shopify.phlo",
                "class": "Shopify",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Shopify Admin API connector: read customers, orders and products; create draft orders and products; update inventory",
                    "advice": "Needs shop_domain including myshopify.com and an admin access token; api_version defaults to 2024-01 and a Shopify version is supported for a limited time, so set the one you tested against rather than leaning on the default. setInventory() writes an absolute level, not a difference, so read the current one first when you mean to add. A draft order is not an order until it is completed in Shopify.",
                    "extends": "Connector",
                    "package": "connectors",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@Connector creds:Shopify",
                    "tags": "shopify webshop ecommerce orders products connector"
                },
                "nodes": {
                    "section": {
                        "node": "const",
                        "visibility": null,
                        "name": "section",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'Shopify'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "base": {
                        "node": "method",
                        "visibility": null,
                        "name": "base",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "'https://'.($this->config['shop_domain'] ?? void).'/admin/api/'.($this->config['api_version'] ?? '2024-01')",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "headers": {
                        "node": "method",
                        "visibility": null,
                        "name": "headers",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "['X-Shopify-Access-Token: '.($this->config['access_token'] ?? void)]",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "fields": {
                        "node": "static",
                        "visibility": null,
                        "name": "fields",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "arr(\n\tsection: 'Shopify',\n\tconfig: arr(\n\t\tshop_domain: 'Shop domain, e.g. your-store.myshopify.com',\n\t\tapi_version: 'Admin API version (optional, default 2024-01)',\n\t),\n\tsecret: arr(access_token: 'Admin API access token (shpat_...)'),\n\tscopes: 'read_customers, read_orders, read_products, write_draft_orders, write_inventory',\n)",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "customers": {
                        "node": "method",
                        "visibility": null,
                        "name": "customers",
                        "args": "array $query = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('shop_domain', 'access_token')) return $m\n\treturn $this->get('customers.json', $query)",
                        "line": 28,
                        "bodyLine": 29
                    },
                    "searchCustomers": {
                        "node": "method",
                        "visibility": null,
                        "name": "searchCustomers",
                        "args": "$query, int $limit = 10",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('shop_domain', 'access_token')) return $m\n\treturn $this->get('customers/search.json', ['query' => $query, 'limit' => $limit])",
                        "line": 33,
                        "bodyLine": 34
                    },
                    "customer": {
                        "node": "method",
                        "visibility": null,
                        "name": "customer",
                        "args": "$id",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('shop_domain', 'access_token')) return $m\n\treturn $this->get('customers/'.$id.'.json')",
                        "line": 38,
                        "bodyLine": 39
                    },
                    "orders": {
                        "node": "method",
                        "visibility": null,
                        "name": "orders",
                        "args": "array $query = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('shop_domain', 'access_token')) return $m\n\treturn $this->get('orders.json', $query)",
                        "line": 43,
                        "bodyLine": 44
                    },
                    "products": {
                        "node": "method",
                        "visibility": null,
                        "name": "products",
                        "args": "array $query = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('shop_domain', 'access_token')) return $m\n\treturn $this->get('products.json', $query)",
                        "line": 48,
                        "bodyLine": 49
                    },
                    "createDraftOrder": {
                        "node": "method",
                        "visibility": null,
                        "name": "createDraftOrder",
                        "args": "array $order",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('shop_domain', 'access_token')) return $m\n\treturn $this->post('draft_orders.json', ['draft_order' => $order])",
                        "line": 53,
                        "bodyLine": 54
                    },
                    "createProduct": {
                        "node": "method",
                        "visibility": null,
                        "name": "createProduct",
                        "args": "array $product",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('shop_domain', 'access_token')) return $m\n\treturn $this->post('products.json', ['product' => $product])",
                        "line": 58,
                        "bodyLine": 59
                    },
                    "setInventory": {
                        "node": "method",
                        "visibility": null,
                        "name": "setInventory",
                        "args": "$inventoryItemId, $locationId, int $available",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('shop_domain', 'access_token')) return $m\n\treturn $this->post('inventory_levels/set.json', ['inventory_item_id' => $inventoryItemId, 'location_id' => $locationId, 'available' => $available])",
                        "line": 63,
                        "bodyLine": 64
                    }
                },
                "functions": [],
                "assets": []
            },
            "Slack": {
                "file": "/srv/control/phlo/resources/connectors/chat/Slack.phlo",
                "class": "Slack",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Slack connector: post messages, read channel history and list channels",
                    "advice": "Needs a bot token, and the bot has to be invited into the channel it posts in, else the call comes back ok false with not_in_channel. Slack answers HTTP 200 even when it refuses, so read the outcome from the result, never from the status.",
                    "extends": "Connector",
                    "package": "connectors",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@Connector creds:Slack",
                    "tags": "slack messaging chat connector"
                },
                "nodes": {
                    "section": {
                        "node": "const",
                        "visibility": null,
                        "name": "section",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'Slack'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "api": {
                        "node": "const",
                        "visibility": null,
                        "name": "api",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'https://slack.com/api'",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "headers": {
                        "node": "method",
                        "visibility": null,
                        "name": "headers",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[static::bearer($this->config['bot_token'] ?? void)]",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "fields": {
                        "node": "static",
                        "visibility": null,
                        "name": "fields",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "arr(\n\tsection: 'Slack',\n\tsecret: arr(\n\t\tbot_token: 'Bot user OAuth token (xoxb-...)',\n\t\tsigning_secret: 'Signing secret for inbound webhook verification (optional)',\n\t),\n\tscopes: 'chat:write, channels:history, channels:read',\n)",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "result": {
                        "node": "method",
                        "visibility": null,
                        "name": "result",
                        "args": "obj $res",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif (!$res->ok) return $res\n\t$data = $res->data\n\tif (is_object($data) && ($data->ok ?? null) === false) return static::fail($data->error ?? 'Slack API error', $res->status)\n\treturn $res",
                        "line": 26,
                        "bodyLine": 27
                    },
                    "send": {
                        "node": "method",
                        "visibility": null,
                        "name": "send",
                        "args": "$channel, $text, array $extra = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif (!$this->configured('bot_token')) return static::fail('Slack bot_token not configured')\n\treturn $this->result($this->post('chat.postMessage', ['channel' => $channel, 'text' => $text] + $extra))",
                        "line": 33,
                        "bodyLine": 34
                    },
                    "history": {
                        "node": "method",
                        "visibility": null,
                        "name": "history",
                        "args": "$channel, int $limit = 20",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif (!$this->configured('bot_token')) return static::fail('Slack bot_token not configured')\n\treturn $this->result($this->get('conversations.history', ['channel' => $channel, 'limit' => $limit]))",
                        "line": 38,
                        "bodyLine": 39
                    },
                    "channels": {
                        "node": "method",
                        "visibility": null,
                        "name": "channels",
                        "args": "int $limit = 100, string $types = 'public_channel'",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif (!$this->configured('bot_token')) return static::fail('Slack bot_token not configured')\n\treturn $this->result($this->get('conversations.list', ['limit' => $limit, 'types' => $types]))",
                        "line": 43,
                        "bodyLine": 44
                    }
                },
                "functions": [],
                "assets": []
            },
            "Telegram": {
                "file": "/srv/control/phlo/resources/connectors/chat/Telegram.phlo",
                "class": "Telegram",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Telegram Bot API connector: send messages, photos and documents; poll updates",
                    "advice": "Needs a bot token from BotFather and a chat_id, and a person has to have written to the bot before it can write to them. Like Slack, the API answers 200 on refusal, which the connector already translates into ok false. photo() and document() take a URL or an existing file_id, not raw bytes.",
                    "extends": "Connector",
                    "package": "connectors",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@Connector creds:Telegram",
                    "tags": "telegram bot messaging chat connector"
                },
                "nodes": {
                    "section": {
                        "node": "const",
                        "visibility": null,
                        "name": "section",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'Telegram'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "base": {
                        "node": "method",
                        "visibility": null,
                        "name": "base",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "'https://api.telegram.org/bot'.($this->config['bot_token'] ?? void)",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "fields": {
                        "node": "static",
                        "visibility": null,
                        "name": "fields",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "arr(\n\tsection: 'Telegram',\n\tsecret: arr(\n\t\tbot_token: 'Bot token from BotFather',\n\t\twebhook_secret: 'Webhook secret token for inbound verification (optional)',\n\t),\n\thelp: 'Create a bot via BotFather and store its token. The chat_id is the recipient or chat to message.',\n)",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "result": {
                        "node": "method",
                        "visibility": null,
                        "name": "result",
                        "args": "obj $res",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif (!$res->ok) return $res\n\t$data = $res->data\n\tif (is_object($data) && ($data->ok ?? null) === false) return static::fail($data->description ?? 'Telegram API error', $res->status)\n\treturn $res",
                        "line": 25,
                        "bodyLine": 26
                    },
                    "send": {
                        "node": "method",
                        "visibility": null,
                        "name": "send",
                        "args": "$chatId, $text, array $extra = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('bot_token')) return $m\n\treturn $this->result($this->post('sendMessage', ['chat_id' => $chatId, 'text' => $text] + $extra))",
                        "line": 32,
                        "bodyLine": 33
                    },
                    "photo": {
                        "node": "method",
                        "visibility": null,
                        "name": "photo",
                        "args": "$chatId, $photo, $caption = void, array $extra = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('bot_token')) return $m\n\t$payload = ['chat_id' => $chatId, 'photo' => $photo]\n\tif ($caption !== void) $payload['caption'] = $caption\n\treturn $this->result($this->post('sendPhoto', $payload + $extra))",
                        "line": 37,
                        "bodyLine": 38
                    },
                    "document": {
                        "node": "method",
                        "visibility": null,
                        "name": "document",
                        "args": "$chatId, $document, $caption = void, array $extra = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('bot_token')) return $m\n\t$payload = ['chat_id' => $chatId, 'document' => $document]\n\tif ($caption !== void) $payload['caption'] = $caption\n\treturn $this->result($this->post('sendDocument', $payload + $extra))",
                        "line": 44,
                        "bodyLine": 45
                    },
                    "updates": {
                        "node": "method",
                        "visibility": null,
                        "name": "updates",
                        "args": "int $offset = 0, int $limit = 100",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('bot_token')) return $m\n\treturn $this->result($this->get('getUpdates', ['offset' => $offset, 'limit' => $limit]))",
                        "line": 51,
                        "bodyLine": 52
                    }
                },
                "functions": [],
                "assets": []
            },
            "TokenStore": {
                "file": "/srv/control/phlo/resources/connectors/TokenStore.phlo",
                "class": "TokenStore",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Persisted OAuth2 token store with automatic refresh via the OAuth2 resource",
                    "advice": "Tokens are kept as one file per key under data/tokens with 0600 rights, so a refresh survives a restart and every worker shares it. A refresh takes an exclusive lock, which matters with providers that rotate the refresh token: without it the losing request is left holding a dead one.",
                    "package": "connectors",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "OAuth2",
                    "tags": "oauth oauth2 token refresh store credentials"
                },
                "nodes": {
                    "path": {
                        "node": "static",
                        "visibility": null,
                        "name": "path",
                        "args": "$key",
                        "type": "string",
                        "operator": "arrow",
                        "body": "data.'tokens/'.preg_replace('/[^a-z0-9_.-]+/i', us, (string)$key).'.json'",
                        "line": 11,
                        "bodyLine": 11
                    },
                    "read": {
                        "node": "static",
                        "visibility": null,
                        "name": "read",
                        "args": "$key",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$file = static::path($key)\n\tif (!is_file($file)) return []\n\t@chmod(data.'tokens', 0700)\n\t@chmod($file, 0600)\n\treturn (array)json_read($file, true)",
                        "line": 13,
                        "bodyLine": 14
                    },
                    "write": {
                        "node": "static",
                        "visibility": null,
                        "name": "write",
                        "args": "$key, array $token",
                        "type": "void",
                        "operator": "method",
                        "body": "\t$dir = data.'tokens'\n\tis_dir($dir) || mkdir($dir, 0700, true)\n\t@chmod($dir, 0700)\n\t$file = static::path($key)\n\tjson_write($file, $token)\n\t@chmod($file, 0600)",
                        "line": 21,
                        "bodyLine": 22
                    },
                    "valid": {
                        "node": "static",
                        "visibility": null,
                        "name": "valid",
                        "args": "array $token",
                        "type": "bool",
                        "operator": "arrow",
                        "body": "($token['access_token'] ?? void) !== void && (int)($token['expires_at'] ?? 0) > time() + 30",
                        "line": 30,
                        "bodyLine": 30
                    },
                    "store": {
                        "node": "static",
                        "visibility": null,
                        "name": "store",
                        "args": "$res, $refresh",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$token = [\n\t\t'access_token' => $res['access_token'],\n\t\t'refresh_token' => $res['refresh_token'] ?? $refresh,\n\t\t'expires_at' => time() + (int)($res['expires_in'] ?? 3600),\n\t]\n\treturn $token",
                        "line": 32,
                        "bodyLine": 33
                    },
                    "lock": {
                        "node": "static",
                        "visibility": null,
                        "name": "lock",
                        "args": "$key",
                        "type": null,
                        "operator": "method",
                        "body": "\t$dir = data.'tokens'\n\tis_dir($dir) || mkdir($dir, 0700, true)\n\t$lock = @fopen(static::path($key).'.lock', 'c')\n\tif (!$lock || !flock($lock, LOCK_EX)){\n\t\t$lock && fclose($lock)\n\t\treturn null\n\t}\n\treturn $lock",
                        "line": 44,
                        "comments": "Takes an exclusive lock around one key's refresh cycle.\nWithout it concurrent callers each fire a refresh, and since that rotates the\nrefresh_token every loser is left holding a dead one.",
                        "bodyLine": 45
                    },
                    "access": {
                        "node": "static",
                        "visibility": null,
                        "name": "access",
                        "args": "$key, $tokenUrl, $clientId, $clientSecret, array $seed = []",
                        "type": "?string",
                        "operator": "method",
                        "body": "\t$token = static::read($key)\n\tif (static::valid($token)) return $token['access_token']\n\t$lock = static::lock($key)\n\tif (!$lock) return null\n\ttry {\n\t\t$token = static::read($key)\n\t\tif (!($token['refresh_token'] ?? null) && ($seed['refresh_token'] ?? null)){\n\t\t\t$token = ['refresh_token' => $seed['refresh_token']]\n\t\t\tstatic::write($key, $token)\n\t\t}\n\t\tif (static::valid($token)) return $token['access_token']\n\t\t$refresh = $token['refresh_token'] ?? null\n\t\tif (!$refresh || !$tokenUrl || !$clientId) return null\n\t\t$res = OAuth2::refresh($tokenUrl, $clientId, $clientSecret, $refresh)\n\t\tif (!($res['access_token'] ?? null)) return null\n\t\t$token = static::store($res, $refresh)\n\t\tstatic::write($key, $token)\n\t\treturn $token['access_token']\n\t} finally {\n\t\tflock($lock, LOCK_UN)\n\t\tfclose($lock)\n\t}",
                        "line": 55,
                        "bodyLine": 56
                    }
                },
                "functions": [],
                "assets": []
            },
            "Twilio": {
                "file": "/srv/control/phlo/resources/connectors/chat/Twilio.phlo",
                "class": "Twilio",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Twilio connector: send SMS and read message status",
                    "advice": "Needs account_sid and auth_token, plus either a from_number in E.164 or a messaging_service_sid; without one of the two the send is refused before it leaves. Twilio speaks form encoding rather than JSON, which sms() handles, so pass extra fields in Twilio's own capitalized names. A returned sid says it was accepted, not delivered; message() tells you what became of it.",
                    "extends": "Connector",
                    "package": "connectors",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@Connector creds:Twilio",
                    "tags": "twilio sms messaging connector"
                },
                "nodes": {
                    "section": {
                        "node": "const",
                        "visibility": null,
                        "name": "section",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'Twilio'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "base": {
                        "node": "method",
                        "visibility": null,
                        "name": "base",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "'https://api.twilio.com/2010-04-01/Accounts/'.($this->config['account_sid'] ?? void)",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "headers": {
                        "node": "method",
                        "visibility": null,
                        "name": "headers",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[static::basic($this->config['account_sid'] ?? void, $this->config['auth_token'] ?? void)]",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "fields": {
                        "node": "static",
                        "visibility": null,
                        "name": "fields",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "arr(\n\tsection: 'Twilio',\n\tconfig: arr(\n\t\taccount_sid: 'Account SID (ACxxxx)',\n\t\tfrom_number: 'Sender number in E.164, e.g. +31600000000 (use this or messaging_service_sid)',\n\t\tmessaging_service_sid: 'Messaging Service SID (optional alternative to from_number)',\n\t),\n\tsecret: arr(auth_token: 'Auth token'),\n)",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "sms": {
                        "node": "method",
                        "visibility": null,
                        "name": "sms",
                        "args": "$to, $body, array $extra = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('account_sid', 'auth_token')) return $m\n\t$from = $this->config['from_number'] ?? void\n\t$service = $this->config['messaging_service_sid'] ?? void\n\tif ($from === void && $service === void) return static::fail('Twilio from_number or messaging_service_sid required')\n\t$fields = ['To' => $to, 'Body' => $body] + $extra\n\tif ($service !== void) $fields['MessagingServiceSid'] = $service\n\telse $fields['From'] = $from\n\treturn $this->form('Messages.json', $fields)",
                        "line": 28,
                        "bodyLine": 29
                    },
                    "message": {
                        "node": "method",
                        "visibility": null,
                        "name": "message",
                        "args": "$sid",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('account_sid', 'auth_token')) return $m\n\treturn $this->get('Messages/'.$sid.'.json')",
                        "line": 39,
                        "bodyLine": 40
                    }
                },
                "functions": [],
                "assets": []
            }
        }
    },
    "DB": {
        "objs": {
            "DB": {
                "file": "/srv/control/phlo/resources/DB/DB.phlo",
                "class": "DB",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Database engine class",
                    "advice": "The shape every driver fills in, and the reason a model can move between MySQL, PostgreSQL, SQLite and a JSON file untouched. Reads come in named flavours, so ask for what you want back: record for one, records keyed by id, rows in order, column, pair and item. Anything you pass as an argument is bound, never pasted into the SQL, so a value from a visitor is safe by construction. A connection that went away is reconnected and the query is tried once more, which is what keeps a long-lived worker alive.",
                    "type": "abstract class",
                    "package": "database",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "php-ext:pdo",
                    "tags": "database pdo sql"
                },
                "nodes": {
                    "PDO": {
                        "node": "prop",
                        "visibility": null,
                        "name": "PDO",
                        "args": null,
                        "type": "\\PDO",
                        "operator": "arrow",
                        "body": "error('No PDO connector defined')",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "fieldQuotes": {
                        "node": "prop",
                        "visibility": null,
                        "name": "fieldQuotes",
                        "args": null,
                        "type": "string",
                        "operator": "value",
                        "body": "bt",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "savepoint": {
                        "node": "prop",
                        "visibility": null,
                        "name": "savepoint",
                        "args": null,
                        "type": "int",
                        "operator": "value",
                        "body": "0",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "insertIgnore": {
                        "node": "prop",
                        "visibility": null,
                        "name": "insertIgnore",
                        "args": null,
                        "type": "string",
                        "operator": "value",
                        "body": "' IGNORE'",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "insertOnConflict": {
                        "node": "prop",
                        "visibility": null,
                        "name": "insertOnConflict",
                        "args": null,
                        "type": "string",
                        "operator": "value",
                        "body": "void",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "load": {
                        "node": "method",
                        "visibility": null,
                        "name": "load",
                        "args": "string $table, string $columns = '*', string $where = void, string $joins = void, string $group = void, string $limit = void, string $order = void, ...$args",
                        "type": null,
                        "operator": "method",
                        "body": "\t$table = strpos($table, space) || strpos($table, dot) ? $table : \"$this->fieldQuotes$table$this->fieldQuotes\"\n\t$args && $where = ($where ? \"$where AND \" : void).loop(array_keys($args), fn($column) => $table.dot.$this->quoteId($column).'=?', ' AND ')\n\t$joins && $joins = \" $joins\"\n\t$where && $where = \" WHERE $where\"\n\t$group && $group = \" GROUP BY $group\"\n\t$order && $order = \" ORDER BY $order\"\n\t$limit && $limit = \" LIMIT $limit\"\n\t$query = \"SELECT $columns FROM $table$joins$where$group$order$limit\"\n\treturn $this->query($query, ...array_values($args))",
                        "line": 18,
                        "bodyLine": 19
                    },
                    "query": {
                        "node": "method",
                        "visibility": null,
                        "name": "query",
                        "args": "$query, ...$args",
                        "type": null,
                        "operator": "arrow",
                        "body": "$this->queryRun($query, $args, true)",
                        "line": 30,
                        "bodyLine": 30
                    },
                    "queryRun": {
                        "node": "method",
                        "visibility": null,
                        "name": "queryRun",
                        "args": "$query, $args, $retry",
                        "type": "\\PDOStatement",
                        "operator": "method",
                        "body": "\ttry {\n\t\tif (!$args) $stmt = $this->PDO->query($query)\n\t\telse {\n\t\t\t$stmt = $this->PDO->prepare($query)\n\t\t\t$stmt->execute($args)\n\t\t}\n\t\tif (debug){\n\t\t\t$match = regex('/\\b(UPDATE|INSERT INTO|DELETE FROM|FROM)\\b\\s+([`\"\\[]?\\w+[`\"\\]]?)/i', strtr($query, [$this->fieldQuotes => void]))\n\t\t\t$where = strtr(regex('/\\bWHERE (\\b.+)/is', $query)[1] ?? void, [' ORDER BY' => void])\n\t\t\t$match && debug(\"Q: $match[1] $match[2]\".strtr(rtrim(\" $where \"), [dq => void]).\" (\".$stmt->rowCount().\")\")\n\t\t}\n\t\treturn $stmt\n\t}\n\tcatch (\\PDOException $e){\n\t\t// Retry only idempotent read statements outside a transaction. WITH is excluded: a `WITH ... UPDATE`\n\t\t// or `WITH ... DELETE` CTE is a mutation that may already have run before the connection dropped\n\t\t// (error 2013), and a reconnect would start a fresh, transaction-less session.\n\t\tif (!$retry || !$this->goneAway($e) || $this->PDO->inTransaction() || !preg_match('/^\\s*\\(*\\s*(SELECT|SHOW|EXPLAIN|DESCRIBE|DESC)\\b/i', $query)) error('Database error'.colon.lf.$query.lf.lf.$e->getMessage())\n\t\tunset($this->PDO)\n\t\treturn $this->queryRun($query, $args, false)\n\t}",
                        "line": 37,
                        "comments": "Runs a statement, retrying once when the connection was lost.\nA worker can hold a MySQL connection past its wait_timeout, and the next request's\nfirst query then throws \"server has gone away\". The retry drops the cached PDO, which\nreconnects on next access. Only connection errors are\nretried, never a normal SQL failure, so a mutation is never silently re-run.",
                        "bodyLine": 38
                    },
                    "goneAway": {
                        "node": "method",
                        "visibility": null,
                        "name": "goneAway",
                        "args": "$e",
                        "type": "bool",
                        "operator": "arrow",
                        "body": "in_array((int)($e->errorInfo[1] ?? 0), [2006, 2013], true) || stripos($e->getMessage(), 'gone away') !== false || stripos($e->getMessage(), 'lost connection') !== false",
                        "line": 61,
                        "bodyLine": 61
                    },
                    "column": {
                        "node": "method",
                        "visibility": null,
                        "name": "column",
                        "args": "...$args",
                        "type": "array",
                        "operator": "arrow",
                        "body": "$this->load(...$args)->fetchAll(\\PDO::FETCH_COLUMN)",
                        "line": 63,
                        "bodyLine": 63
                    },
                    "item": {
                        "node": "method",
                        "visibility": null,
                        "name": "item",
                        "args": "...$args",
                        "type": null,
                        "operator": "arrow",
                        "body": "($v = $this->load(...$args)->fetch(\\PDO::FETCH_COLUMN)) === false ? null : $v",
                        "line": 64,
                        "bodyLine": 64
                    },
                    "pair": {
                        "node": "method",
                        "visibility": null,
                        "name": "pair",
                        "args": "...$args",
                        "type": "array",
                        "operator": "arrow",
                        "body": "$this->load(...$args)->fetchAll(\\PDO::FETCH_KEY_PAIR)",
                        "line": 65,
                        "bodyLine": 65
                    },
                    "group": {
                        "node": "method",
                        "visibility": null,
                        "name": "group",
                        "args": "...$args",
                        "type": "array",
                        "operator": "arrow",
                        "body": "$this->load(...$args)->fetchAll(\\PDO::FETCH_GROUP|\\PDO::FETCH_CLASS, obj::class)",
                        "line": 66,
                        "bodyLine": 66
                    },
                    "records": {
                        "node": "method",
                        "visibility": null,
                        "name": "records",
                        "args": "...$args",
                        "type": "array",
                        "operator": "arrow",
                        "body": "$this->load(...$args)->fetchAll(\\PDO::FETCH_CLASS|\\PDO::FETCH_UNIQUE, obj::class)",
                        "line": 67,
                        "bodyLine": 67
                    },
                    "rows": {
                        "node": "method",
                        "visibility": null,
                        "name": "rows",
                        "args": "...$args",
                        "type": "array",
                        "operator": "arrow",
                        "body": "$this->load(...$args)->fetchAll(\\PDO::FETCH_CLASS, obj::class)",
                        "line": 68,
                        "bodyLine": 68
                    },
                    "record": {
                        "node": "method",
                        "visibility": null,
                        "name": "record",
                        "args": "...$args",
                        "type": "?obj",
                        "operator": "arrow",
                        "body": "$this->load(...$args)->fetchObject(obj::class) ?: null",
                        "line": 69,
                        "bodyLine": 69
                    },
                    "quoteList": {
                        "node": "method",
                        "visibility": null,
                        "name": "quoteList",
                        "args": "array $ids",
                        "type": "string",
                        "operator": "arrow",
                        "body": "loop($ids, fn($id) => $this->PDO->quote((string)$id), comma)",
                        "line": 70,
                        "bodyLine": 70
                    },
                    "quoteId": {
                        "node": "method",
                        "visibility": null,
                        "name": "quoteId",
                        "args": "$id",
                        "type": "string",
                        "operator": "arrow",
                        "body": "$this->fieldQuotes.str_replace($this->fieldQuotes, $this->fieldQuotes.$this->fieldQuotes, (string)$id).$this->fieldQuotes",
                        "line": 75,
                        "comments": "Safely quotes a SQL identifier such as a column or table name.\nIt wraps the name in the connector's quote char and doubles any embedded quote, so an\nattacker-controlled array key cannot break out of the identifier. Values are ?-bound.",
                        "bodyLine": 75
                    },
                    "create": {
                        "node": "method",
                        "visibility": null,
                        "name": "create",
                        "args": "string $table, ...$data",
                        "type": null,
                        "operator": "method",
                        "body": "\tif ($ignore = $data['ignore'] ?? false) unset($data['ignore'])\n\t$columns = implode(comma, loop(array_keys($data), fn($k) => $this->quoteId($k)))\n\t$values = implode(comma, array_fill(0, count($data), qm))\n\t$query = \"INSERT\".($ignore ? $this->insertIgnore : void).\" INTO $table ($columns) VALUES ($values)\".($ignore ? $this->insertOnConflict : void)\n\t$this->query($query, ...array_values(loop($data, fn($value) => is_a($value, obj::class) ? $value->id : $value)))\n\treturn $this->lastId() ?: ($data['id'] ?? null)",
                        "line": 77,
                        "bodyLine": 78
                    },
                    "lastId": {
                        "node": "method",
                        "visibility": null,
                        "name": "lastId",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "$this->PDO->lastInsertId()",
                        "line": 86,
                        "bodyLine": 86
                    },
                    "change": {
                        "node": "method",
                        "visibility": null,
                        "name": "change",
                        "args": "string $table, string $where, ...$data",
                        "type": "int",
                        "operator": "method",
                        "body": "\t$whereCount = substr_count($where, qm)\n\t$updates = isset($data['updates']) ? $data['updates'] : void\n\tunset($data['updates'])\n\t$updates .= (($wheres = array_slice(array_keys($data), $whereCount)) && $updates ? comma : void).loop($wheres, fn($key) => $this->quoteId($key).'=?', comma)\n\t$query = \"UPDATE $table SET $updates WHERE $where\"\n\t$args = array_values([...array_slice($data, $whereCount), ...array_slice($data, 0, $whereCount)])\n\treturn $this->query($query, ...$args)->rowCount()",
                        "line": 88,
                        "bodyLine": 89
                    },
                    "delete": {
                        "node": "method",
                        "visibility": null,
                        "name": "delete",
                        "args": "string $table, string $where, ...$args",
                        "type": "int",
                        "operator": "arrow",
                        "body": "$this->query(\"DELETE FROM $table WHERE $where\", ...$args)->rowCount()",
                        "line": 98,
                        "bodyLine": 98
                    },
                    "begin": {
                        "node": "method",
                        "visibility": null,
                        "name": "begin",
                        "args": null,
                        "type": "?bool",
                        "operator": "arrow",
                        "body": "$this->PDO->beginTransaction()",
                        "line": 99,
                        "bodyLine": 99
                    },
                    "commit": {
                        "node": "method",
                        "visibility": null,
                        "name": "commit",
                        "args": null,
                        "type": "?bool",
                        "operator": "arrow",
                        "body": "$this->PDO->commit()",
                        "line": 100,
                        "bodyLine": 100
                    },
                    "rollback": {
                        "node": "method",
                        "visibility": null,
                        "name": "rollback",
                        "args": null,
                        "type": "?bool",
                        "operator": "arrow",
                        "body": "$this->PDO->inTransaction() && $this->PDO->rollBack()",
                        "line": 101,
                        "bodyLine": 101
                    },
                    "transaction": {
                        "node": "method",
                        "visibility": null,
                        "name": "transaction",
                        "args": "$callback",
                        "type": null,
                        "operator": "method",
                        "body": "\tif (!$this->PDO->inTransaction()){\n\t\t$this->begin\n\t\ttry {\n\t\t\t$result = $callback()\n\t\t\t$this->commit\n\t\t\treturn $result\n\t\t} catch (\\Throwable $e){\n\t\t\t$this->rollback\n\t\t\tthrow $e\n\t\t}\n\t}\n\t// Nested: a savepoint gives the inner unit its own rollback point, so its failure\n\t// (e.g. an audit insert) is undone even when the outer transaction commits.\n\t$sp = 'phlo_sp_'.(++$this->savepoint)\n\t$this->PDO->exec('SAVEPOINT '.$sp)\n\ttry {\n\t\t$result = $callback()\n\t\t$this->PDO->exec('RELEASE SAVEPOINT '.$sp)\n\t\t$this->savepoint--\n\t\treturn $result\n\t} catch (\\Throwable $e){\n\t\t$this->PDO->exec('ROLLBACK TO SAVEPOINT '.$sp)\n\t\t$this->PDO->exec('RELEASE SAVEPOINT '.$sp)\n\t\t$this->savepoint--\n\t\tthrow $e\n\t}",
                        "line": 103,
                        "bodyLine": 104
                    }
                },
                "functions": [],
                "assets": []
            },
            "JSONDB": {
                "file": "/srv/control/phlo/resources/DB/JSONDB.phlo",
                "class": "JSONDB",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "JSON file database driver. One JSONDB instance = one JSON file = one model table. No joins, no transactions, no schema introspection.",
                    "advice": "A model in a JSON file, for a set of records that stays small and readable: no joins, no transactions, and every write rewrites the whole file. It understands only equality and IN in a where, and raw SQL is refused outright, so keep the model plain. Move to SQLite the moment the file grows or two processes start writing.",
                    "extends": "DB",
                    "package": "database",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@DB @JSON_result",
                    "tags": "json database file storage"
                },
                "nodes": {
                    "__handle": {
                        "node": "static",
                        "visibility": null,
                        "name": "__handle",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "\"JSONDB/$file\"",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "__construct": {
                        "node": "method",
                        "visibility": null,
                        "name": "__construct",
                        "args": "private string $file",
                        "type": null,
                        "operator": "method",
                        "body": "\t$dir = dirname($this->file)\n\tis_dir($dir) || mkdir($dir, 0755, true) || error(\"JSONDB: cannot create dir $dir\")",
                        "line": 13,
                        "bodyLine": 14
                    },
                    "PDO": {
                        "node": "prop",
                        "visibility": null,
                        "name": "PDO",
                        "args": null,
                        "type": "\\PDO",
                        "operator": "arrow",
                        "body": "error('JSONDB driver does not use PDO')",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "fieldQuotes": {
                        "node": "prop",
                        "visibility": null,
                        "name": "fieldQuotes",
                        "args": null,
                        "type": "string",
                        "operator": "value",
                        "body": "void",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "lastInsertedId": {
                        "node": "prop",
                        "visibility": null,
                        "name": "lastInsertedId",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "null",
                        "line": 19,
                        "bodyLine": 19
                    },
                    "quoteList": {
                        "node": "method",
                        "visibility": null,
                        "name": "quoteList",
                        "args": "array $ids",
                        "type": "string",
                        "operator": "arrow",
                        "body": "dq.implode(dq.comma.dq, $ids).dq",
                        "line": 26,
                        "comments": "Builds the quoted id list an IN clause expects.\nThis driver has no PDO and runs no SQL, so objFilter parses the list itself and strips\nthe quotes again; a literal-quoted list is what it expects rather than PDO escaping.\nLimitation: it splits on commas and trims quotes, so a string primary key that itself\ncontains a comma or a quote is not matched. Numeric and token-style ids are fine.",
                        "bodyLine": 26
                    },
                    "objRead": {
                        "node": "method",
                        "visibility": null,
                        "name": "objRead",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "file_exists($this->file) ? json_decode(file_get_contents($this->file), true) ?: [] : []",
                        "line": 28,
                        "bodyLine": 28
                    },
                    "objWrite": {
                        "node": "method",
                        "visibility": null,
                        "name": "objWrite",
                        "args": "array $data",
                        "type": "int|false",
                        "operator": "arrow",
                        "body": "file_put_contents($this->file, json_encode(array_values($data), jsonPretty), LOCK_EX)",
                        "line": 29,
                        "bodyLine": 29
                    },
                    "objNextId": {
                        "node": "method",
                        "visibility": null,
                        "name": "objNextId",
                        "args": "array $data",
                        "type": "int",
                        "operator": "arrow",
                        "body": "$data ? (int)max(array_column($data, 'id')) + 1 : 1",
                        "line": 30,
                        "bodyLine": 30
                    },
                    "objFilter": {
                        "node": "method",
                        "visibility": null,
                        "name": "objFilter",
                        "args": "array $data, string $where = void, ...$args",
                        "type": "array",
                        "operator": "method",
                        "body": "\tif (!$where) return $data\n\t$filtered = []\n\tforeach ($data AS $row){\n\t\t$match = true\n\t\t$parts = preg_split('/\\s+AND\\s+/i', $where)\n\t\t$argIndex = 0\n\t\tforeach ($parts AS $part){\n\t\t\tif (preg_match('/^[`\"]?(\\w+)[`\"]?\\s*=\\s*\\?$/', trim($part), $m)){\n\t\t\t\t$column = $m[1]\n\t\t\t\t$value = $args[$argIndex++] ?? null\n\t\t\t\tif (($row[$column] ?? null) != $value) $match = false\n\t\t\t}\n\t\t\telseif (preg_match('/^[`\"]?(\\w+)[`\"]?\\s+IN\\s*\\((.+)\\)$/i', trim($part), $m)){\n\t\t\t\t$column = $m[1]\n\t\t\t\t$ids = array_map(fn($v) => trim($v, \"\\\"' \"), explode(comma, $m[2]))\n\t\t\t\tif (!in_array($row[$column] ?? null, $ids)) $match = false\n\t\t\t}\n\t\t}\n\t\t$match && $filtered[] = $row\n\t}\n\treturn $filtered",
                        "line": 32,
                        "bodyLine": 33
                    },
                    "objSelect": {
                        "node": "method",
                        "visibility": null,
                        "name": "objSelect",
                        "args": "string $where = void, string $limit = void, string $order = void, ...$args",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$data = $this->objFilter($this->objRead(), $where, ...array_values($args))\n\tif ($order){\n\t\t$desc = str_contains($order, 'DESC')\n\t\t$col = trim(preg_replace('/\\s+(ASC|DESC)/i', void, $order), '` ')\n\t\tusort($data, fn($a, $b) => $desc ? ($b[$col] ?? 0) <=> ($a[$col] ?? 0) : ($a[$col] ?? 0) <=> ($b[$col] ?? 0))\n\t}\n\t$limit && $data = array_slice($data, 0, (int)$limit)\n\treturn $data",
                        "line": 56,
                        "bodyLine": 57
                    },
                    "create": {
                        "node": "method",
                        "visibility": null,
                        "name": "create",
                        "args": "string $table, ...$data",
                        "type": null,
                        "operator": "method",
                        "body": "\tif ($ignore = $data['ignore'] ?? false) unset($data['ignore'])\n\t$all = $this->objRead()\n\t$data['id'] ??= $this->objNextId($all)\n\tforeach ($data AS $key => $value) is_a($value, 'obj') && $data[$key] = $value->id\n\tif ($ignore){\n\t\tforeach ($all AS $row) if (($row['id'] ?? null) == $data['id']) return $data['id']\n\t}\n\t$all[] = $data\n\t$this->objWrite($all)\n\t$this->lastInsertedId = $data['id']\n\treturn $data['id']",
                        "line": 70,
                        "comments": "ignore compares ids loosely, so the string \"7\" and the number 7 count as one row.\nThe check scans the whole file. An obj passed as a value is stored as its id, which is\nwhat lets a parent relation work in a driver that knows nothing about relations.",
                        "bodyLine": 71
                    },
                    "change": {
                        "node": "method",
                        "visibility": null,
                        "name": "change",
                        "args": "string $table, string $where, ...$data",
                        "type": "int",
                        "operator": "method",
                        "body": "\t$all = $this->objRead()\n\t$whereCount = substr_count($where, qm)\n\t$whereArgs = array_slice(array_values($data), 0, $whereCount)\n\t$updates = array_slice($data, $whereCount, null, true)\n\t$changed = 0\n\tforeach ($all AS &$row){\n\t\tif ($this->objFilter([$row], $where, ...$whereArgs)){\n\t\t\tforeach ($updates AS $key => $value) $row[$key] = $value\n\t\t\t$changed++\n\t\t}\n\t}\n\tunset($row)\n\t$this->objWrite($all)\n\treturn $changed",
                        "line": 84,
                        "bodyLine": 85
                    },
                    "delete": {
                        "node": "method",
                        "visibility": null,
                        "name": "delete",
                        "args": "string $table, string $where, ...$args",
                        "type": "int",
                        "operator": "method",
                        "body": "\t$all = $this->objRead()\n\t$matching = $this->objFilter($all, $where, ...$args)\n\t$matchIds = array_column($matching, 'id')\n\t$remaining = array_values(array_filter($all, fn($row) => !in_array($row['id'] ?? null, $matchIds)))\n\t$this->objWrite($remaining)\n\treturn count($matching)",
                        "line": 101,
                        "bodyLine": 102
                    },
                    "load": {
                        "node": "method",
                        "visibility": null,
                        "name": "load",
                        "args": "string $table, string $columns = '*', string $where = void, string $joins = void, string $group = void, string $limit = void, string $order = void, ...$args",
                        "type": "JSON_result",
                        "operator": "method",
                        "body": "\t!$where && $args && $where = loop(array_keys($args), fn($column) => \"$column=?\", ' AND ')\n\t$data = $this->objSelect($where, $limit, $order, ...array_values($args))\n\treturn %JSON_result($data)",
                        "line": 110,
                        "bodyLine": 111
                    },
                    "query": {
                        "node": "method",
                        "visibility": null,
                        "name": "query",
                        "args": "$query, ...$args",
                        "type": null,
                        "operator": "arrow",
                        "body": "error('JSONDB driver does not support raw SQL queries')",
                        "line": 115,
                        "bodyLine": 115
                    },
                    "begin": {
                        "node": "method",
                        "visibility": null,
                        "name": "begin",
                        "args": null,
                        "type": "?bool",
                        "operator": "arrow",
                        "body": "null",
                        "line": 117,
                        "bodyLine": 117
                    },
                    "commit": {
                        "node": "method",
                        "visibility": null,
                        "name": "commit",
                        "args": null,
                        "type": "?bool",
                        "operator": "arrow",
                        "body": "null",
                        "line": 118,
                        "bodyLine": 118
                    },
                    "rollback": {
                        "node": "method",
                        "visibility": null,
                        "name": "rollback",
                        "args": null,
                        "type": "?bool",
                        "operator": "arrow",
                        "body": "null",
                        "line": 119,
                        "bodyLine": 119
                    }
                },
                "functions": [],
                "assets": []
            },
            "JSON_result": {
                "file": "/srv/control/phlo/resources/DB/JSON.result.phlo",
                "class": "JSON_result",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Minimal PDOStatement-like wrapper for JSONDB result arrays",
                    "advice": "Exists so JSONDB can hand back something that behaves like a PDO statement, which is what lets the ORM stay unaware of which driver answered. You do not build one yourself; you meet it as the return value of a JSONDB query.",
                    "package": "database",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "json database result"
                },
                "nodes": {
                    "__handle": {
                        "node": "static",
                        "visibility": null,
                        "name": "__handle",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "null",
                        "line": 10,
                        "bodyLine": 10
                    },
                    "data": {
                        "node": "prop",
                        "visibility": null,
                        "name": "data",
                        "args": null,
                        "type": "array",
                        "operator": "value",
                        "body": "[]",
                        "line": 11,
                        "bodyLine": 11
                    },
                    "__construct": {
                        "node": "method",
                        "visibility": null,
                        "name": "__construct",
                        "args": "array $data",
                        "type": null,
                        "operator": "arrow",
                        "body": "$this->data = $data",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "fetchAll": {
                        "node": "method",
                        "visibility": null,
                        "name": "fetchAll",
                        "args": "$mode = 2",
                        "type": "array",
                        "operator": "method",
                        "body": "\tif ($mode === \\PDO::FETCH_COLUMN) return loop($this->data, fn($row) => reset((array)$row))\n\tif ($mode === \\PDO::FETCH_KEY_PAIR){\n\t\t$out = []\n\t\tforeach ($this->data AS $row){\n\t\t\t$vals = array_values((array)$row)\n\t\t\t$out[$vals[0] ?? null] = $vals[1] ?? null\n\t\t}\n\t\treturn $out\n\t}\n\tif (($mode & (\\PDO::FETCH_CLASS | \\PDO::FETCH_UNIQUE)) === (\\PDO::FETCH_CLASS | \\PDO::FETCH_UNIQUE)){\n\t\t$out = []\n\t\tforeach ($this->data AS $row){\n\t\t\t$o = new obj\n\t\t\tforeach ((array)$row AS $k => $v) $o->$k = $v\n\t\t\t$out[$row['id'] ?? count($out)] = $o\n\t\t}\n\t\treturn $out\n\t}\n\tif (($mode & \\PDO::FETCH_CLASS) === \\PDO::FETCH_CLASS){\n\t\t$out = []\n\t\tforeach ($this->data AS $row){\n\t\t\t$o = new obj\n\t\t\tforeach ((array)$row AS $k => $v) $o->$k = $v\n\t\t\t$out[] = $o\n\t\t}\n\t\treturn $out\n\t}\n\treturn $this->data",
                        "line": 14,
                        "bodyLine": 15
                    },
                    "fetchObject": {
                        "node": "method",
                        "visibility": null,
                        "name": "fetchObject",
                        "args": "$class = 'obj'",
                        "type": "?obj",
                        "operator": "method",
                        "body": "\tif (!$this->data) return null\n\t$row = reset($this->data)\n\t$o = new $class\n\tforeach ((array)$row AS $k => $v) $o->$k = $v\n\treturn $o",
                        "line": 45,
                        "bodyLine": 46
                    },
                    "fetch": {
                        "node": "method",
                        "visibility": null,
                        "name": "fetch",
                        "args": "$mode = 2",
                        "type": null,
                        "operator": "method",
                        "body": "\tif (!$this->data) return null\n\t$row = reset($this->data)\n\tif ($mode === \\PDO::FETCH_COLUMN) return reset((array)$row)\n\treturn $row",
                        "line": 53,
                        "bodyLine": 54
                    },
                    "fetchColumn": {
                        "node": "method",
                        "visibility": null,
                        "name": "fetchColumn",
                        "args": "$col = 0",
                        "type": null,
                        "operator": "method",
                        "body": "\tif (!$this->data) return false\n\t$row = reset($this->data)\n\t$vals = array_values((array)$row)\n\treturn $vals[$col] ?? false",
                        "line": 60,
                        "bodyLine": 61
                    },
                    "rowCount": {
                        "node": "method",
                        "visibility": null,
                        "name": "rowCount",
                        "args": null,
                        "type": "int",
                        "operator": "arrow",
                        "body": "count($this->data)",
                        "line": 67,
                        "bodyLine": 67
                    }
                },
                "functions": [],
                "assets": []
            },
            "model": {
                "file": "/srv/control/phlo/resources/DB/model.phlo",
                "class": "model",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Phlo ORM class with unified columns and schema",
                    "advice": "A model is a class with fields, and everything else follows from that: columns, form, validation and relations. Named arguments in a read are equality, so records(active: 1) is a where and nothing more; reach for query() when you need a comparison, a LIKE or an IN. A parent field gives back the record itself rather than an id, and children and many-relations are fetched once for a whole result set instead of per row. Set objValidate to have field rules enforced on save, objAudit to log every change, and objCache to keep reads in APCu.",
                    "type": "abstract class",
                    "package": "database",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@DB apcu? audit?",
                    "tags": "orm model database records schema"
                },
                "nodes": {
                    "DB": {
                        "node": "static",
                        "visibility": null,
                        "name": "DB",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "error('No database engine configured for '.static::class)",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "objCache": {
                        "node": "static",
                        "visibility": null,
                        "name": "objCache",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "false",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "objRecordLimit": {
                        "node": "static",
                        "visibility": null,
                        "name": "objRecordLimit",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "10000",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "objAudit": {
                        "node": "static",
                        "visibility": null,
                        "name": "objAudit",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "false",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "objValidate": {
                        "node": "static",
                        "visibility": null,
                        "name": "objValidate",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "false",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "idColumn": {
                        "node": "static",
                        "visibility": null,
                        "name": "idColumn",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'id'",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "idType": {
                        "node": "static",
                        "visibility": null,
                        "name": "idType",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'int'",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "canView": {
                        "node": "static",
                        "visibility": null,
                        "name": "canView",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "true",
                        "line": 20,
                        "bodyLine": 20
                    },
                    "canCreate": {
                        "node": "static",
                        "visibility": null,
                        "name": "canCreate",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "true",
                        "line": 21,
                        "bodyLine": 21
                    },
                    "canChange": {
                        "node": "static",
                        "visibility": null,
                        "name": "canChange",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "true",
                        "line": 22,
                        "bodyLine": 22
                    },
                    "canDelete": {
                        "node": "static",
                        "visibility": null,
                        "name": "canDelete",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "true",
                        "line": 23,
                        "bodyLine": 23
                    },
                    "state": {
                        "node": "static",
                        "visibility": null,
                        "name": "state",
                        "args": null,
                        "type": "obj",
                        "operator": "arrow",
                        "body": "%req->model ??= obj(meta: [], records: [], errors: [])",
                        "line": 25,
                        "bodyLine": 25
                    },
                    "columns": {
                        "node": "static",
                        "visibility": null,
                        "name": "columns",
                        "args": null,
                        "type": "string",
                        "operator": "method",
                        "body": "\tif (isset(static::$columns)) return static::$columns\n\tif (!method_exists(static::class, 'schema')) return static::$table.'.*'\n\t$state = static::state()\n\t$key = spl_object_id(static::DB()).':'.static::DB()->fieldQuotes.':'.static::$table\n\treturn $state->meta[static::class]['columns'][$key] ??= static::_columns()",
                        "line": 26,
                        "bodyLine": 27
                    },
                    "_columns": {
                        "node": "static",
                        "visibility": null,
                        "name": "_columns",
                        "args": null,
                        "type": "string",
                        "operator": "method",
                        "body": "\t$fq = static::DB()->fieldQuotes\n\t$list = array_merge(...array_values(array_filter(loop(static::fields(), fn($field) => loop($field->objColumns, fn($col) => static::$table.\"$fq.$fq\".$col)))))\n\treturn $fq.implode(\"$fq,$fq\", $list).$fq",
                        "line": 33,
                        "bodyLine": 34
                    },
                    "fields": {
                        "node": "static",
                        "visibility": null,
                        "name": "fields",
                        "args": null,
                        "type": "array",
                        "operator": "method",
                        "body": "\tif (!method_exists(static::class, 'schema')) return static::$fields ?? []\n\t$state = static::state()\n\treturn $state->meta[static::class]['fields'] ??= static::_fields()",
                        "line": 38,
                        "bodyLine": 39
                    },
                    "_fields": {
                        "node": "static",
                        "visibility": null,
                        "name": "_fields",
                        "args": null,
                        "type": "array",
                        "operator": "method",
                        "body": "\t$reserved = ['table','order','fields','columns','create','change','delete','records','record','column','item','pair','DB','objCache','objState','objSave','objGet','objAudit','objValidate','objErrors','idColumn','idType']\n\t$fields = loop(static::schema(), fn($field, $column) => last($field->name ??= $column, $field->type === 'parent' && $field->obj ??= $column, $field))\n\tforeach ($reserved AS $word) isset($fields[$word]) && error(\"Reserved column name '$word' in \".static::class)\n\treturn $fields",
                        "line": 43,
                        "bodyLine": 44
                    },
                    "field": {
                        "node": "static",
                        "visibility": null,
                        "name": "field",
                        "args": "$name",
                        "type": null,
                        "operator": "arrow",
                        "body": "static::fields()[$name]",
                        "line": 49,
                        "bodyLine": 49
                    },
                    "create": {
                        "node": "static",
                        "visibility": null,
                        "name": "create",
                        "args": "...$args",
                        "type": "?static",
                        "operator": "method",
                        "body": "\t$class = static::class\n\tif (static::objValidate() && !static::objRunValidation($args)) return null\n\t$record = new $class(...$args)\n\tmethod_exists(static::class, 'beforeSave') && $record->beforeSave()\n\tmethod_exists(static::class, 'beforeCreate') && $record->beforeCreate()\n\t$pk = static::idColumn()\n\treturn static::objAudit() ? static::transaction(fn() => static::objCreateCommit($record, $pk)) : static::objCreateCommit($record, $pk)",
                        "line": 51,
                        "bodyLine": 52
                    },
                    "objCreateCommit": {
                        "node": "static",
                        "visibility": null,
                        "name": "objCreateCommit",
                        "args": "$record, $pk",
                        "type": "?static",
                        "operator": "method",
                        "body": "\t$id = static::createRecord(...$record)\n\t$record = static::record(...[$pk => $record->$pk ?? $id])\n\tmethod_exists(static::class, 'afterCreate') && $record->afterCreate()\n\tmethod_exists(static::class, 'afterSave') && $record->afterSave()\n\tstatic::objAudit() && audit::log($record, 'create', [], $record->objData)\n\treturn $record",
                        "line": 61,
                        "bodyLine": 62
                    },
                    "objRunValidation": {
                        "node": "static",
                        "visibility": null,
                        "name": "objRunValidation",
                        "args": "$data",
                        "type": "bool",
                        "operator": "method",
                        "body": "\t$errors = []\n\t$fields = static::fields()\n\tforeach ($data AS $column => $value){\n\t\tif (!($field = $fields[$column] ?? null) || !method_exists($field, 'objValidate')) continue\n\t\tif ($error = $field->objValidate($value)) $errors[$column] = $error\n\t}\n\tstatic::state()->errors[static::class] = $errors\n\treturn empty($errors)",
                        "line": 70,
                        "bodyLine": 71
                    },
                    "objErrors": {
                        "node": "static",
                        "visibility": null,
                        "name": "objErrors",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "static::state()->errors[static::class] ?? []",
                        "line": 81,
                        "bodyLine": 81
                    },
                    "createRecord": {
                        "node": "static",
                        "visibility": null,
                        "name": "createRecord",
                        "args": "...$args",
                        "type": null,
                        "operator": "arrow",
                        "body": "static::DB()->create(static::$table, ...$args)",
                        "line": 82,
                        "bodyLine": 82
                    },
                    "change": {
                        "node": "static",
                        "visibility": null,
                        "name": "change",
                        "args": "$where, ...$args",
                        "type": "int",
                        "operator": "method",
                        "body": "\tif (!static::objAudit()) return static::DB()->change(static::$table, $where, ...$args)\n\t$pk = static::idColumn()\n\t$bindings = array_values(array_filter($args, 'is_int', ARRAY_FILTER_USE_KEY))\n\treturn static::transaction(function() use ($where, $args, $bindings, $pk){\n\t\t$old = static::DB()->query('SELECT '.static::$table.'.* FROM '.static::$table.' WHERE '.$where, ...$bindings)->fetchAll(\\PDO::FETCH_CLASS, static::class)\n\t\t$result = static::DB()->change(static::$table, $where, ...$args)\n\t\tforeach ($old AS $record){\n\t\t\t$fresh = static::record(...[$pk => $record->$pk])\n\t\t\t$fresh && audit::log($fresh, 'update', $record->objData, $fresh->objData)\n\t\t}\n\t\treturn $result\n\t})",
                        "line": 83,
                        "bodyLine": 84
                    },
                    "delete": {
                        "node": "static",
                        "visibility": null,
                        "name": "delete",
                        "args": "$where, ...$args",
                        "type": "int",
                        "operator": "method",
                        "body": "\tif (method_exists(static::class, 'beforeDelete') || method_exists(static::class, 'afterDelete') || static::objAudit()){\n\t\t$records = static::DB()->query('SELECT '.static::$table.'.* FROM '.static::$table.' WHERE '.$where, ...$args)->fetchAll(\\PDO::FETCH_CLASS, static::class)\n\t\tforeach ($records AS $record) method_exists(static::class, 'beforeDelete') && $record->beforeDelete()\n\t\treturn static::objAudit() ? static::transaction(fn() => static::objDeleteCommit($where, $args, $records)) : static::objDeleteCommit($where, $args, $records)\n\t}\n\treturn static::DB()->delete(static::$table, $where, ...$args)",
                        "line": 98,
                        "bodyLine": 99
                    },
                    "objDeleteCommit": {
                        "node": "static",
                        "visibility": null,
                        "name": "objDeleteCommit",
                        "args": "$where, $args, $records",
                        "type": "int",
                        "operator": "method",
                        "body": "\t$result = static::DB()->delete(static::$table, $where, ...$args)\n\tforeach ($records AS $record){\n\t\tmethod_exists(static::class, 'afterDelete') && $record->afterDelete()\n\t\tstatic::objAudit() && audit::log($record, 'delete', $record->objData, [])\n\t}\n\treturn $result",
                        "line": 107,
                        "bodyLine": 108
                    },
                    "objLogChange": {
                        "node": "static",
                        "visibility": null,
                        "name": "objLogChange",
                        "args": "$where, ...$args",
                        "type": "int",
                        "operator": "arrow",
                        "body": "static::change($where, ...$args)",
                        "line": 116,
                        "bodyLine": 116
                    },
                    "objSave": {
                        "node": "method",
                        "visibility": null,
                        "name": "objSave",
                        "args": null,
                        "type": "?static",
                        "operator": "method",
                        "body": "\t$pk = static::idColumn()\n\t$pkValue = $this->$pk ?? $this->id ?? null\n\t$pkValue || error('Can\\'t save '.static::class.' record without '.$pk)\n\t$old = static::record(...[$pk => $pkValue])\n\t$isNew = !$old\n\tmethod_exists(static::class, 'beforeSave') && $this->beforeSave($old)\n\tif ($isNew){\n\t\tmethod_exists(static::class, 'beforeCreate') && $this->beforeCreate()\n\t\t$saved = static::objAudit() ? static::transaction(fn() => $this->objSaveCreate($pk, $pkValue)) : $this->objSaveCreate($pk, $pkValue)\n\t}\n\telse {\n\t\tmethod_exists(static::class, 'beforeChange') && $this->beforeChange($old)\n\t\tstatic::change($pk.'=?', $pkValue, ...$this)\n\t\t$saved = static::record(...[$pk => $pkValue])\n\t\tmethod_exists(static::class, 'afterChange') && $saved->afterChange($old)\n\t}\n\tmethod_exists(static::class, 'afterSave') && $saved->afterSave($old)\n\treturn $saved",
                        "line": 118,
                        "bodyLine": 119
                    },
                    "objSaveCreate": {
                        "node": "method",
                        "visibility": null,
                        "name": "objSaveCreate",
                        "args": "$pk, $pkValue",
                        "type": "?static",
                        "operator": "method",
                        "body": "\tstatic::createRecord(...$this)\n\t$saved = static::record(...[$pk => $pkValue])\n\tmethod_exists(static::class, 'afterCreate') && $saved->afterCreate()\n\tstatic::objAudit() && audit::log($saved, 'create', [], $saved->objData)\n\treturn $saved",
                        "line": 139,
                        "bodyLine": 140
                    },
                    "transaction": {
                        "node": "static",
                        "visibility": null,
                        "name": "transaction",
                        "args": "$callback",
                        "type": null,
                        "operator": "arrow",
                        "body": "static::DB()->transaction($callback)",
                        "line": 147,
                        "bodyLine": 147
                    },
                    "query": {
                        "node": "static",
                        "visibility": null,
                        "name": "query",
                        "args": null,
                        "type": "query",
                        "operator": "arrow",
                        "body": "phlo('query', class: static::class)",
                        "line": 148,
                        "bodyLine": 148
                    },
                    "column": {
                        "node": "static",
                        "visibility": null,
                        "name": "column",
                        "args": "...$args",
                        "type": "array",
                        "operator": "arrow",
                        "body": "static::recordsLoad($args, 'fetchAll', [\\PDO::FETCH_COLUMN])",
                        "line": 150,
                        "bodyLine": 150
                    },
                    "item": {
                        "node": "static",
                        "visibility": null,
                        "name": "item",
                        "args": "...$args",
                        "type": null,
                        "operator": "arrow",
                        "body": "static::recordsLoad($args, 'fetch', [\\PDO::FETCH_COLUMN])",
                        "line": 151,
                        "bodyLine": 151
                    },
                    "pair": {
                        "node": "static",
                        "visibility": null,
                        "name": "pair",
                        "args": "...$args",
                        "type": "array",
                        "operator": "arrow",
                        "body": "static::recordsLoad($args, 'fetchAll', [\\PDO::FETCH_KEY_PAIR])",
                        "line": 152,
                        "bodyLine": 152
                    },
                    "records": {
                        "node": "static",
                        "visibility": null,
                        "name": "records",
                        "args": "...$args",
                        "type": "array",
                        "operator": "arrow",
                        "body": "static::recordsLoad($args, 'fetchAll', [\\PDO::FETCH_CLASS|\\PDO::FETCH_UNIQUE, static::class], true)",
                        "line": 153,
                        "bodyLine": 153
                    },
                    "recordCount": {
                        "node": "static",
                        "visibility": null,
                        "name": "recordCount",
                        "args": "...$args",
                        "type": null,
                        "operator": "arrow",
                        "body": "static::item(...$args, columns: 'COUNT('.static::idColumn().')')",
                        "line": 154,
                        "bodyLine": 154
                    },
                    "record": {
                        "node": "static",
                        "visibility": null,
                        "name": "record",
                        "args": "...$args",
                        "type": "?static",
                        "operator": "arrow",
                        "body": "count($records = static::records(...$args)) > 1 ? error('Multiple records for '.static::class) : (current($records) ?: null)",
                        "line": 155,
                        "bodyLine": 155
                    },
                    "recordsLoad": {
                        "node": "static",
                        "visibility": null,
                        "name": "recordsLoad",
                        "args": "$args, $fetch, $fetchMode, $saveRelations = false",
                        "type": null,
                        "operator": "method",
                        "body": "\t$pk = static::idColumn()\n\t$args['table'] ??= static::$table\n\t$saveRelations && $args['columns'] ??= static::$table.'.'.$pk.' as _,'.static::columns()\n\tisset(static::$joins) && debug && error('DEPRECATED: static $joins in '.static::class.'. Use getParent/getChildren/getMany instead.')\n\tisset(static::$joins) && $args['joins'] = static::$joins.(isset($args['joins']) ? \" $args[joins]\" : void)\n\tmethod_exists(static::class, 'where') && $args['where'] = static::where().(isset($args['where']) ? \" AND $args[where]\" : void)\n\tisset(static::$group) && $args['group'] ??= static::$group\n\tisset(static::$order) && $args['order'] ??= static::$order\n\tif ($cacheKey = $args['cacheKey'] ?? null) unset($args['cacheKey'])\n\tif ($duration = $args['cache'] ?? static::objCache()){\n\t\tunset($args['cache'])\n\t\t$cacheArgs = $args\n\t\tksort($cacheArgs)\n\t\t$records = apcu($cacheKey ?? static::class.slash.md5(json_encode($cacheArgs)), fn() => static::DB()->load(...$args)->$fetch(...$fetchMode), $duration === true ? 86400 : $duration)\n\t}\n\telse $records = static::DB()->load(...$args)->$fetch(...$fetchMode)\n\tif ($saveRelations && $records){\n\t\t$state = static::state()\n\t\t$state->records[static::class] = array_replace($state->records[static::class] ?? [], array_column($records, null, $pk))\n\t\tcount($state->records[static::class]) > static::objRecordLimit() && $state->records[static::class] = array_slice($state->records[static::class], -static::objRecordLimit(), preserve_keys: true)\n\t}\n\treturn $records",
                        "line": 162,
                        "comments": "Loads records and keeps a full record read on the request-local state for relation reuse.\nA relation can then be fetched once for the whole set instead of once per row. That set is\ncapped at objRecordLimit and trimmed to the newest, so a long-lived worker cannot grow it\nwithout bound. The cache key sorts the arguments first, so the same query written in a\ndifferent order shares one entry.",
                        "bodyLine": 163
                    },
                    "objRel": {
                        "node": "static",
                        "visibility": null,
                        "name": "objRel",
                        "args": "$key",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$state = static::state()\n\treturn $state->meta[static::class][$key] ??= method_exists(static::class, $key) ? static::$key() : static::$$key ?? []",
                        "line": 187,
                        "bodyLine": 188
                    },
                    "objState": {
                        "node": "prop",
                        "visibility": null,
                        "name": "objState",
                        "args": null,
                        "type": "array",
                        "operator": "value",
                        "body": "['parents' => [], 'children' => [], 'many' => []]",
                        "line": 192,
                        "bodyLine": 192
                    },
                    "objGet": {
                        "node": "method",
                        "visibility": null,
                        "name": "objGet",
                        "args": "$key",
                        "type": null,
                        "operator": "arrow",
                        "body": "$this->getParent($key) ?? $this->getChildren($key) ?? $this->getMany($key)",
                        "line": 193,
                        "bodyLine": 193
                    },
                    "objIn": {
                        "node": "method",
                        "visibility": null,
                        "name": "objIn",
                        "args": "$ids, $db = null",
                        "type": "string",
                        "operator": "arrow",
                        "body": "$ids ? ($db ?? static::DB())->quoteList($ids) : 'NULL'",
                        "line": 194,
                        "bodyLine": 194
                    },
                    "objMirror": {
                        "node": "method",
                        "visibility": null,
                        "name": "objMirror",
                        "args": "string $bucket, $key",
                        "type": null,
                        "operator": "method",
                        "body": "\t$pk = $this->objData[static::idColumn()] ?? null\n\tif ($pk === null) return\n\t$canonical = static::state()->records[static::class][$pk] ?? null\n\tif (!$canonical || $canonical === $this) return\n\tif (array_key_exists($key, $this->objState[$bucket] ?? [])) return\n\tif (array_key_exists($key, $canonical->objState[$bucket] ?? [])) $this->objState[$bucket][$key] = $canonical->objState[$bucket][$key]",
                        "line": 200,
                        "comments": "Mirrors a freshly loaded relation onto a stale reference to the same record.\nA reference held across a re-fetch is orphaned from the record cache, so once a\nrelation is loaded onto the canonical record it is copied here too and the held\nreference sees it. No-op when this object is itself the canonical one.",
                        "bodyLine": 201
                    },
                    "getParent": {
                        "node": "method",
                        "visibility": null,
                        "name": "getParent",
                        "args": "$key",
                        "type": null,
                        "operator": "method",
                        "body": "\tif (array_key_exists($key, $this->objState['parents'])) return $this->objState['parents'][$key]\n\t$state = static::state()\n\t$parents = self::objRel('objParents')\n\tif (!$relation = $parents[$key] ?? null) return\n\t$isArray = is_array($relation)\n\t$class = $isArray ? $relation['obj'] : $relation\n\t$column = $isArray ? $relation['key'] ?? $key : $key\n\tif (!$parentId = $this->objData[$column] ?? null) return $this->objState['parents'][$key] = null\n\tif (!isset($state->records[$class][$parentId])){\n\t\t$idsToLoad = [$parentId => true]\n\t\t$allObjData = array_map(fn($record) => $record->objData, $state->records[static::class] ?? [])\n\t\tforeach ($parents as $pKey => $pRelation){\n\t\t\t$pIsArray = is_array($pRelation)\n\t\t\t$pClass = $pIsArray ? $pRelation['obj'] : $pRelation\n\t\t\tif ($pClass === $class) foreach (array_column($allObjData, $pIsArray ? $pRelation['key'] ?? $pKey : $pKey) as $pId) $pId && !isset($state->records[$class][$pId]) && $idsToLoad[$pId] = true\n\t\t}\n\t\tif ($idsToLoad = array_keys($idsToLoad)) $class::records(where: $class::idColumn().' IN ('.$this->objIn($idsToLoad, $class::DB()).')')\n\t}\n\t$parentObject = $state->records[$class][$parentId] ?? null\n\treturn $this->objState['parents'][$key] = $parentObject",
                        "line": 209,
                        "bodyLine": 210
                    },
                    "getChildren": {
                        "node": "method",
                        "visibility": null,
                        "name": "getChildren",
                        "args": "$key",
                        "type": null,
                        "operator": "method",
                        "body": "\tif (array_key_exists($key, $this->objState['children'])) return $this->objState['children'][$key]\n\t$state = static::state()\n\tif (!$relation = self::objRel('objChildren')[$key] ?? null) return\n\t$isArray = is_array($relation)\n\t$class = $isArray ? $relation['obj'] : $relation\n\t$column = $isArray ? $relation['key'] : static::objShortName()\n\t$toLoad = array_filter($state->records[static::class] ?? [], fn($p) => !array_key_exists($key, $p->objState['children']))\n\tif ($toLoad){\n\t\t$fq = $class::DB()->fieldQuotes\n\t\t$children = $class::records(where: $fq.$column.$fq.' IN ('.$this->objIn(array_keys($toLoad), $class::DB()).')')\n\t\tforeach ($toLoad AS $parentRecord) $parentRecord->objState['children'][$key] = []\n\t\tforeach ($children AS $childId => $child) !is_null($pId = $child->objData[$column] ?? null) && isset($state->records[static::class][$pId]) && $state->records[static::class][$pId]->objState['children'][$key][$childId] = $child\n\t}\n\t$this->objMirror('children', $key)\n\treturn $this->objState['children'][$key] ?? []",
                        "line": 232,
                        "bodyLine": 233
                    },
                    "getMany": {
                        "node": "method",
                        "visibility": null,
                        "name": "getMany",
                        "args": "$key",
                        "type": null,
                        "operator": "method",
                        "body": "\tif (array_key_exists($key, $this->objState['many'])) return $this->objState['many'][$key]\n\t$state = static::state()\n\tif (!$relation = self::objRel('objMany')[$key] ?? null) return\n\t$class = $relation['obj']\n\t$toLoad = array_filter($state->records[static::class] ?? [], fn($p) => !array_key_exists($key, $p->objState['many']))\n\tif ($toLoad){\n\t\t$fq = static::DB()->fieldQuotes\n\t\t$lk = $relation['localKey']\n\t\t$fk = $relation['foreignKey']\n\t\t$pivotRows = static::DB()->rows(table: $relation['table'], columns: $fq.$lk.$fq.comma.$fq.$fk.$fq, where: $fq.$lk.$fq.' IN ('.$this->objIn(array_keys($toLoad)).')')\n\t\t$targetIds = array_unique(array_map(fn($row) => $row->{$relation['foreignKey']}, $pivotRows ?: []))\n\t\t$targetRecords = $targetIds ? $class::records(where: $class::idColumn().' IN ('.$this->objIn($targetIds, $class::DB()).')') : []\n\t\tforeach ($toLoad AS $parentRecord) $parentRecord->objState['many'][$key] = []\n\t\tforeach ($pivotRows ?: [] AS $row){\n\t\t\t$parentId = $row->$lk\n\t\t\t$foreignId = $row->$fk\n\t\t\tif (isset($state->records[static::class][$parentId]) && isset($targetRecords[$foreignId])) $state->records[static::class][$parentId]->objState['many'][$key][$foreignId] = $targetRecords[$foreignId]\n\t\t}\n\t}\n\t$this->objMirror('many', $key)\n\treturn $this->objState['many'][$key] ?? []",
                        "line": 250,
                        "bodyLine": 251
                    },
                    "getCount": {
                        "node": "method",
                        "visibility": null,
                        "name": "getCount",
                        "args": "$key",
                        "type": "int",
                        "operator": "method",
                        "body": "\tif (array_key_exists($key, $this->objState['counts'] ?? [])) return $this->objState['counts'][$key]\n\t$state = static::state()\n\tif ($relation = self::objRel('objChildren')[$key] ?? null){\n\t\t$toLoad = array_filter($state->records[static::class] ?? [], fn($p) => !array_key_exists($key, $p->objState['counts'] ?? []))\n\t\tif ($toLoad){\n\t\t\t$isArray = is_array($relation)\n\t\t\t$class = $isArray ? $relation['obj'] : $relation\n\t\t\t$column = $isArray ? $relation['key'] : static::objShortName()\n\t\t\t$fq = $class::DB()->fieldQuotes\n\t\t\t$counts = $class::pair(columns: $fq.$column.$fq.', COUNT(*)', where: $fq.$column.$fq.' IN ('.$this->objIn(array_keys($toLoad), $class::DB()).')', group: $fq.$column.$fq)\n\t\t\tforeach ($toLoad as $id => $record) $record->objState['counts'][$key] = (int)($counts[$id] ?? 0)\n\t\t}\n\t\t$this->objMirror('counts', $key)\n\t\treturn $this->objState['counts'][$key] ?? 0\n\t}\n\tif ($relation = self::objRel('objMany')[$key] ?? null){\n\t\t$toLoad = array_filter($state->records[static::class] ?? [], fn($p) => !array_key_exists($key, $p->objState['counts'] ?? []))\n\t\tif ($toLoad){\n\t\t\t$fq = static::DB()->fieldQuotes\n\t\t\t$localKey = $relation['localKey']\n\t\t\t$counts = static::DB()->load(table: $relation['table'], columns: $fq.$localKey.$fq.',COUNT(*)', where: $fq.$localKey.$fq.' IN ('.$this->objIn(array_keys($toLoad)).')', group: $fq.$localKey.$fq)->fetchAll(\\PDO::FETCH_KEY_PAIR)\n\t\t\tforeach ($toLoad as $id => $record) $record->objState['counts'][$key] = (int)($counts[$id] ?? 0)\n\t\t}\n\t\t$this->objMirror('counts', $key)\n\t\treturn $this->objState['counts'][$key] ?? 0\n\t}\n\treturn 0",
                        "line": 274,
                        "bodyLine": 275
                    },
                    "getLast": {
                        "node": "method",
                        "visibility": null,
                        "name": "getLast",
                        "args": "$key",
                        "type": null,
                        "operator": "method",
                        "body": "\tif (array_key_exists($key, $this->objState['last_child'] ?? [])) return $this->objState['last_child'][$key]\n\t$state = static::state()\n\tif ($relation = self::objRel('objChildren')[$key] ?? null){\n\t\t$toLoad = array_filter($state->records[static::class] ?? [], fn($p) => !array_key_exists($key, $p->objState['last_child'] ?? []))\n\t\tif ($toLoad){\n\t\t\t$isArray = is_array($relation)\n\t\t\t$class = $isArray ? $relation['obj'] : $relation\n\t\t\t$column = $isArray ? $relation['key'] : static::objShortName()\n\t\t\t$childTable = $class::$table\n\t\t\t$fq = $class::DB()->fieldQuotes\n\t\t\t$qt = $fq.$childTable.$fq\n\t\t\t$qc = $fq.$column.$fq\n\t\t\t$ids = $this->objIn(array_keys($toLoad), $class::DB())\n\t\t\t$childPk = $class::idColumn()\n\t\t\t$joins = ' INNER JOIN (SELECT MAX('.$fq.$childPk.$fq.') AS last_id, '.$qc.' AS parent_id FROM '.$qt.' WHERE '.$qc.' IN ('.$ids.') GROUP BY '.$qc.') AS lcmax ON '.$qt.'.'.$fq.$childPk.$fq.' = lcmax.last_id'\n\t\t\t$lastChildren = $class::records(joins: $joins)\n\t\t\tforeach ($toLoad as $record) $record->objState['last_child'][$key] = null\n\t\t\tforeach ($lastChildren as $child) if (isset($state->records[static::class][$parentId = $child->objData[$column]])) $state->records[static::class][$parentId]->objState['last_child'][$key] = $child\n\t\t}\n\t\t$this->objMirror('last_child', $key)\n\t\treturn $this->objState['last_child'][$key] ?? null\n\t}\n\treturn null",
                        "line": 304,
                        "bodyLine": 305
                    },
                    "objResolveClass": {
                        "node": "static",
                        "visibility": null,
                        "name": "objResolveClass",
                        "args": "$name",
                        "type": "string",
                        "operator": "arrow",
                        "body": "$name",
                        "line": 330,
                        "bodyLine": 330
                    },
                    "objShortName": {
                        "node": "static",
                        "visibility": null,
                        "name": "objShortName",
                        "args": "$class = null",
                        "type": "string",
                        "operator": "arrow",
                        "body": "$class ?? static::class",
                        "line": 331,
                        "bodyLine": 331
                    },
                    "objParents": {
                        "node": "static",
                        "visibility": null,
                        "name": "objParents",
                        "args": null,
                        "type": "array",
                        "operator": "method",
                        "body": "\tif (property_exists(static::class, 'objParents')) return static::$objParents\n\tif (!method_exists(static::class, 'schema')) return []\n\treturn loop(array_filter(static::fields(), fn($f) => $f->type === 'parent'), fn($f, $c) => $f->key ? arr(obj: static::objResolveClass($f->obj), key: $f->key) : (static::objResolveClass($f->obj ?? $c)))",
                        "line": 333,
                        "bodyLine": 334
                    },
                    "objChildren": {
                        "node": "static",
                        "visibility": null,
                        "name": "objChildren",
                        "args": null,
                        "type": "array",
                        "operator": "method",
                        "body": "\tif (property_exists(static::class, 'objChildren')) return static::$objChildren\n\tif (!method_exists(static::class, 'schema')) return []\n\treturn loop(array_filter(static::fields(), fn($f) => $f->type === 'child'), fn($f, $c) => $f->key ? arr(obj: static::objResolveClass($f->obj), key: $f->key) : (static::objResolveClass($f->obj ?? $c)))",
                        "line": 339,
                        "bodyLine": 340
                    },
                    "objMany": {
                        "node": "static",
                        "visibility": null,
                        "name": "objMany",
                        "args": null,
                        "type": "array",
                        "operator": "method",
                        "body": "\tif (property_exists(static::class, 'objMany')) return static::$objMany\n\tif (!method_exists(static::class, 'schema')) return []\n\treturn loop(array_filter(static::fields(), fn($f) => $f->type === 'many'), fn($f) => arr(obj: static::objResolveClass($f->obj), table: $f->table, localKey: $f->localKey ?? static::objShortName(), foreignKey: $f->foreignKey ?? $f->obj))",
                        "line": 345,
                        "bodyLine": 346
                    }
                },
                "functions": [],
                "assets": []
            },
            "MySQL": {
                "file": "/srv/control/phlo/resources/DB/MySQL.phlo",
                "class": "MySQL",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "MySQL handler via DB class",
                    "advice": "Reads host, database, user and password from the mysql section of %creds. It is the default assumption of the ORM, so a model that names no engine of its own ends up here.",
                    "extends": "DB",
                    "package": "database",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@DB creds:mysql php-ext:pdo php-ext:pdo_mysql",
                    "tags": "mysql pdo database sql"
                },
                "nodes": {
                    "PDO": {
                        "node": "prop",
                        "visibility": null,
                        "name": "PDO",
                        "args": null,
                        "type": "\\PDO",
                        "operator": "arrow",
                        "body": "new \\PDO('mysql:host='.%creds->mysql->host.';dbname='.%creds->mysql->database, %creds->mysql->user, %creds->mysql->password)",
                        "line": 12,
                        "bodyLine": 12
                    }
                },
                "functions": [],
                "assets": []
            },
            "PostgreSQL": {
                "file": "/srv/control/phlo/resources/DB/PostgreSQL.phlo",
                "class": "PostgreSQL",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "PostgreSQL resource",
                    "advice": "Quotes identifiers with double quotes rather than backticks and has no INSERT IGNORE, so a duplicate is skipped with ON CONFLICT DO NOTHING. Postgres folds an unquoted name to lower case, so a column called createdAt in a schema is not the same one you get back unquoted.",
                    "extends": "DB",
                    "package": "database",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@DB creds:postgresql php-ext:pdo php-ext:pdo_pgsql",
                    "tags": "postgresql pdo database sql"
                },
                "nodes": {
                    "PDO": {
                        "node": "prop",
                        "visibility": null,
                        "name": "PDO",
                        "args": null,
                        "type": "\\PDO",
                        "operator": "arrow",
                        "body": "new PDO('pgsql:host='.%creds->postgresql->host.';dbname='.%creds->postgresql->database, %creds->postgresql->user, %creds->postgresql->password)",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "fieldQuotes": {
                        "node": "prop",
                        "visibility": null,
                        "name": "fieldQuotes",
                        "args": null,
                        "type": "string",
                        "operator": "value",
                        "body": "dq",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "insertIgnore": {
                        "node": "prop",
                        "visibility": null,
                        "name": "insertIgnore",
                        "args": null,
                        "type": "string",
                        "operator": "value",
                        "body": "void",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "insertOnConflict": {
                        "node": "prop",
                        "visibility": null,
                        "name": "insertOnConflict",
                        "args": null,
                        "type": "string",
                        "operator": "value",
                        "body": "' ON CONFLICT DO NOTHING'",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "lastId": {
                        "node": "method",
                        "visibility": null,
                        "name": "lastId",
                        "args": null,
                        "type": null,
                        "operator": "method",
                        "body": "\t$savepoint = $this->PDO->inTransaction()\n\t$savepoint && $this->PDO->exec('SAVEPOINT phlo_lastid')\n\ttry {\n\t\t$id = $this->PDO->lastInsertId()\n\t} catch (\\Throwable $e){\n\t\t$id = false\n\t}\n\tif ($savepoint) $this->PDO->exec($id === false ? 'ROLLBACK TO SAVEPOINT phlo_lastid' : 'RELEASE SAVEPOINT phlo_lastid')\n\treturn $id",
                        "line": 21,
                        "comments": "Asks for the last id inside a savepoint, so a failed lastval() cannot abort the transaction.\nPostgres aborts the whole transaction when lastval() is called before any sequence has been\nused in the session. Without the savepoint, one insert into a table without a sequence would\ntake every later statement in the same transaction down with it.",
                        "bodyLine": 22
                    }
                },
                "functions": [],
                "assets": []
            },
            "Qdrant": {
                "file": "/srv/control/phlo/resources/DB/Qdrant.phlo",
                "class": "Qdrant",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Embeddings resource with Qdrant",
                    "advice": "Embeddings are cached in APCu for four weeks per input, so repeating a search costs nothing at the AI end. create() opens a collection at 1536 dimensions, the length of an OpenAI vector, so state the size yourself when another engine fills it. search() without input sends a zero vector, which lists a collection rather than searching it.",
                    "package": "ai",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@AI creds:qdrant apcu",
                    "tags": "qdrant embeddings vector search ai"
                },
                "nodes": {
                    "get": {
                        "node": "method",
                        "visibility": null,
                        "name": "get",
                        "args": "string $input, ?string $model = null",
                        "type": "array",
                        "operator": "arrow",
                        "body": "apcu('embedding/'.token(input: $input), fn($input) => %AI->embedding(input: $input, model: $model), 86400 * 28)",
                        "line": 11,
                        "bodyLine": 11
                    },
                    "collections": {
                        "node": "method",
                        "visibility": null,
                        "name": "collections",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "array_column($this->request('collections')->result->collections, 'name')",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "create": {
                        "node": "method",
                        "visibility": null,
                        "name": "create",
                        "args": "$collection, $size = 1536, $distance = 'Cosine'",
                        "type": "bool",
                        "operator": "arrow",
                        "body": "$this->request(\"collections/$collection\", PUT: arr(vectors: arr(size: $size, distance: $distance)))->status === 'ok'",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "upsert": {
                        "node": "method",
                        "visibility": null,
                        "name": "upsert",
                        "args": "$collection, $id, $input, ...$payload",
                        "type": null,
                        "operator": "arrow",
                        "body": "$this->request(\"collections/$collection/points\", PUT: arr(points: [arr(id: $id, vector: $this->get($input), payload: $payload ?: null)]))->result->operation_id",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "delete": {
                        "node": "method",
                        "visibility": null,
                        "name": "delete",
                        "args": "$collection, ...$ids",
                        "type": null,
                        "operator": "arrow",
                        "body": "$this->request(\"collections/$collection/points/delete\", POST: arr(points: $ids))->result",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "search": {
                        "node": "method",
                        "visibility": null,
                        "name": "search",
                        "args": "$collection, $input = null, $top = 100",
                        "type": "array",
                        "operator": "arrow",
                        "body": "create($this->request(\"collections/$collection/points/search\", POST: arr(vector: is_null($input) ? array_fill(0, 1536, 0) : $this->get($input), top: $top, with_payload: true))->result, fn($record) => $record->id, fn($record) => last($record = array_merge(get_object_vars($record), get_object_vars($record->payload)), obj(...array_filter($record, fn($key) => $key !== 'payload', ARRAY_FILTER_USE_KEY))))",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "drop": {
                        "node": "method",
                        "visibility": null,
                        "name": "drop",
                        "args": "$collection",
                        "type": null,
                        "operator": "arrow",
                        "body": "$this->request(\"collections/$collection\", DELETE: true)->result",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "request": {
                        "node": "method",
                        "visibility": null,
                        "name": "request",
                        "args": "$uri, ...$data",
                        "type": null,
                        "operator": "arrow",
                        "body": "json_decode(HTTP(%creds->qdrant->server.$uri, %creds->qdrant->key ? ['api-key: '.%creds->qdrant->key] : [], true, ...$data))",
                        "line": 20,
                        "bodyLine": 20
                    }
                },
                "functions": [],
                "assets": []
            },
            "query": {
                "file": "/srv/control/phlo/resources/DB/query.phlo",
                "class": "query",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Fluent query builder for Phlo ORM",
                    "advice": "For everything named arguments cannot say: eq, gt, like, in, isNull, order, limit and offset, chained and closed with records, record, column, item or count. Column names are quoted for the driver you are on, so the same chain works on MySQL and Postgres. Values go in as bindings, so a search box can be passed straight through.",
                    "package": "database",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@DB",
                    "tags": "query builder orm database sql"
                },
                "nodes": {
                    "class": {
                        "node": "prop",
                        "visibility": null,
                        "name": "class",
                        "args": null,
                        "type": null,
                        "operator": null,
                        "body": null,
                        "line": 11
                    },
                    "conditions": {
                        "node": "prop",
                        "visibility": null,
                        "name": "conditions",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "[]",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "bindings": {
                        "node": "prop",
                        "visibility": null,
                        "name": "bindings",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "[]",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "orderBy": {
                        "node": "prop",
                        "visibility": null,
                        "name": "orderBy",
                        "args": null,
                        "type": null,
                        "operator": null,
                        "body": null,
                        "line": 14
                    },
                    "limitVal": {
                        "node": "prop",
                        "visibility": null,
                        "name": "limitVal",
                        "args": null,
                        "type": null,
                        "operator": null,
                        "body": null,
                        "line": 15
                    },
                    "offsetVal": {
                        "node": "prop",
                        "visibility": null,
                        "name": "offsetVal",
                        "args": null,
                        "type": null,
                        "operator": null,
                        "body": null,
                        "line": 16
                    },
                    "fq": {
                        "node": "method",
                        "visibility": null,
                        "name": "fq",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "($class = $this->class) ? $class::DB()->fieldQuotes : bt",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "q": {
                        "node": "method",
                        "visibility": null,
                        "name": "q",
                        "args": "$column",
                        "type": "string",
                        "operator": "method",
                        "body": "\tpreg_match('/^[A-Za-z_][A-Za-z0-9_.]*$/', $column) || error(\"Invalid column for query builder: $column\")\n\t$fq = $this->fq\n\treturn implode(dot, array_map(fn($part) => $fq.$part.$fq, explode(dot, $column)))",
                        "line": 18,
                        "bodyLine": 19
                    },
                    "eq": {
                        "node": "method",
                        "visibility": null,
                        "name": "eq",
                        "args": "$column, $value",
                        "type": "static",
                        "operator": "arrow",
                        "body": "$this->where($this->q($column).\" = ?\", $value)",
                        "line": 24,
                        "bodyLine": 24
                    },
                    "neq": {
                        "node": "method",
                        "visibility": null,
                        "name": "neq",
                        "args": "$column, $value",
                        "type": "static",
                        "operator": "arrow",
                        "body": "$this->where($this->q($column).\" != ?\", $value)",
                        "line": 25,
                        "bodyLine": 25
                    },
                    "gt": {
                        "node": "method",
                        "visibility": null,
                        "name": "gt",
                        "args": "$column, $value",
                        "type": "static",
                        "operator": "arrow",
                        "body": "$this->where($this->q($column).\" > ?\", $value)",
                        "line": 26,
                        "bodyLine": 26
                    },
                    "gte": {
                        "node": "method",
                        "visibility": null,
                        "name": "gte",
                        "args": "$column, $value",
                        "type": "static",
                        "operator": "arrow",
                        "body": "$this->where($this->q($column).\" >= ?\", $value)",
                        "line": 27,
                        "bodyLine": 27
                    },
                    "lt": {
                        "node": "method",
                        "visibility": null,
                        "name": "lt",
                        "args": "$column, $value",
                        "type": "static",
                        "operator": "arrow",
                        "body": "$this->where($this->q($column).\" < ?\", $value)",
                        "line": 28,
                        "bodyLine": 28
                    },
                    "lte": {
                        "node": "method",
                        "visibility": null,
                        "name": "lte",
                        "args": "$column, $value",
                        "type": "static",
                        "operator": "arrow",
                        "body": "$this->where($this->q($column).\" <= ?\", $value)",
                        "line": 29,
                        "bodyLine": 29
                    },
                    "like": {
                        "node": "method",
                        "visibility": null,
                        "name": "like",
                        "args": "$column, $value",
                        "type": "static",
                        "operator": "arrow",
                        "body": "$this->where($this->q($column).\" LIKE ?\", $value)",
                        "line": 30,
                        "bodyLine": 30
                    },
                    "in": {
                        "node": "method",
                        "visibility": null,
                        "name": "in",
                        "args": "$column, array $values",
                        "type": "static",
                        "operator": "arrow",
                        "body": "$this->where($this->q($column).\" IN (\".implode(comma, array_fill(0, count($values), qm)).\")\", ...$values)",
                        "line": 31,
                        "bodyLine": 31
                    },
                    "isNull": {
                        "node": "method",
                        "visibility": null,
                        "name": "isNull",
                        "args": "$column",
                        "type": "static",
                        "operator": "arrow",
                        "body": "$this->where($this->q($column).\" IS NULL\")",
                        "line": 32,
                        "bodyLine": 32
                    },
                    "notNull": {
                        "node": "method",
                        "visibility": null,
                        "name": "notNull",
                        "args": "$column",
                        "type": "static",
                        "operator": "arrow",
                        "body": "$this->where($this->q($column).\" IS NOT NULL\")",
                        "line": 33,
                        "bodyLine": 33
                    },
                    "between": {
                        "node": "method",
                        "visibility": null,
                        "name": "between",
                        "args": "$column, $min, $max",
                        "type": "static",
                        "operator": "arrow",
                        "body": "$this->where($this->q($column).\" BETWEEN ? AND ?\", $min, $max)",
                        "line": 34,
                        "bodyLine": 34
                    },
                    "raw": {
                        "node": "method",
                        "visibility": null,
                        "name": "raw",
                        "args": "$sql, ...$bindings",
                        "type": "static",
                        "operator": "arrow",
                        "body": "$this->where($sql, ...$bindings)",
                        "line": 35,
                        "bodyLine": 35
                    },
                    "where": {
                        "node": "method",
                        "visibility": null,
                        "name": "where",
                        "args": "$condition, ...$values",
                        "type": "static",
                        "operator": "method",
                        "body": "\t$this->conditions[] = $condition\n\tforeach ($values AS $v) $this->bindings[] = $v\n\treturn $this",
                        "line": 36,
                        "bodyLine": 37
                    },
                    "order": {
                        "node": "method",
                        "visibility": null,
                        "name": "order",
                        "args": "$order",
                        "type": "static",
                        "operator": "method",
                        "body": "\t$this->orderBy = $order\n\treturn $this",
                        "line": 42,
                        "bodyLine": 43
                    },
                    "limit": {
                        "node": "method",
                        "visibility": null,
                        "name": "limit",
                        "args": "$limit",
                        "type": "static",
                        "operator": "method",
                        "body": "\t$this->limitVal = $limit\n\treturn $this",
                        "line": 47,
                        "bodyLine": 48
                    },
                    "offset": {
                        "node": "method",
                        "visibility": null,
                        "name": "offset",
                        "args": "$offset",
                        "type": "static",
                        "operator": "method",
                        "body": "\t$this->offsetVal = $offset\n\treturn $this",
                        "line": 52,
                        "bodyLine": 53
                    },
                    "build": {
                        "node": "method",
                        "visibility": null,
                        "name": "build",
                        "args": null,
                        "type": "array",
                        "operator": "method",
                        "body": "\t$where = $this->conditions ? implode(' AND ', $this->conditions) : void\n\t$limit = $this->limitVal ? ($this->offsetVal ? \"$this->offsetVal,$this->limitVal\" : \"$this->limitVal\") : void\n\t$args = ['where' => $where ?: void, 'order' => $this->orderBy ?: void, 'limit' => $limit ?: void]\n\tforeach ($this->bindings AS $b) $args[] = $b\n\treturn $args",
                        "line": 56,
                        "bodyLine": 57
                    },
                    "records": {
                        "node": "prop",
                        "visibility": null,
                        "name": "records",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "($class = $this->class) && $class::records(...$this->build)",
                        "line": 64,
                        "bodyLine": 64
                    },
                    "record": {
                        "node": "prop",
                        "visibility": null,
                        "name": "record",
                        "args": null,
                        "type": "?model",
                        "operator": "arrow",
                        "body": "($class = $this->class) && $class::record(...$this->build)",
                        "line": 65,
                        "bodyLine": 65
                    },
                    "column": {
                        "node": "prop",
                        "visibility": null,
                        "name": "column",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "($class = $this->class) && $class::column(...$this->build)",
                        "line": 66,
                        "bodyLine": 66
                    },
                    "item": {
                        "node": "prop",
                        "visibility": null,
                        "name": "item",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "($class = $this->class) && $class::item(...$this->build)",
                        "line": 67,
                        "bodyLine": 67
                    },
                    "count": {
                        "node": "prop",
                        "visibility": null,
                        "name": "count",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "($class = $this->class) && $class::recordCount(...$this->build)",
                        "line": 68,
                        "bodyLine": 68
                    },
                    "delete": {
                        "node": "method",
                        "visibility": null,
                        "name": "delete",
                        "args": null,
                        "type": "int",
                        "operator": "method",
                        "body": "\t$class = $this->class\n\t$where = $this->conditions ? implode(' AND ', $this->conditions) : error('Cannot delete without conditions')\n\treturn $class::delete($where, ...$this->bindings)",
                        "line": 69,
                        "bodyLine": 70
                    }
                },
                "functions": [],
                "assets": []
            },
            "SQLite": {
                "file": "/srv/control/phlo/resources/DB/SQLite.phlo",
                "class": "SQLite",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "SQLite resource",
                    "advice": "One file, one database, given as a path: %SQLite('/path/db.sqlite'). It writes with a lock over the whole file, so it fits a single site or a worker but not a set of processes writing at once. Perfect where you want the ORM without a server.",
                    "extends": "DB",
                    "package": "database",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@DB php-ext:pdo php-ext:pdo_sqlite",
                    "tags": "sqlite pdo database sql"
                },
                "nodes": {
                    "__handle": {
                        "node": "static",
                        "visibility": null,
                        "name": "__handle",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "\"SQLite/$file\"",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "__construct": {
                        "node": "method",
                        "visibility": null,
                        "name": "__construct",
                        "args": "private string $file",
                        "type": null,
                        "operator": null,
                        "body": null,
                        "line": 13
                    },
                    "PDO": {
                        "node": "prop",
                        "visibility": null,
                        "name": "PDO",
                        "args": null,
                        "type": "\\PDO",
                        "operator": "arrow",
                        "body": "new PDO('sqlite:'.$this->file)",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "insertIgnore": {
                        "node": "prop",
                        "visibility": null,
                        "name": "insertIgnore",
                        "args": null,
                        "type": "string",
                        "operator": "value",
                        "body": "' OR IGNORE'",
                        "line": 15,
                        "bodyLine": 15
                    }
                },
                "functions": [],
                "assets": []
            }
        }
    },
    "DOM": {
        "objs": {
            "charts": {
                "file": "/srv/control/phlo/resources/DOM/charts.phlo",
                "class": "charts",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Lightweight dependency-free SVG charts: sparkline, bars and donut. Use charts::spark/bars/donut.",
                    "advice": "Three small SVG charts rendered on the server, without a library and without a script, so they show up in a mail, a PDF and a page with a strict policy alike. They are meant for a number in context, not for exploring data: no axes, no legend, no tooltips.",
                    "package": "dom",
                    "frontend": "true",
                    "backend": "true",
                    "tags": "chart svg sparkline bars donut visualization"
                },
                "nodes": {
                    "spark": {
                        "node": "static",
                        "visibility": null,
                        "name": "spark",
                        "args": "$values, $color = '#888', $w = 240, $h = 48, $label = null",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$values = array_values(array_map('floatval', (array)$values))\n\tif (!$values) return void\n\t$max = max($values) ?: 1\n\t$min = min($values)\n\t$range = max(0.0001, $max - $min)\n\t$count = count($values)\n\t$step = $count > 1 ? $w / ($count - 1) : 0\n\t$points = []\n\tforeach ($values AS $i => $v){\n\t\t$x = round($i * $step, 1)\n\t\t$y = round($h - (($v - $min) / $range) * ($h - 6) - 3, 1)\n\t\t$points[] = \"$x,$y\"\n\t}\n\t$area = array_merge($points, [round($w, 1).comma.$h, '0,'.$h])\n\t$id = 'g'.substr(md5(implode(comma, $points)), 0, 6)\n\t$aria = $label !== null ? ' role=\"img\" aria-label=\"'.esc($label).'\"' : void\n\treturn '<svg viewBox=\"0 0 '.$w.' '.$h.'\"'.$aria.' xmlns=\"http://www.w3.org/2000/svg\" preserveAspectRatio=\"none\" style=\"display:block;width:100%;height:'.$h.'px\"><defs><linearGradient id=\"'.$id.'\" x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\"><stop offset=\"0%\" stop-color=\"'.$color.'\" stop-opacity=\".4\"/><stop offset=\"100%\" stop-color=\"'.$color.'\" stop-opacity=\"0\"/></linearGradient></defs><polygon points=\"'.implode(space, $area).'\" fill=\"url(#'.$id.')\"/><polyline points=\"'.implode(space, $points).'\" fill=\"none\" stroke=\"'.$color.'\" stroke-width=\"1.6\" stroke-linejoin=\"round\" stroke-linecap=\"round\"/></svg>'",
                        "line": 10,
                        "bodyLine": 11
                    },
                    "bars": {
                        "node": "static",
                        "visibility": null,
                        "name": "bars",
                        "args": "$values, $color = '#888', $w = 240, $h = 48, $label = null",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$values = array_values(array_map('floatval', (array)$values))\n\tif (!$values) return void\n\t$max = max($values) ?: 1\n\t$count = count($values)\n\t$gap = 2\n\t$bw = max(1, ($w - ($count - 1) * $gap) / $count)\n\t$out = void\n\tforeach ($values AS $i => $v){\n\t\t$bh = round(($v / $max) * ($h - 4), 1)\n\t\t$x = round($i * ($bw + $gap), 1)\n\t\t$y = round($h - $bh, 1)\n\t\t$out .= '<rect x=\"'.$x.'\" y=\"'.$y.'\" width=\"'.round($bw, 1).'\" height=\"'.max(0.5, $bh).'\" fill=\"'.$color.'\" rx=\"1.5\"/>'\n\t}\n\t$aria = $label !== null ? ' role=\"img\" aria-label=\"'.esc($label).'\"' : void\n\treturn '<svg viewBox=\"0 0 '.$w.' '.$h.'\"'.$aria.' xmlns=\"http://www.w3.org/2000/svg\" preserveAspectRatio=\"none\" style=\"display:block;width:100%;height:'.$h.'px\">'.$out.'</svg>'",
                        "line": 30,
                        "bodyLine": 31
                    },
                    "donut": {
                        "node": "static",
                        "visibility": null,
                        "name": "donut",
                        "args": "$parts, $colors = null, $size = 120",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$parts = array_filter(array_map('floatval', (array)$parts))\n\t$total = array_sum($parts)\n\tif (!$total) return void\n\t$colors ??= ['#FFC76B', '#6CC0FF', '#9D7BFF', '#6BE39E', '#FF8A5C', '#FF6B6B']\n\t$cx = $size / 2\n\t$cy = $size / 2\n\t$r = $size * 0.4\n\t$circumference = 2 * M_PI * $r\n\t$out = void\n\t$rotate = -90\n\t$i = 0\n\tforeach ($parts AS $value){\n\t\t$len = ($value / $total) * $circumference\n\t\t$color = $colors[$i % count($colors)]\n\t\t$out .= '<circle cx=\"'.$cx.'\" cy=\"'.$cy.'\" r=\"'.$r.'\" fill=\"none\" stroke=\"'.$color.'\" stroke-width=\"'.($size * 0.18).'\" stroke-dasharray=\"'.$len.space.$circumference.'\" transform=\"rotate('.$rotate.space.$cx.space.$cy.')\"/>'\n\t\t$rotate += ($value / $total) * 360\n\t\t$i++\n\t}\n\treturn '<svg viewBox=\"0 0 '.$size.' '.$size.'\" xmlns=\"http://www.w3.org/2000/svg\" style=\"display:block;width:'.$size.'px;height:'.$size.'px\">'.$out.'<circle cx=\"'.$cx.'\" cy=\"'.$cy.'\" r=\"'.($r - $size * 0.09).'\" fill=\"rgba(255,255,255,.04)\"/></svg>'",
                        "line": 48,
                        "bodyLine": 49
                    }
                },
                "functions": [],
                "assets": []
            },
            "cookiewall": {
                "file": "/srv/control/phlo/resources/DOM/cookiewall.phlo",
                "class": "cookiewall",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Subtle GDPR cookie-consent banner. English by default; auto-translates when the lang system (en()) is loaded. Override prop labels for a fixed language, or prop translate to force it on/off.",
                    "advice": "Asks once and keeps the answer in a cookie, so it stays out of the way afterwards. canTrack and canAnalytics are what the rest of the app should ask before it loads anything; the banner itself blocks nothing. It translates itself when the language system is loaded, so it speaks the visitor's language without a second set of texts.",
                    "package": "privacy",
                    "frontend": "true",
                    "backend": "true",
                    "requires": "@cookies",
                    "tags": "gdpr consent cookies privacy"
                },
                "nodes": {
                    "__handle": {
                        "node": "static",
                        "visibility": null,
                        "name": "__handle",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "null",
                        "line": 11,
                        "bodyLine": 11
                    },
                    "choice": {
                        "node": "prop",
                        "visibility": null,
                        "name": "choice",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "%cookies->cookieChoice ?? null",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "hasChosen": {
                        "node": "method",
                        "visibility": null,
                        "name": "hasChosen",
                        "args": null,
                        "type": "bool",
                        "operator": "arrow",
                        "body": "$this->choice !== null",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "canTrack": {
                        "node": "method",
                        "visibility": null,
                        "name": "canTrack",
                        "args": null,
                        "type": "bool",
                        "operator": "arrow",
                        "body": "$this->choice === 'all'",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "canAnalytics": {
                        "node": "method",
                        "visibility": null,
                        "name": "canAnalytics",
                        "args": null,
                        "type": "bool",
                        "operator": "arrow",
                        "body": "$this->choice === 'all'",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "translate": {
                        "node": "prop",
                        "visibility": null,
                        "name": "translate",
                        "args": null,
                        "type": "bool",
                        "operator": "arrow",
                        "body": "function_exists('en')",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "labels": {
                        "node": "prop",
                        "visibility": null,
                        "name": "labels",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "arr(\n\tregion:    'Cookie choice',\n\tbody:      'We use essential cookies to make this site work. With your permission we also use analytics to improve the site.',\n\tessential: 'Essential only',\n\taccept:    'Accept',\n)",
                        "line": 19,
                        "bodyLine": 19
                    },
                    "label": {
                        "node": "method",
                        "visibility": null,
                        "name": "label",
                        "args": "$key",
                        "type": "string",
                        "operator": "arrow",
                        "body": "$this->translate ? en($this->labels[$key]) : $this->labels[$key]",
                        "line": 25,
                        "bodyLine": 25
                    },
                    "AsyncPOSTCookiewallAcceptAll": {
                        "node": "route",
                        "mode": "async",
                        "method": "POST",
                        "path": "cookiewall accept all",
                        "data": null,
                        "operator": "method",
                        "body": "\t%cookies->objSet('cookieChoice', 'all', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])\n\tapply(remove: '#cookiewall')",
                        "line": 27,
                        "bodyLine": 28,
                        "name": "AsyncPOSTCookiewallAcceptAll"
                    },
                    "AsyncPOSTCookiewallAcceptEssential": {
                        "node": "route",
                        "mode": "async",
                        "method": "POST",
                        "path": "cookiewall accept essential",
                        "data": null,
                        "operator": "method",
                        "body": "\t%cookies->objSet('cookieChoice', 'essential', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])\n\tapply(remove: '#cookiewall')",
                        "line": 32,
                        "bodyLine": 33,
                        "name": "AsyncPOSTCookiewallAcceptEssential"
                    },
                    "banner": {
                        "node": "view",
                        "visibility": null,
                        "name": "banner",
                        "args": null,
                        "type": null,
                        "operator": "view",
                        "body": "<if !$this->hasChosen()>\n\t<div#cookiewall role=region aria-label=\"{{ $this->label('region') }}\">\n\t\t<p>{{ $this->label('body') }}</p>\n\t\t<div.actions>\n\t\t\t<form.async method=post action=/cookiewall/accept/essential>\n\t\t\t\t<button.ghost type=submit>{{ $this->label('essential') }}</button>\n\t\t\t</form>\n\t\t\t<form.async method=post action=/cookiewall/accept/all>\n\t\t\t\t<button.primary type=submit>{{ $this->label('accept') }}</button>\n\t\t\t</form>\n\t\t</div>\n\t</div>\n</if>",
                        "line": 37
                    }
                },
                "functions": [],
                "assets": [
                    {
                        "node": "style",
                        "ns": null,
                        "line": 52,
                        "body": "#cookiewall {\n\tbackground: #1a1a1a\n\tborder-radius: 8px\n\tbottom: 16px\n\tbox-shadow: 0 8px 32px #0004\n\tcolor: #fff\n\tfont-size: 13px\n\tleft: 16px\n\tline-height: 1.5\n\tmax-width: 360px\n\tpadding: 14px 16px\n\tposition: fixed\n\tright: 16px\n\tz-index: 9999\n\t@media(min-width: 600px): right: auto\n\tp: margin: 0 0 10px\n\t.actions {\n\t\tdisplay: flex\n\t\tgap: 8px\n\t\tjustify-content: flex-end\n\t}\n\tbutton {\n\t\tborder-radius: 4px\n\t\tborder: 0\n\t\tcursor: pointer\n\t\tfont-size: 12px\n\t\tpadding: 6px 12px\n\t}\n\tbutton.ghost {\n\t\tbackground: #444\n\t\tcolor: #fff\n\t}\n\tbutton.primary {\n\t\tbackground: #fff\n\t\tcolor: #1a1a1a\n\t\tfont-weight: 600\n\t}\n}"
                    }
                ]
            },
            "CSS_fixes": {
                "file": "/srv/control/phlo/resources/DOM/CSS.fixes.phlo",
                "class": "CSS_fixes",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Single Page App basic CSS boilerplate fixes",
                    "advice": "The handful of corrections nearly every app makes anyway: border-box sizing, no tap delay on anything clickable, no spinners on a number field, collapsed table borders and a [hidden] that actually hides. Note that it also clears the focus outline on inputs and buttons, so give focus a visible state of your own or keyboard users lose their place.",
                    "package": "css",
                    "frontend": "true",
                    "backend": "false",
                    "tags": "css fixes boilerplate reset"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "style",
                        "ns": null,
                        "line": 10,
                        "body": "*, ::before, ::after: box-sizing: border-box\na, area, button, input, label, select, summary, textarea, [tabindex]: touch-action: manipulation\nbutton:focus, input:focus, select:focus, textarea:focus, [contenteditable]:focus: outline: 0\ninput::-webkit-outer-spin-button, input::-webkit-inner-spin-button: -webkit-appearance: none\ninput[type=\"number\"]: -moz-appearance: textfield\ntable: border-collapse: collapse\n[hidden]: display: none !important\n::-ms-expand: display: none"
                    }
                ]
            },
            "CSS_var": {
                "file": "/srv/control/phlo/resources/DOM/CSS.var.phlo",
                "class": "CSS_var",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "CSS variable proxy via app.var",
                    "advice": "app.var reads and writes CSS custom properties as if they were an object, so the server can change a colour, a size or a spacing with a command instead of a stylesheet swap. It writes on the root element, so what you set applies everywhere that inherits it.",
                    "package": "css",
                    "frontend": "true",
                    "backend": "false",
                    "tags": "css variables app.var frontend"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 10,
                        "body": "Object.defineProperty(app, 'var', {get(){return new Proxy({}, {get(_, key){return getComputedStyle(document.documentElement).getPropertyValue(`--${key}`).trim()}, set(_, key, value){ return document.documentElement.style.setProperty(`--${key}`, value)}})}, configurable: true})\napp.mod.setvar = (key, value) => app.var[key] = value"
                    }
                ]
            },
            "datatags": {
                "file": "/srv/control/phlo/resources/DOM/datatags.phlo",
                "class": "datatags",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Single Page App datatag plugin",
                    "advice": "Turn any element into a request without writing a handler: data-get, data-post, data-put, data-patch or data-delete holds the path, and with post, put and patch every other data attribute travels along as a field. That is why an element that also carries data-confirm is left alone here: the dialog resource asks first and clicks it again afterwards. Attribute names arrive dash-lowered as the browser gives them, so keep them one word.",
                    "package": "dom",
                    "frontend": "true",
                    "backend": "false",
                    "requires": "@DOM",
                    "tags": "dom datatag dataset spa events"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 11,
                        "body": "on('click', '[data-get], [data-post], [data-put], [data-patch], [data-delete]', (el, e) => {\n\tif (el.dataset.confirm) return\n\te.preventDefault()\n\tlet method, path, data = null\n\tif ((path = el.dataset.get) !== undefined) method = 'get'\n\telse if ((path = el.dataset.delete) !== undefined) method = 'delete'\n\telse [data = {}, Object.keys(el.dataset).forEach(key => key === 'post' || key === 'put' || key === 'patch' ? [method = key, path = el.dataset[key]] : data[key] = el.dataset[key])]\n\tapp[method](path, data)\n})"
                    }
                ]
            },
            "dialog": {
                "file": "/srv/control/phlo/resources/DOM/dialog.phlo",
                "class": "dialog",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Single Page App dialog resource",
                    "advice": "Replaces alert, confirm and prompt with a real dialog element, so they no longer block the page and no longer break an automated session. They answer a promise, so await them. Put data-confirm on a link or a button to ask before it does anything: the question is asked once, then the original action runs.",
                    "package": "dom",
                    "frontend": "true",
                    "backend": "false",
                    "requires": "@DOM",
                    "tags": "dom dialog modal confirm prompt alert"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 11,
                        "body": "window.alert = app.mod.alert = msg => phlo.dialog('alert', msg)\nwindow.confirm = msg => phlo.dialog('confirm', msg)\nwindow.prompt = (msg, defaultValue) => phlo.dialog('prompt', msg, defaultValue)\n\nphlo.dialog = async (type, message, defaultValue = '') => new Promise(resolve => {\n\tapp.mod.append('body', '<dialog id=\"phloDialog\" class=\"phlo-dialog\" role=\"dialog\" aria-modal=\"true\">\\n<form method=\"dialog\">\\n<p class=\"phlo-dialog__message\"></p>\\n' + (type === 'prompt' ? '<input class=\"phlo-dialog__input\" name=\"value\">' : '') + '\\n<menu class=\"phlo-dialog__actions\">\\n<button value=\"1\" autofocus>OK</button>\\n' + (type !== 'alert' ? '<button value=\"0\">Cancel</button>' : '') + '\\n</menu>\\n</form>\\n</dialog>')\n\tconst dialog = obj('#phloDialog')\n\tconst messageEl = dialog.querySelector('.phlo-dialog__message')\n\tmessageEl && (messageEl.textContent = String(message ?? ''))\n\tif (type === 'prompt') dialog.querySelector('input').value = String(defaultValue ?? '')\n\tdialog.showModal()\n\tdialog.addEventListener('close', () => {\n\t\tconst value = dialog.returnValue\n\t\tconst input = dialog.querySelector('input')\n\t\tdialog.remove()\n\t\tif (type === 'alert') return resolve()\n\t\tif (type === 'confirm') return resolve(value === '1')\n\t\tif (type === 'prompt') return resolve(value === '1' ? input.value : null)\n\t})\n})\n\non('click', '[data-confirm]', async (el, e) => {\n\te.preventDefault()\n\tif (!await window.confirm(el.dataset.confirm)) return\n\tdelete el.dataset.confirm\n\tapp.update()\n\tel.click()\n})"
                    }
                ]
            },
            "exists": {
                "file": "/srv/control/phlo/resources/DOM/exists.phlo",
                "class": "exists",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "onExist helper for dynamic SPA elements",
                    "advice": "on() binds to what is there now, which is why it does not survive a page swap; onExist runs your callback the first time an element appears and only then, however it got there. That makes it the right hook for anything a plugin has to prepare once, and it is what the store and the numpad use themselves.",
                    "package": "dom",
                    "frontend": "true",
                    "backend": "false",
                    "requires": "@DOM",
                    "tags": "dom onexist spa lifecycle"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 11,
                        "body": "phlo.exist = []\nphlo.existing = new WeakMap\n\nconst onExist = (els, cb) => phlo.exist.push({els, cb})\n\napp.updates.push(() => {\n\tconst existing = []\n\tphlo.exist.forEach(item => objects(item.els).forEach(el => phlo.existing.has(el) || existing.push({el, cb: item.cb})))\n\texisting.forEach(item => [phlo.existing.has(item.el) || phlo.existing.set(item.el, 'exist'), item.cb(item.el)])\n})"
                    }
                ]
            },
            "ffmpeg": {
                "file": "/srv/control/phlo/resources/DOM/ffmpeg.phlo",
                "class": "ffmpeg",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "ffmpeg-wasm for the DOM: encode a canvas timeline to MP4, decode source frames via WebCodecs (seek fallback), transcode/run arbitrary ffmpeg. Exposes the ready singleton `ffmpeg` (and class `Ffmpeg`).",
                    "advice": "Encodes and converts video in the browser through ffmpeg-wasm, so no file has to leave the machine and no server has to be equipped for it. That comes at a price: several megabytes of wasm on first use, and encoding costs real time and memory, so it fits a clip rather than an hour of video. It loads the multithreaded core only on a cross-origin isolated page and falls back to a single-threaded one otherwise, which still works but is markedly slower.",
                    "package": "dom",
                    "frontend": "true",
                    "backend": "false",
                    "tags": "video ffmpeg wasm canvas encode transcode webcodecs mp4 render"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 17,
                        "comments": "ffmpeg-wasm brought to the browser DOM.\nUse the ready singleton `ffmpeg`, or `new Ffmpeg({corePath})`; only Ffmpeg and ffmpeg\nenter the bundle scope.\nDecoding prefers WebCodecs with an MP4Box demux and falls back to seeking a\n<video> for containers or codecs it cannot handle. Encoding prefers the\nmultithreaded core and retries single-threaded when it stalls, so a browser\nwithout cross-origin isolation still finishes, only slower.",
                        "body": "class Ffmpeg {\n\n\tconstructor(opts = {}){\n\t\tthis.corePath = opts.corePath || '/ffmpeg/'\n\t\tthis.mp4boxURL = opts.mp4boxURL || '/mp4box.js'\n\t\tthis.muxerURL = opts.muxerURL || '/mp4-muxer.js'\n\t\tthis.log = opts.log || (() => {})\n\t\tthis.ffmpeg = null\n\t\tthis.mode = null\n\t\tthis.scripts = {}\n\t\tthis.demuxKey = null\n\t\tthis.demuxVal = null\n\t}\n\n\tloadScript(src){\n\t\tif (this.scripts[src]) return this.scripts[src]\n\t\tthis.scripts[src] = new Promise((resolve, reject) => {\n\t\t\tconst el = document.createElement('script')\n\t\t\tel.src = src\n\t\t\tconst nonce = document.querySelector('meta[name=\"nonce\"]')\n\t\t\tif (nonce) el.nonce = nonce.content\n\t\t\tel.onload = () => resolve()\n\t\t\tel.onerror = () => {\n\t\t\t\tdelete this.scripts[src]\n\t\t\t\treject(new Error('ffmpeg: failed to load ' + src))\n\t\t\t}\n\t\t\tdocument.head.appendChild(el)\n\t\t})\n\t\treturn this.scripts[src]\n\t}\n\n\tasync frameSource(src, opts = {}){\n\t\tconst from = opts.from || 0\n\t\tconst to = opts.to || 0\n\t\tconst mp4boxURL = opts.mp4boxURL || this.mp4boxURL\n\t\tconst log = opts.log || this.log\n\t\tconst isFile = (typeof File !== 'undefined') && src instanceof File\n\t\tconst seek = () => this.seekSource(isFile ? URL.createObjectURL(src) : src, isFile)\n\t\tif (!('VideoDecoder' in window)) return seek()\n\t\tlet wc = null\n\t\ttry {\n\t\t\tawait this.loadScript(mp4boxURL)\n\t\t\tconst size = await this.sourceSize(src)\n\t\t\tconst key = isFile ? src.name + ':' + src.size + ':' + src.lastModified : src\n\t\t\tlet demuxed\n\t\t\tif (this.demuxKey === key) demuxed = this.demuxVal\n\t\t\telse {\n\t\t\t\tdemuxed = await this.demux(src, size)\n\t\t\t\tthis.demuxKey = key\n\t\t\t\tthis.demuxVal = demuxed\n\t\t\t}\n\t\t\tconst support = await VideoDecoder.isConfigSupported(demuxed.config)\n\t\t\tif (!support.supported){\n\t\t\t\tlog('[ffmpeg] codec ' + demuxed.config.codec + ' not decodable, using seek')\n\t\t\t\treturn seek()\n\t\t\t}\n\t\t\tlog('[ffmpeg] WebCodecs ' + demuxed.config.codec + ', ' + demuxed.samples.length + ' frames, ' + Math.round(size / 1048576) + ' MiB streamed')\n\t\t\tconst build = async acceleration => {\n\t\t\t\tconst config = acceleration ? Object.assign({}, demuxed.config, {hardwareAcceleration: acceleration}) : demuxed.config\n\t\t\t\tconst source = this.webCodecsSource(src, size, config, demuxed.samples, from, to)\n\t\t\t\ttry {\n\t\t\t\t\tif (!await source.getFrame(from)) throw new Error('decoder produced no frames')\n\t\t\t\t}\n\t\t\t\tcatch (err){\n\t\t\t\t\tsource.close()\n\t\t\t\t\tthrow err\n\t\t\t\t}\n\t\t\t\treturn source\n\t\t\t}\n\t\t\ttry {\n\t\t\t\twc = await build()\n\t\t\t}\n\t\t\tcatch (err){\n\t\t\t\tlog('[ffmpeg] hardware decode failed (' + (err && err.message ? err.message : err) + '), retrying software')\n\t\t\t\twc = await build('prefer-software')\n\t\t\t}\n\t\t\treturn wc\n\t\t}\n\t\tcatch (err){\n\t\t\tif (wc) try { wc.close() } catch (e){}\n\t\t\tlog('[ffmpeg] WebCodecs unavailable (' + (err && err.message ? err.message : err) + '), using seek')\n\t\t\treturn seek()\n\t\t}\n\t}\n\n\tasync sourceSize(src){\n\t\tif ((typeof File !== 'undefined') && src instanceof File) return src.size\n\t\tconst res = await fetch(src, {method: 'HEAD'})\n\t\tconst length = res.headers.get('content-length')\n\t\tif (!length) throw new Error('source size unknown')\n\t\treturn parseInt(length, 10)\n\t}\n\n\tasync readRange(src, offset, length){\n\t\tif ((typeof File !== 'undefined') && src instanceof File) return new Uint8Array(await src.slice(offset, offset + length).arrayBuffer())\n\t\tconst res = await fetch(src, {headers: {Range: 'bytes=' + offset + '-' + (offset + length - 1)}})\n\t\tif (res.status !== 206) throw new Error('no range support: ' + res.status)\n\t\treturn new Uint8Array(await res.arrayBuffer())\n\t}\n\n\tasync demux(src, size){\n\t\tconst mp4 = MP4Box.createFile()\n\t\tlet info = null\n\t\tlet error = null\n\t\tmp4.onReady = i => info = i\n\t\tmp4.onError = e => error = e\n\t\tconst step = 1 << 20\n\t\tlet next = 0\n\t\twhile (!info && !error && next < size){\n\t\t\tconst bytes = await this.readRange(src, next, Math.min(step, size - next))\n\t\t\tif (!bytes.length) break\n\t\t\tconst buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)\n\t\t\tbuffer.fileStart = next\n\t\t\tconst parsed = mp4.appendBuffer(buffer)\n\t\t\tif (parsed == null || parsed <= next) break\n\t\t\tnext = parsed\n\t\t}\n\t\tif (error) throw new Error('demux error: ' + error)\n\t\tconst track = info && info.videoTracks && info.videoTracks[0]\n\t\tif (!track) throw new Error('no video track')\n\t\tconst trak = mp4.getTrackById(track.id)\n\t\tlet description = null\n\t\tfor (const entry of trak.mdia.minf.stbl.stsd.entries){\n\t\t\tconst box = entry.avcC || entry.hvcC || entry.vpcC || entry.av1C\n\t\t\tif (box){\n\t\t\t\tconst stream = new DataStream(undefined, 0, DataStream.BIG_ENDIAN)\n\t\t\t\tbox.write(stream)\n\t\t\t\tdescription = new Uint8Array(stream.buffer, 8)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tconst samples = (trak.samples || []).map(s => ({offset: s.offset, size: s.size, cts: s.cts, duration: s.duration, timescale: s.timescale, key: !!s.is_sync}))\n\t\tif (!samples.length) throw new Error('demux produced no samples')\n\t\treturn {config: {codec: track.codec, codedWidth: track.video.width, codedHeight: track.video.height, description}, samples}\n\t}\n\n\twebCodecsSource(src, size, config, samples, startT, endT){\n\t\tconst decoded = []\n\t\tlet error = null\n\t\tlet waiter = null\n\t\tlet target = -Infinity\n\t\tconst signal = () => {\n\t\t\tif (!waiter) return\n\t\t\tconst w = waiter\n\t\t\twaiter = null\n\t\t\tw()\n\t\t}\n\t\tconst prune = () => {\n\t\t\tlet keep = 0\n\t\t\tfor (let i = 0; i < decoded.length; i++){\n\t\t\t\tif (decoded[i].timestamp <= target + 1000) keep = i\n\t\t\t\telse break\n\t\t\t}\n\t\t\tfor (let i = 0; i < keep; i++) decoded[i].close()\n\t\t\tif (keep) decoded.splice(0, keep)\n\t\t}\n\t\tconst decoder = new VideoDecoder({\n\t\t\toutput: f => {\n\t\t\t\tdecoded.push(f)\n\t\t\t\tprune()\n\t\t\t\tsignal()\n\t\t\t},\n\t\t\terror: e => {\n\t\t\t\terror = e\n\t\t\t\tsignal()\n\t\t\t},\n\t\t})\n\t\tdecoder.configure(config)\n\n\t\tconst seconds = s => s.cts / s.timescale\n\t\tlet fed = 0\n\t\tlet last = samples.length - 1\n\t\tfor (let i = 0; i < samples.length; i++) if (samples[i].key && seconds(samples[i]) <= startT) fed = i\n\t\tif (endT > startT){\n\t\t\tfor (let i = samples.length - 1; i >= fed; i--) if (seconds(samples[i]) <= endT){\n\t\t\t\tlast = Math.min(samples.length - 1, i + 2)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tconst blockSize = 8 << 20\n\t\tconst self = this\n\t\tlet block = null\n\t\tconst ensure = async (offset, need) => {\n\t\t\tif (block && offset >= block.start && offset + need <= block.end) return\n\t\t\tconst end = Math.min(size, Math.max(offset + need, offset + blockSize))\n\t\t\tblock = {start: offset, end, bytes: await self.readRange(src, offset, end - offset)}\n\t\t}\n\t\tconst nextChunk = async () => {\n\t\t\tconst s = samples[fed++]\n\t\t\tawait ensure(s.offset, s.size)\n\t\t\tconst at = s.offset - block.start\n\t\t\treturn new EncodedVideoChunk({\n\t\t\t\ttype: s.key ? 'key' : 'delta',\n\t\t\t\ttimestamp: Math.round(s.cts * 1e6 / s.timescale),\n\t\t\t\tduration: Math.round((s.duration || 0) * 1e6 / s.timescale),\n\t\t\t\tdata: block.bytes.subarray(at, at + s.size),\n\t\t\t})\n\t\t}\n\t\tlet flushed = false\n\n\t\tconst pump = async tMicros => {\n\t\t\ttarget = tMicros\n\t\t\twhile (!error){\n\t\t\t\tprune()\n\t\t\t\tif (decoded.length && decoded[decoded.length - 1].timestamp > tMicros) return\n\t\t\t\tif (fed <= last){\n\t\t\t\t\twhile (fed <= last && decoder.decodeQueueSize < 4 && decoded.length < 4) decoder.decode(await nextChunk())\n\t\t\t\t\tawait new Promise(r => {\n\t\t\t\t\t\twaiter = r\n\t\t\t\t\t\tsetTimeout(signal, 100)\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\telse {\n\t\t\t\t\tif (!flushed){\n\t\t\t\t\t\tflushed = true\n\t\t\t\t\t\tawait decoder.flush().catch(e => error = e)\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn {\n\t\t\tmode: 'webcodecs',\n\t\t\tasync getFrame(t){\n\t\t\t\tconst tM = Math.round(t * 1e6)\n\t\t\t\tawait Promise.race([pump(tM), new Promise((res, rej) => setTimeout(() => rej(new Error('WebCodecs decode timeout')), 20000))])\n\t\t\t\tif (error) throw error\n\t\t\t\tlet idx = 0\n\t\t\t\tfor (let i = 0; i < decoded.length; i++){\n\t\t\t\t\tif (decoded[i].timestamp <= tM + 1000) idx = i\n\t\t\t\t\telse break\n\t\t\t\t}\n\t\t\t\tfor (let i = 0; i < idx; i++) decoded[i].close()\n\t\t\t\tdecoded.splice(0, idx)\n\t\t\t\treturn decoded[0] || null\n\t\t\t},\n\t\t\tclose(){\n\t\t\t\tblock = null\n\t\t\t\tfor (const f of decoded) f.close()\n\t\t\t\tdecoded.length = 0\n\t\t\t\ttry {\n\t\t\t\t\tdecoder.close()\n\t\t\t\t}\n\t\t\t\tcatch (e){}\n\t\t\t},\n\t\t}\n\t}\n\n\tseekTo(rv, time){\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tconst timer = setTimeout(() => reject(new Error('seek timeout')), 20000)\n\t\t\trv.addEventListener('seeked', function once(){\n\t\t\t\trv.removeEventListener('seeked', once)\n\t\t\t\tclearTimeout(timer)\n\t\t\t\tresolve()\n\t\t\t})\n\t\t\trv.currentTime = time\n\t\t})\n\t}\n\n\tasync seekSource(srcUrl, revoke){\n\t\tconst rv = document.createElement('video')\n\t\trv.muted = true\n\t\trv.preload = 'auto'\n\t\trv.src = srcUrl\n\t\tconst self = this\n\t\tawait new Promise((resolve, reject) => {\n\t\t\tconst timer = setTimeout(() => reject(new Error('video load timeout')), 20000)\n\t\t\trv.addEventListener('loadedmetadata', () => {\n\t\t\t\tclearTimeout(timer)\n\t\t\t\tresolve()\n\t\t\t}, {once: true})\n\t\t\trv.addEventListener('error', () => {\n\t\t\t\tclearTimeout(timer)\n\t\t\t\treject(new Error('Could not load video for rendering'))\n\t\t\t}, {once: true})\n\t\t})\n\t\treturn {\n\t\t\tmode: 'seek',\n\t\t\tasync getFrame(t){\n\t\t\t\tawait self.seekTo(rv, Math.min(t, (rv.duration || t + 1) - 0.001))\n\t\t\t\treturn rv\n\t\t\t},\n\t\t\tclose(){\n\t\t\t\trv.removeAttribute('src')\n\t\t\t\trv.load()\n\t\t\t\tif (revoke) URL.revokeObjectURL(srcUrl)\n\t\t\t},\n\t\t}\n\t}\n\n\tdrop(){\n\t\tif (!this.ffmpeg) return\n\t\ttry {\n\t\t\tthis.ffmpeg.terminate()\n\t\t}\n\t\tcatch (e){}\n\t\tthis.ffmpeg = null\n\t}\n\n\tasync load(log){\n\t\tlog = log || this.log\n\t\tawait this.loadScript(this.corePath + 'ffmpeg.js')\n\t\tconst mode = (window.crossOriginIsolated && !window.ffmpegForceST) ? 'mt' : 'st'\n\t\tif (this.ffmpeg && this.ffmpeg.loaded && this.mode === mode) return this.ffmpeg\n\t\tthis.drop()\n\t\tlog('[ffmpeg] loading core: ' + mode)\n\t\tthis.ffmpeg = new FFmpegWASM.FFmpeg()\n\t\tthis.ffmpeg.on('log', e => log('[ffmpeg] ' + e.message))\n\t\tthis.mode = mode\n\t\tconst base = new URL(this.corePath, location.origin).href\n\t\tawait this.ffmpeg.load(mode === 'mt'\n\t\t\t? {coreURL: base + 'ffmpeg-core.js', wasmURL: base + 'ffmpeg-core.wasm', workerURL: base + 'ffmpeg-core.worker.js'}\n\t\t\t: {coreURL: base + 'ffmpeg-core-st.js', wasmURL: base + 'ffmpeg-core-st.wasm'})\n\t\treturn this.ffmpeg\n\t}\n\n\tasync exec(args, log){\n\t\tlog = log || this.log\n\t\tconst ff = this.ffmpeg\n\t\tlog('[ffmpeg] $ ffmpeg ' + args.join(' '))\n\t\tlet beat = performance.now()\n\t\tconst bump = () => beat = performance.now()\n\t\tff.on('log', bump)\n\t\tff.on('progress', bump)\n\t\tconst watchdog = setInterval(() => {\n\t\t\tif (performance.now() - beat > 25000) ff.terminate()\n\t\t}, 4000)\n\t\ttry {\n\t\t\tawait ff.exec(args)\n\t\t}\n\t\tfinally {\n\t\t\tclearInterval(watchdog)\n\t\t\tff.off('log', bump)\n\t\t\tff.off('progress', bump)\n\t\t}\n\t}\n\n\tasync withFallback(fn, onProgress, log){\n\t\tlog = log || this.log\n\t\ttry {\n\t\t\treturn await fn()\n\t\t}\n\t\tcatch (err){\n\t\t\tif (this.mode !== 'mt') throw err\n\t\t\tlog('[ffmpeg] multithreaded stalled (' + (err && err.message ? err.message : err) + '); retrying single-threaded')\n\t\t\twindow.ffmpegForceST = true\n\t\t\tthis.drop()\n\t\t\tif (onProgress) onProgress('Multithreaded encoder stalled; retrying single-threaded...', 3)\n\t\t\treturn fn()\n\t\t}\n\t}\n\n\tasync run(opts){\n\t\tconst log = opts.log || this.log\n\t\treturn this.withFallback(async () => {\n\t\t\tconst ff = await this.load(log)\n\t\t\tconst inName = opts.inputName || 'input'\n\t\t\tconst outName = opts.output || 'output'\n\t\t\tconst cleanups = []\n\t\t\tif (opts.input != null){\n\t\t\t\tconst src = opts.input\n\t\t\t\tconst bytes = src instanceof Uint8Array ? src\n\t\t\t\t\t: (typeof Blob !== 'undefined' && src instanceof Blob ? new Uint8Array(await src.arrayBuffer())\n\t\t\t\t\t: new Uint8Array(await fetch(src).then(r => r.arrayBuffer())))\n\t\t\t\tawait ff.writeFile(inName, bytes)\n\t\t\t\tcleanups.push(() => ff.deleteFile(inName).catch(() => {}))\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tawait this.exec(opts.args, log)\n\t\t\t\tconst out = await ff.readFile(outName)\n\t\t\t\tcleanups.push(() => ff.deleteFile(outName).catch(() => {}))\n\t\t\t\treturn new Blob([out.buffer], {type: opts.type || 'video/mp4'})\n\t\t\t}\n\t\t\tfinally {\n\t\t\t\tfor (const c of cleanups) await c()\n\t\t\t}\n\t\t}, opts.onProgress, log)\n\t}\n\n\tasync transcode(input, opts = {}){\n\t\tconst to = opts.to || 'mp4'\n\t\tconst inName = 'tc-in'\n\t\tconst outName = 'tc-out.' + to\n\t\tconst args = ['-i', inName]\n\t\tif (opts.args) args.push(...opts.args)\n\t\telse args.push('-c:v', 'libx264', '-preset', 'veryfast', '-crf', String(opts.crf || 20), '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart')\n\t\targs.push(outName)\n\t\treturn this.run({input, inputName: inName, output: outName, args, type: opts.type || 'video/' + to, log: opts.log, onProgress: opts.onProgress})\n\t}\n\n\tasync encode(opts){\n\t\tthis.demuxKey = null\n\t\tthis.demuxVal = null\n\t\tconst log = opts.log || this.log\n\t\tif ('VideoEncoder' in window && !window.ffmpegForceEncoder){\n\t\t\ttry {\n\t\t\t\treturn await this.encodeWebCodecs(opts)\n\t\t\t}\n\t\t\tcatch (err){\n\t\t\t\tlog('[ffmpeg] hardware encode failed (' + (err && err.message ? err.message : err) + '), retrying software')\n\t\t\t\ttry {\n\t\t\t\t\treturn await this.encodeWebCodecs(Object.assign({}, opts, {acceleration: 'prefer-software'}))\n\t\t\t\t}\n\t\t\t\tcatch (err2){\n\t\t\t\t\tlog('[ffmpeg] WebCodecs encode failed (' + (err2 && err2.message ? err2.message : err2) + '), using ffmpeg')\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn this.withFallback(() => this.pass(opts), opts.onProgress, log)\n\t}\n\n\tasync pickVideoCodec(width, height, fps, bitrate, acceleration){\n\t\tfor (const codec of ['avc1.640028', 'avc1.4d0028', 'avc1.42001f']){\n\t\t\tconst config = {codec, width, height, bitrate, framerate: fps}\n\t\t\tif (acceleration) config.hardwareAcceleration = acceleration\n\t\t\ttry {\n\t\t\t\tconst support = await VideoEncoder.isConfigSupported(config)\n\t\t\t\tif (support.supported) return codec\n\t\t\t}\n\t\t\tcatch (e){}\n\t\t}\n\t\treturn null\n\t}\n\n\tasync encodeWebCodecs(opts){\n\t\tconst log = opts.log || this.log\n\t\tconst fps = opts.fps\n\t\tconst from = opts.from || 0\n\t\tconst to = opts.to\n\t\tconst onProgress = opts.onProgress || (() => {})\n\t\tconst audio = opts.audio || []\n\t\tconst W = Math.round(opts.width) - (Math.round(opts.width) % 2)\n\t\tconst H = Math.round(opts.height) - (Math.round(opts.height) % 2)\n\t\tconst total = Math.max(1, Math.round((to - from) * fps))\n\t\tconst bitrate = opts.bitrate || Math.round(W * H * fps * 0.15)\n\n\t\tawait this.loadScript(this.muxerURL)\n\t\tconst codec = await this.pickVideoCodec(W, H, fps, bitrate, opts.acceleration)\n\t\tif (!codec) throw new Error('no supported h264 encoder configuration')\n\t\tlog('[ffmpeg] WebCodecs encode ' + W + 'x' + H + ' @' + fps + 'fps, ' + total + ' frames, ' + codec + ', ' + Math.round(bitrate / 1000) + ' kbps' + (opts.acceleration ? ', ' + opts.acceleration : ''))\n\n\t\tconst target = new Mp4Muxer.ArrayBufferTarget()\n\t\tconst muxer = new Mp4Muxer.Muxer({target, video: {codec: 'avc', width: W, height: H}, fastStart: 'in-memory'})\n\t\tlet error = null\n\t\tconst encoder = new VideoEncoder({\n\t\t\toutput: (chunk, meta) => muxer.addVideoChunk(chunk, meta),\n\t\t\terror: e => error = e,\n\t\t})\n\t\tconst config = {codec, width: W, height: H, bitrate, framerate: fps, avc: {format: 'avc'}}\n\t\tif (opts.acceleration) config.hardwareAcceleration = opts.acceleration\n\t\tencoder.configure(config)\n\n\t\tconst canvas = opts.canvas || document.createElement('canvas')\n\t\tcanvas.width = W\n\t\tcanvas.height = H\n\t\tconst ctx = canvas.getContext('2d', {willReadFrequently: false})\n\t\tconst keyEvery = Math.max(1, Math.round(fps * 2))\n\n\t\tlet video\n\t\tif (opts.beforePass) await opts.beforePass()\n\t\ttry {\n\t\t\tfor (let i = 0; i < total; i++){\n\t\t\t\tif (error) throw error\n\t\t\t\tawait opts.drawFrame(ctx, from + i / fps, W, H)\n\t\t\t\tconst frame = new VideoFrame(canvas, {timestamp: Math.round(i * 1e6 / fps), duration: Math.round(1e6 / fps)})\n\t\t\t\tencoder.encode(frame, {keyFrame: i % keyEvery === 0})\n\t\t\t\tframe.close()\n\t\t\t\twhile (encoder.encodeQueueSize > 8 && !error) await new Promise(r => setTimeout(r, 0))\n\t\t\t\tonProgress('Rendering frame ' + (i + 1) + '/' + total, 5 + 85 * (i + 1) / total)\n\t\t\t}\n\t\t\tif (error) throw error\n\t\t\tawait Promise.race([encoder.flush(), new Promise((res, rej) => setTimeout(() => rej(new Error('encoder flush timeout')), 30000))])\n\t\t\tif (error) throw error\n\t\t\tmuxer.finalize()\n\t\t\tvideo = new Blob([target.buffer], {type: 'video/mp4'})\n\t\t}\n\t\tfinally {\n\t\t\ttry {\n\t\t\t\tencoder.close()\n\t\t\t}\n\t\t\tcatch (e){}\n\t\t\tif (opts.afterPass) await opts.afterPass()\n\t\t}\n\t\tlog('[ffmpeg] WebCodecs video ' + video.size + ' bytes')\n\t\tif (!audio.length) return video\n\t\treturn this.muxAudio(video, audio, onProgress, log)\n\t}\n\n\tasync muxAudio(video, audio, onProgress, log){\n\t\tlog = log || this.log\n\t\tonProgress('Muxing audio...', 92)\n\t\tconst ff = await this.load(log)\n\t\tawait ff.writeFile('wcv.mp4', new Uint8Array(await video.arrayBuffer()))\n\t\tlet prepared\n\t\ttry {\n\t\t\tprepared = await this.prepareAudio(ff, audio)\n\t\t}\n\t\tcatch (err){\n\t\t\tlog('[ffmpeg] audio unavailable, keeping silent video: ' + (err && err.message ? err.message : err))\n\t\t\tawait ff.deleteFile('wcv.mp4').catch(() => {})\n\t\t\treturn video\n\t\t}\n\t\tconst args = ['-i', 'wcv.mp4'].concat(prepared.args)\n\t\tconst ins = prepared.inputs\n\t\tif (ins.length === 1 && ins[0].volume === 1 && !ins[0].delay) args.push('-map', '0:v', '-map', '1:a?', '-c', 'copy', '-shortest', 'out.mp4')\n\t\telse if (ins.length === 1 && !ins[0].delay) args.push('-map', '0:v', '-map', '1:a?', '-c:v', 'copy', '-filter:a', 'volume=' + ins[0].volume, '-c:a', 'aac', '-b:a', '192k', '-shortest', 'out.mp4')\n\t\telse {\n\t\t\tconst parts = ins.map((a, i) => '[' + (i + 1) + ':a]' + (a.delay ? 'adelay=' + Math.round(a.delay) + ':all=1,' : '') + 'volume=' + a.volume + '[a' + i + ']')\n\t\t\tconst mix = ins.map((a, i) => '[a' + i + ']').join('') + 'amix=inputs=' + ins.length + ':duration=longest:normalize=0[aout]'\n\t\t\targs.push('-filter_complex', parts.join(';') + ';' + mix, '-map', '0:v', '-map', '[aout]', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-shortest', 'out.mp4')\n\t\t}\n\t\ttry {\n\t\t\tawait this.exec(args, log)\n\t\t\tonProgress('Reading result...', 98)\n\t\t\tconst out = await ff.readFile('out.mp4')\n\t\t\treturn new Blob([out.buffer], {type: 'video/mp4'})\n\t\t}\n\t\tcatch (err){\n\t\t\tlog('[ffmpeg] audio mux failed, keeping silent video: ' + (err && err.message ? err.message : err))\n\t\t\tvideo.silent = true\n\t\t\treturn video\n\t\t}\n\t\tfinally {\n\t\t\tawait ff.deleteFile('wcv.mp4').catch(() => {})\n\t\t\tawait ff.deleteFile('out.mp4').catch(() => {})\n\t\t\tawait prepared.cleanup()\n\t\t}\n\t}\n\n\tasync prepareAudio(ff, audio){\n\t\tconst args = []\n\t\tconst inputs = []\n\t\tconst cleanups = []\n\t\tlet mountIdx = 0\n\t\tfor (const a of audio){\n\t\t\tif (!a) continue\n\t\t\tlet path\n\t\t\tif (a.file){\n\t\t\t\tconst dir = '/ffaud' + (mountIdx++)\n\t\t\t\tawait ff.createDir(dir).catch(() => {})\n\t\t\t\tawait ff.mount(FFmpegWASM.FFFSType.WORKERFS, {files: [a.file]}, dir)\n\t\t\t\tpath = dir + '/' + a.file.name\n\t\t\t\tcleanups.push(() => ff.unmount(dir).catch(() => {}))\n\t\t\t}\n\t\t\telse {\n\t\t\t\tconst ext = String(a.name || a.url || 'a.mp4').split('?')[0].split('.').pop() || 'mp4'\n\t\t\t\tconst name = a.name || ('ffaud' + inputs.length + '.' + ext)\n\t\t\t\tconst bytes = a.bytes ? a.bytes : new Uint8Array(await fetch(a.url).then(r => r.arrayBuffer()))\n\t\t\t\tawait ff.writeFile(name, bytes)\n\t\t\t\tpath = name\n\t\t\t\tcleanups.push(() => ff.deleteFile(name).catch(() => {}))\n\t\t\t}\n\t\t\tif (a.seek) args.push('-ss', String(a.seek))\n\t\t\targs.push('-i', path)\n\t\t\tinputs.push({volume: a.volume == null ? 1 : a.volume, delay: a.delay || 0})\n\t\t}\n\t\treturn {args, inputs, cleanup: async () => { for (const c of cleanups) await c() }}\n\t}\n\n\tasync pass(opts){\n\t\tconst log = opts.log || this.log\n\t\tconst fps = opts.fps\n\t\tconst from = opts.from || 0\n\t\tconst to = opts.to\n\t\tconst drawFrame = opts.drawFrame\n\t\tconst audio = opts.audio || []\n\t\tconst onProgress = opts.onProgress || (() => {})\n\t\tconst quality = opts.quality || 0.9\n\t\tconst crf = opts.crf || 20\n\t\tconst segmentFrames = opts.segmentFrames || 120\n\t\tconst W = Math.round(opts.width) - (Math.round(opts.width) % 2)\n\t\tconst H = Math.round(opts.height) - (Math.round(opts.height) % 2)\n\t\tconst ff = await this.load(log)\n\t\tconst canvas = opts.canvas || document.createElement('canvas')\n\t\tcanvas.width = W\n\t\tcanvas.height = H\n\t\tconst ctx = canvas.getContext('2d', {willReadFrequently: false})\n\t\tconst total = Math.max(1, Math.round((to - from) * fps))\n\t\tlog('[ffmpeg] encode ' + W + 'x' + H + ' @' + fps + 'fps, ' + total + ' frames, ' + from.toFixed(2) + '-' + to.toFixed(2) + 's, mode=' + this.mode)\n\n\t\tif (opts.beforePass) await opts.beforePass()\n\t\tconst segments = []\n\t\ttry {\n\t\t\tlet frame = 0\n\t\t\tlet segIdx = 0\n\t\t\twhile (frame < total){\n\t\t\t\tconst count = Math.min(segmentFrames, total - frame)\n\t\t\t\tfor (let i = 0; i < count; i++){\n\t\t\t\t\tconst t = from + (frame + i) / fps\n\t\t\t\t\tawait drawFrame(ctx, t, W, H)\n\t\t\t\t\tconst blob = await new Promise(res => canvas.toBlob(res, 'image/jpeg', quality))\n\t\t\t\t\tawait ff.writeFile('f' + String(i).padStart(6, '0') + '.jpg', new Uint8Array(await blob.arrayBuffer()))\n\t\t\t\t\tonProgress('Rendering frame ' + (frame + i + 1) + '/' + total, 5 + 75 * (frame + i + 1) / total)\n\t\t\t\t}\n\t\t\t\tconst segName = 'seg' + segIdx + '.mp4'\n\t\t\t\tonProgress('Encoding segment ' + (segIdx + 1) + '...', 5 + 75 * (frame + count) / total)\n\t\t\t\tawait this.exec(['-threads', '4', '-framerate', String(fps), '-i', 'f%06d.jpg', '-frames:v', String(count), '-c:v', 'libx264', '-preset', 'veryfast', '-crf', String(crf), '-pix_fmt', 'yuv420p', segName], log)\n\t\t\t\tfor (let i = 0; i < count; i++) await ff.deleteFile('f' + String(i).padStart(6, '0') + '.jpg').catch(() => {})\n\t\t\t\tsegments.push(segName)\n\t\t\t\tframe += count\n\t\t\t\tsegIdx++\n\t\t\t}\n\t\t}\n\t\tfinally {\n\t\t\tif (opts.afterPass) await opts.afterPass()\n\t\t}\n\n\t\tonProgress('Muxing audio...', 85)\n\t\tawait ff.writeFile('list.txt', new TextEncoder().encode(segments.map(s => \"file '\" + s + \"'\").join('\\n') + '\\n'))\n\t\tconst args = ['-f', 'concat', '-safe', '0', '-i', 'list.txt']\n\t\tlet prepared = {args: [], inputs: [], cleanup: async () => {}}\n\t\tif (audio.length){\n\t\t\ttry {\n\t\t\t\tprepared = await this.prepareAudio(ff, audio)\n\t\t\t}\n\t\t\tcatch (err){\n\t\t\t\tlog('[ffmpeg] audio unavailable, rendering silent: ' + (err && err.message ? err.message : err))\n\t\t\t}\n\t\t}\n\t\targs.push(...prepared.args)\n\t\tconst ins = prepared.inputs\n\t\tif (!ins.length) args.push('-map', '0:v', '-an', '-c:v', 'copy', 'out.mp4')\n\t\telse if (ins.length === 1 && !ins[0].delay){\n\t\t\targs.push('-map', '0:v', '-map', '1:a?', '-c:v', 'copy')\n\t\t\tif (ins[0].volume !== 1) args.push('-filter:a', 'volume=' + ins[0].volume)\n\t\t\targs.push('-c:a', 'aac', '-b:a', '192k', '-shortest', 'out.mp4')\n\t\t}\n\t\telse {\n\t\t\tconst parts = ins.map((a, i) => '[' + (i + 1) + ':a]' + (a.delay ? 'adelay=' + Math.round(a.delay) + ':all=1,' : '') + 'volume=' + a.volume + '[a' + i + ']')\n\t\t\tconst mix = ins.map((a, i) => '[a' + i + ']').join('') + 'amix=inputs=' + ins.length + ':duration=longest:normalize=0[aout]'\n\t\t\targs.push('-filter_complex', parts.join(';') + ';' + mix, '-map', '0:v', '-map', '[aout]', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-shortest', 'out.mp4')\n\t\t}\n\t\tawait this.exec(args, log)\n\n\t\tonProgress('Reading result...', 96)\n\t\tconst out = await ff.readFile('out.mp4')\n\t\tlog('[ffmpeg] output ' + out.length + ' bytes (' + segments.length + ' segments)')\n\t\tfor (const s of segments) await ff.deleteFile(s).catch(() => {})\n\t\tawait ff.deleteFile('list.txt').catch(() => {})\n\t\tawait ff.deleteFile('out.mp4').catch(() => {})\n\t\tawait prepared.cleanup()\n\t\treturn new Blob([out.buffer], {type: 'video/mp4'})\n\t}\n}\n\nconst ffmpeg = new Ffmpeg()"
                    }
                ]
            },
            "form": {
                "file": "/srv/control/phlo/resources/DOM/form.phlo",
                "class": "form",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Single Page App form handler and input state saver",
                    "advice": "A form with class async submits over the same channel as the rest and answers with commands rather than a new page, using its own method attribute. Apart from that, this keeps the DOM honest: what a visitor types is written back into the attributes, so the state that is saved and restored on a back button matches what is on screen.",
                    "package": "dom",
                    "frontend": "true",
                    "backend": "false",
                    "requires": "@DOM",
                    "tags": "dom form input state spa"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 11,
                        "body": "on('input change', 'input, select, textarea', input => {\n\tif (input.tagName === 'SELECT') input.querySelectorAll('option').forEach((option, index) => option.selected ? option.setAttribute('selected', '') : option.removeAttribute('selected'))\n\tif (input.type === 'checkbox') input.checked ? input.setAttribute('checked', '') : input.removeAttribute('checked')\n\tif (input.type === 'text' && input.value !== input.getAttribute('value')) input.setAttribute('value', input.value)\n\tif (input.type === 'textarea' && input.value !== input.innerHTML) input.innerHTML = input.value\n\tphlo.state.replace()\n\treturn false\n})\non('submit', 'form.async', (form, e) => [e.preventDefault(), app[(form.attributes.method?.value ?? 'GET').toLowerCase()](new URL(form.action).pathname.substr(1), new FormData(form))])"
                    }
                ]
            },
            "image_resizer": {
                "file": "/srv/control/phlo/resources/DOM/image.resizer.phlo",
                "class": "image_resizer",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Client-side file upload image resizer",
                    "advice": "Scales a picked file down in the browser before it is uploaded, so a phone photo of several megabytes leaves as a few hundred kilobytes. It only shrinks, keeps the aspect ratio, and gives a data URL back through the callback, which can go straight into a preview or a form field. The image field uses it, so an upload through the CMS is already covered.",
                    "package": "dom",
                    "frontend": "true",
                    "backend": "false",
                    "tags": "dom image resize upload canvas"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 10,
                        "body": "const imageResizer = (file, maxWidth, maxHeight, cb, quality = .8) => {\n\tconst img = new Image\n\timg.onload = () => {\n\t\tlet width = img.width, height = img.height\n\t\tconst aspectRatio = width / height\n\t\tif (width > maxWidth || height > maxHeight){\n\t\t\tif (width > height){\n\t\t\t\twidth = maxWidth\n\t\t\t\theight = Math.round(maxWidth / aspectRatio)\n\t\t\t}\n\t\t\telse {\n\t\t\t\theight = maxHeight\n\t\t\t\twidth = Math.round(maxHeight * aspectRatio)\n\t\t\t}\n\t\t}\n\t\tconst canvas = document.createElement('canvas')\n\t\tcanvas.width = width\n\t\tcanvas.height = height\n\t\tcanvas.getContext('2d').drawImage(img, 0, 0, width, height)\n\t\tcb(canvas.toDataURL(file.type, quality))\n\t}\n\timg.src = URL.createObjectURL(file)\n}"
                    }
                ]
            },
            "keyboard": {
                "file": "/srv/control/phlo/resources/DOM/keyboard.phlo",
                "class": "keyboard",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "On-screen keyboard for touch devices, with selectable layout",
                    "advice": "Put data-keyboard on a field to open a keyboard on focus; data-keyboard=\"azerty\" picks the layout. Inside a dialog the keys mount in that dialog; data-keyboard-dock on an element there places them in the flow. phlo.keyboard.layouts takes extra layouts.",
                    "package": "dom",
                    "frontend": "true",
                    "backend": "false",
                    "requires": "@DOM",
                    "tags": "dom keyboard onscreen touch input frontend"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 16,
                        "comments": "Every event is delegated from the body.\nThe keys are drawn outside the update cycle and a field can appear at any moment in a\nsingle page app, so binding per element would miss both.\nA layout is three letter rows; the digit row and the function keys are the same\neverywhere. Add your own with phlo.keyboard.layouts.dvorak = [...].",
                        "body": "phlo.keyboard = {\n\tlayouts: {\n\t\tqwerty: ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'],\n\t\tazerty: ['azertyuiop', 'qsdfghjklm', 'wxcvbn'],\n\t\tqwertz: ['qwertzuiop', 'asdfghjkl', 'yxcvbnm'],\n\t},\n\tdigits: '1234567890',\n\tshifted: {',': ';', '.': ':', '-': '_', \"'\": '\"'},\n\tlabels: {shift: '&uarr;', back: '&larr;', space: '&nbsp;', enter: '&crarr;', close: '&times;'},\n\topen: null,\n\tcaps: false,\n\tlayout: el => phlo.keyboard.layouts[el?.dataset.keyboard] ? el.dataset.keyboard : (el?.closest('[data-keyboard-layout]')?.dataset.keyboardLayout || 'qwerty'),\n\tkey: (value, label, cls) => `<button class=\"keyboard__key${cls ? ' ' + cls : ''}\" type=\"button\" data-keyboard-key=\"${value}\">${label ?? value}</button>`,\n\trows(name){\n\t\tconst layout = phlo.keyboard.layouts[name] || phlo.keyboard.layouts.qwerty\n\t\tconst cast = char => phlo.keyboard.caps ? (phlo.keyboard.shifted[char] ?? char.toUpperCase()) : char\n\t\tconst key = phlo.keyboard.key\n\t\tlet html = '<div class=\"keyboard__row\">' + [...phlo.keyboard.digits].map(char => key(char)).join('') + key('back', phlo.keyboard.labels.back, 'keyboard__key--wide') + '</div>'\n\t\tlayout.forEach((row, index) => {\n\t\t\thtml += '<div class=\"keyboard__row\">'\n\t\t\tif (index === 2) html += key('shift', phlo.keyboard.labels.shift, 'keyboard__key--wide' + (phlo.keyboard.caps ? ' keyboard__key--on' : ''))\n\t\t\thtml += [...row].map(char => key(cast(char))).join('')\n\t\t\tif (index === 2) html += key('enter', phlo.keyboard.labels.enter, 'keyboard__key--wide')\n\t\t\thtml += '</div>'\n\t\t})\n\t\thtml += '<div class=\"keyboard__row\">' + [key('-'), key(cast(\"'\")), key(' ', phlo.keyboard.labels.space, 'keyboard__key--space'), key(cast(',')), key(cast('.')), key('close', phlo.keyboard.labels.close, 'keyboard__key--close')].join('') + '</div>'\n\t\treturn html\n\t},\n\thost(target){\n\t\tconst root = target?.closest('dialog[open]') || document.body\n\t\treturn obj('[data-keyboard-dock]', root) || root\n\t},\n\trender(){\n\t\tconst target = phlo.keyboard.open\n\t\tif (!target) return\n\t\tconst host = phlo.keyboard.host(target)\n\t\tconst docked = host !== document.body && host.matches('[data-keyboard-dock]')\n\t\tif (obj('#keyboard')?.parentElement !== host) obj('#keyboard')?.remove()\n\t\tobj('#keyboard') || app.mod.append(host, '<div id=\"keyboard\" class=\"keyboard' + (docked ? ' keyboard--docked' : '') + '\" role=\"group\" aria-label=\"On-screen keyboard\"></div>')\n\t\tapp.mod.inner('#keyboard', phlo.keyboard.rows(phlo.keyboard.layout(target)))\n\t},\n\tshow(target){\n\t\tphlo.keyboard.open = target\n\t\tphlo.keyboard.caps = false\n\t\tphlo.keyboard.render()\n\t},\n\thide(){\n\t\tphlo.keyboard.open = null\n\t\tobj('#keyboard')?.remove()\n\t},\n\twrite(target, insert, back = false){\n\t\tconst start = target.selectionStart ?? target.value.length\n\t\tconst end = target.selectionEnd ?? start\n\t\tconst from = back && start === end ? Math.max(0, start - 1) : start\n\t\ttarget.value = target.value.slice(0, from) + insert + target.value.slice(end)\n\t\tconst caret = from + insert.length\n\t\ttarget.setSelectionRange?.(caret, caret)\n\t\ttarget.dispatchEvent(new Event('input', {bubbles: true}))\n\t},\n\tpress(name){\n\t\tconst target = phlo.keyboard.open\n\t\tif (!target) return\n\t\tif (!target.isConnected) return phlo.keyboard.hide()\n\t\tif (name === 'close') return phlo.keyboard.hide()\n\t\tif (name === 'shift'){\n\t\t\tphlo.keyboard.caps = !phlo.keyboard.caps\n\t\t\treturn phlo.keyboard.render()\n\t\t}\n\t\tif (name === 'back') return phlo.keyboard.write(target, '', true)\n\t\tif (name === 'enter'){\n\t\t\tconst form = target.closest('form')\n\t\t\tphlo.keyboard.hide()\n\t\t\ttarget.dispatchEvent(new Event('change', {bubbles: true}))\n\t\t\treturn form?.requestSubmit()\n\t\t}\n\t\tphlo.keyboard.write(target, name)\n\t\tif (phlo.keyboard.caps){\n\t\t\tphlo.keyboard.caps = false\n\t\t\tphlo.keyboard.render()\n\t\t}\n\t},\n}\n\napp.keyboard = {\n\tshow: selector => phlo.keyboard.show(obj(selector)),\n\thide: () => phlo.keyboard.hide(),\n}\n\non('focusin', 'body', (body, e) => {\n\tif (e.target.matches?.('[data-keyboard]')) phlo.keyboard.show(e.target)\n})\n\non('click', 'body', (body, e) => {\n\tconst el = e.target.closest?.('[data-keyboard-key]')\n\tif (!el) return\n\te.preventDefault()\n\tphlo.keyboard.press(el.dataset.keyboardKey)\n\tphlo.keyboard.open?.focus({preventScroll: true})\n})\n\non('pointerdown', 'body', (el, e) => {\n\tif (!phlo.keyboard.open) return\n\tif (e.target.closest('#keyboard') || e.target === phlo.keyboard.open) return\n\tconst surface = phlo.keyboard.open.closest('dialog[open]')\n\tif (surface && surface.contains(e.target)) return\n\tphlo.keyboard.hide()\n})\n\non('keydown', 'body', (el, e) => {\n\tif (e.key === 'Escape') phlo.keyboard.hide()\n})\n\non('close', 'dialog', dialog => {\n\tif (phlo.keyboard.open && dialog.contains(phlo.keyboard.open)) phlo.keyboard.hide()\n})"
                    },
                    {
                        "node": "style",
                        "ns": null,
                        "line": 134,
                        "body": ".keyboard {\n\tposition: fixed\n\tleft: 0\n\tright: 0\n\tbottom: 0\n\tz-index: 1000\n\tdisplay: grid\n\tgap: var(--keyboard-gap, .3rem)\n\tpadding: var(--keyboard-pad, .5rem)\n\tbackground: var(--keyboard-bg, #1c2029)\n\tcolor: var(--keyboard-color, #f2f2f2)\n\tbox-shadow: 0 -2px 12px #0006\n\ttouch-action: manipulation\n\tuser-select: none\n}\n.keyboard--docked {\n\tposition: static\n\tbox-shadow: none\n\tpadding: var(--keyboard-dock-pad, 0)\n\tbackground: var(--keyboard-dock-bg, transparent)\n}\n.keyboard__row {\n\tdisplay: flex\n\tgap: var(--keyboard-gap, .3rem)\n\tjustify-content: center\n}\n.keyboard__key {\n\tfont: inherit\n\tfont-size: var(--keyboard-size, 1.1rem)\n\tflex: 1 1 auto\n\tmax-width: var(--keyboard-key, 4rem)\n\tpadding: var(--keyboard-key-pad, .7rem .2rem)\n\tborder: 1px solid var(--keyboard-border, #ffffff26)\n\tborder-radius: var(--keyboard-radius, 6px)\n\tbackground: var(--keyboard-key-bg, #ffffff14)\n\tcolor: inherit\n\tcursor: pointer\n\t\\:active: background: var(--keyboard-key-active, #ffffff2e)\n}\n.keyboard__key--wide {\n\tmax-width: var(--keyboard-wide, 6rem)\n}\n.keyboard__key--space {\n\tmax-width: var(--keyboard-space, 18rem)\n}\n.keyboard__key--on {\n\tbackground: var(--keyboard-key-on, #ffffff33)\n}"
                    }
                ]
            },
            "link": {
                "file": "/srv/control/phlo/resources/DOM/link.phlo",
                "class": "link",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Single Page App async link handler",
                    "advice": "A link with class async is fetched and swapped in instead of loading the page, and everything else keeps working: a target, a modifier click and an outside link go to the browser untouched. An anchor is remembered across the swap, so a deep link scrolls to the right place after the new content has arrived.",
                    "package": "dom",
                    "frontend": "true",
                    "backend": "false",
                    "requires": "@DOM",
                    "tags": "dom link async navigation spa"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 11,
                        "body": "on('click', 'a', (a, e) => {\n\tif (e.ctrlKey || e.shiftKey || e.metaKey || a.target || a.dataset.confirm) return false\n\tconst isAsync = a.classList.contains('async')\n\tconst [uri, hash] = a.getAttribute('href').split('#')\n\tif (isAsync || hash) e.preventDefault()\n\tphlo.anchor = hash ? `#${hash}` : ''\n\tif (hash && (!uri || uri === location.pathname + location.search)) location.hash = phlo.anchor\n\telse if (isAsync) app.get(uri.substr(1))\n})"
                    }
                ]
            },
            "markdown": {
                "file": "/srv/control/phlo/resources/DOM/markdown.phlo",
                "class": "markdown",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Client-side markdown parser",
                    "advice": "Parses markdown in the browser, for text that arrives after the page: a chat message, a preview while typing, an answer streaming in. It renders what it is given, so escape or clean anything a visitor wrote before showing it to someone else. Markdown that is already known at render time is cheaper to parse on the server.",
                    "package": "dom",
                    "frontend": "true",
                    "backend": "false",
                    "tags": "dom markdown parser frontend"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 10,
                        "body": "function parse_markdown(md, opts = {}){\n  const o = {\n    gfm: opts.gfm !== false,\n    breaks: !!opts.breaks,\n    headerIds: opts.headerIds !== false,\n    headerPrefix: opts.headerPrefix || '',\n    smartypants: !!opts.smartypants\n  }\n  const unnull = x => (x == null ? '' : String(x))\n  let src = unnull(md).replace(/\\r\\n?/g, \"\\n\")\n  const escHtml = s => s.replace(/[&<>\"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;'}[c]))\n  const trimEndNL = s => s.replace(/\\s+$/,'')\n  const isBlank = s => /^\\s*$/.test(s)\n  const slugmap = new Map()\n  const slug = t => {\n    let s = t.toLowerCase().replace(/<\\/?[^>]+>/g, '').replace(/[^\\p{L}\\p{N}\\- _]+/gu, '').trim().replace(/[\\s_]+/g, '-')\n    const base = o.headerPrefix + s\n    let k = base, i = 1\n    while (slugmap.has(k)) k = `${base}-${++i}`\n    slugmap.set(k, true)\n    return k\n  }\n  const smart = s => {\n    if (!o.smartypants) return s\n    return s.replace(/---/g, \"-\").replace(/--/g, \"–\").replace(/(^|[\\s\"(\\[])(?=')/g, \"$1‘\").replace(/'/g, \"’\").replace(/(^|[\\s(\\[])(?=\")/g, \"$1“\").replace(/\"/g, \"”\").replace(/\\.{3}/g, \"…\")\n  }\n  const refs = Object.create(null)\n  src = src.replace(\n    /^ {0,3}\\[([^\\]]+)\\]:\\s*<?([^\\s>]+)>?(?:\\s+(?:\"([^\"]*)\"|'([^']*)'|\\(([^)]+)\\)))?\\s*$/gm,\n    (_, label, url, t1, t2, t3) => {\n      const key = label.trim().replace(/\\s+/g, ' ').toLowerCase()\n      if (!refs[key]) refs[key] = { href: url, title: t1 || t2 || t3 || '' }\n      return ''\n    }\n  )\n  const tokens = []\n  const lines = src.split(\"\\n\")\n  function takeWhile(start, pred){\n    let end = start\n    while (end < lines.length && pred(lines[end], end)) end++\n    return { start, end }\n  }\n  function pushParagraph(buf){\n    const text = buf.join(\"\\n\").trimEnd()\n    if (text) tokens.push({ type: \"paragraph\", text })\n    buf.length = 0\n  }\n  function parseBlock(start = 0, end = lines.length){\n    const para = []\n    let l = start\n    while (l < end){\n      const line = lines[l]\n      if (isBlank(line)){\n        pushParagraph(para)\n        l++\n        continue\n      }\n      let m = line.match(/^ {0,3}(`{3,}|~{3,})([^\\n]*)$/)\n      if (m){\n        pushParagraph(para)\n        const fenceLen = m[1].length\n        const info = (m[2] || '').trim()\n        let body = []\n        l++\n        while (l < end){\n          const s = lines[l]\n          const close = s.match(new RegExp(`^ {0,3}${m[1][0]}{${fenceLen},}\\\\s*$`))\n          if (close){\n            l++\n            break\n          }\n          body.push(s)\n          l++\n        }\n        tokens.push({ type: \"code\", lang: info.split(/\\s+/)[0] || '', text: trimEndNL(body.join(\"\\n\")) })\n        continue\n      }\n      if (/^(?: {4}|\\t)/.test(line)){\n        pushParagraph(para)\n        const { end: j } = takeWhile(l, s => /^(?: {4}|\\t)/.test(s) || isBlank(s))\n        const block = lines.slice(l, j).map(s => s.replace(/^(?: {4}|\\t)/, '')).join(\"\\n\")\n        tokens.push({ type: \"code\", lang: '', text: trimEndNL(block) })\n        l = j\n        continue\n      }\n      if (/^ {0,3}<(?:!--|\\/?(?:html|head|body|pre|script|style|table|thead|tbody|tfoot|tr|td|th|div|p|h[1-6]|blockquote|ul|ol|li|section|article|aside|details|summary|figure|figcaption)\\b)/i.test(line)){\n        pushParagraph(para)\n        const { end: j } = takeWhile(l, (s, idx) => !(idx > l && isBlank(lines[idx-1]) && isBlank(s)))\n        const html = lines.slice(l, j).join(\"\\n\")\n        tokens.push({ type: \"html\", text: html })\n        l = j\n        continue\n      }\n      if (/^ {0,3}(?:-+\\s*|-{3,}|_{3,}|\\*{3,})\\s*$/.test(line)){\n        pushParagraph(para)\n        tokens.push({ type: \"hr\" })\n        l++\n        continue\n      }\n      m = line.match(/^ {0,3}(#{1,6})[ \\t]*([^#\\n]*?)[ \\t#]*$/)\n      if (m){\n        pushParagraph(para)\n        tokens.push({ type: \"heading\", depth: m[1].length, text: m[2].trim() })\n        l++\n        continue\n      }\n      if (l + 1 < end && /^[^\\s].*$/.test(line) && /^ {0,3}(=+|-+)\\s*$/.test(lines[l + 1])){\n        pushParagraph(para)\n        const depth = lines[l + 1].trim().startsWith(\"=\") ? 1 : 2\n        tokens.push({ type: \"heading\", depth, text: line.trim() })\n        l += 2\n        continue\n      }\n      if (/^ {0,3}>\\s?/.test(line)){\n        pushParagraph(para)\n        const { end: j } = takeWhile(l, s => /^ {0,3}>\\s?/.test(s) || isBlank(s))\n        const inner = lines.slice(l, j).map(s => s.replace(/^ {0,3}>\\s?/, '')).join(\"\\n\")\n        const sub = parse_markdown(inner, { ...o })\n        tokens.push({ type: \"blockquote\", html: sub })\n        l = j\n        continue\n      }\n      m = line.match(/^ {0,3}((?:[*+-])|\\d{1,9}[.)])\\s+/)\n      if (m){\n        pushParagraph(para)\n        const bulletRe = /^ {0,3}((?:[*+-])|\\d{1,9}[.)])\\s+/\n        const { end: j } = takeWhile(l, (s, idx) =>\n          bulletRe.test(s) ||\n          (/^(?: {4}|\\t)/.test(s)) ||\n          (!isBlank(s) && idx > l && !/^(?: {0,3}(?:[*+-]|\\d{1,9}[.)])\\s+)/.test(s))\n        )\n        const block = lines.slice(l, j)\n        const ordered = /^\\d/.test(m[1])\n        const items = []\n        let cur = []\n        for (let k = 0; k < block.length; k++){\n          const ln = block[k]\n          const head = ln.match(bulletRe)\n          if (head){\n            if (cur.length) items.push(cur), cur = []\n            cur.push(ln.replace(bulletRe, ''))\n          } else {\n            cur.push(ln.replace(/^(?: {4}|\\t)/, ''))\n          }\n        }\n        if (cur.length) items.push(cur)\n        const parsedItems = items.map(linesArr => {\n          let raw = linesArr.join(\"\\n\").replace(/\\n\\s+$/,'')\n          let checked = null\n          if (o.gfm){\n            const t = raw.match(/^\\[([ xX])\\][ \\t]+/)\n            if (t){\n              checked = t[1].toLowerCase() === 'x'\n              raw = raw.replace(/^\\[[ xX]\\][ \\t]+/, '')\n            }\n          }\n          const html = parse_markdown(raw, o)\n          return { html, checked }\n        })\n        tokens.push({ type: \"list\", ordered, items: parsedItems })\n        l = j\n        continue\n      }\n      if (o.gfm){\n        const hdr = line\n        const alignLn = lines[l + 1] || ''\n        if (/\\|/.test(hdr) && /^ {0,3}\\|? *:?-+:? *(?:\\| *:?-+:? *)*\\|? *$/.test(alignLn)){\n          pushParagraph(para)\n          const aligns = alignLn\n            .trim().replace(/^(\\|)|(\\|)$/g,'')\n            .split(\"|\").map(s => s.trim()).map(s => s.startsWith(\":-\") && s.endsWith(\"-:\") ? \"center\" : s.endsWith(\"-:\") ? \"right\" : s.startsWith(\":-\") ? \"left\" : null)\n          const headerCells = hdr.trim().replace(/^(\\|)|(\\|)$/g,'').split(\"|\").map(s => s.trim())\n          l += 2\n          const rows = []\n          while (l < end && /\\|/.test(lines[l]) && !isBlank(lines[l])){\n            rows.push(lines[l].trim().replace(/^(\\|)|(\\|)$/g,'').split(\"|\").map(s => s.trim()))\n            l++\n          }\n          tokens.push({ type: \"table\", header: headerCells, aligns, rows })\n          continue\n        }\n      }\n      para.push(line)\n      const next = lines[l + 1] || ''\n      const endPara =\n        isBlank(next) ||\n        /^ {0,3}(?:`{3,}|~{3,})/.test(next) ||\n        /^(?: {4}|\\t)/.test(next) ||\n        /^ {0,3}((?:[*+-])|\\d{1,9}[.)])\\s+/.test(next) ||\n        /^ {0,3}(#{1,6})/.test(next) ||\n        /^ {0,3}>\\s?/.test(next) ||\n        /^ {0,3}(?:-+\\s*|-{3,}|_{3,}|\\*{3,})\\s*$/.test(next) ||\n        (o.gfm && /\\|/.test(next) && /^ {0,3}\\|? *:?-+:? *(?:\\| *:?-+:? *)*\\|? *$/.test(lines[l + 2] || ''))\n      if (endPara) pushParagraph(para)\n      l++\n    }\n    pushParagraph(para)\n  }\n  parseBlock(0, lines.length)\n  function renderInline(s){\n    if (!s) return ''\n    s = s.replace(/(`+)([^`]|[^`][\\s\\S]*?[^`])\\1/g, (_, ticks, code) => `<code>${escHtml(code)}</code>`)\n    s = s.replace(/!\\[([^\\]]*)\\]\\(\\s*<?([^\\s)<>]+)>?\\s*(?:(?:\"([^\"]*)\"|'([^']*)'|\\(([^)]+)\\)))?\\s*\\)/g,\n      (_, alt, url, t1, t2, t3) => `<img src=\"${escHtml(url)}\" alt=\"${escHtml(alt)}\"${t1||t2||t3?` title=\"${escHtml(t1||t2||t3)}\"`:''}>`)\n    s = s.replace(/!\\[([^\\]]*)\\]\\[([^\\]]*)\\]/g, (_, alt, id) => {\n      const ref = refs[(id || alt).trim().replace(/\\s+/g,' ').toLowerCase()]\n      return ref ? `<img src=\"${escHtml(ref.href)}\" alt=\"${escHtml(alt)}\"${ref.title?` title=\"${escHtml(ref.title)}\"`:''}>` : _\n    })\n    s = s.replace(/\\[([^\\]]+)\\]\\(\\s*<?([^\\s)<>]+)>?\\s*(?:(?:\"([^\"]*)\"|'([^']*)'|\\(([^)]+)\\)))?\\s*\\)/g,\n      (_, text, url, t1, t2, t3) => `<a href=\"${escHtml(url)}\"${t1||t2||t3?` title=\"${escHtml(t1||t2||t3)}\"`:''}>${text}</a>`)\n    s = s.replace(/\\[([^\\]]+)\\]\\s*\\[([^\\]]*)\\]/g, (_, text, id) => {\n      const key = (id || text).trim().replace(/\\s+/g,' ').toLowerCase()\n      const ref = refs[key]\n      return ref ? `<a href=\"${escHtml(ref.href)}\"${ref.title?` title=\"${escHtml(ref.title)}\"`:''}>${text}</a>` : _\n    })\n    s = s.replace(/<([a-zA-Z][a-zA-Z0-9+.-]{1,31}:[^ <>\"']+)>/g, (_, url) => `<a href=\"${escHtml(url)}\">${escHtml(url)}</a>`)\n    s = s.replace(/<([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})>/g, (_, mail) => `<a href=\"mailto:${escHtml(mail)}\">${escHtml(mail)}</a>`)\n    if (o.gfm){\n      s = s.replace(/(?:(?<=\\s)|^)(https?:\\/\\/[^\\s<]+)(?=\\s|$)/g, '<a href=\"$1\">$1</a>')\n      s = s.replace(/(?:(?<=\\s)|^)(www\\.[^\\s<]+)(?=\\s|$)/g, '<a href=\"http://$1\">$1</a>')\n    }\n    s = s.replace(/\\*\\*([\\s\\S]+?)\\*\\*/g, '<strong>$1</strong>').replace(/__([\\s\\S]+?)__/g, '<strong>$1</strong>')\n    s = s.replace(/\\*([^*\\n]+?)\\*/g, '<em>$1</em>').replace(/_([^_\\n]+?)_/g, '<em>$1</em>')\n    if (o.gfm) s = s.replace(/~~([\\s\\S]+?)~~/g, '<del>$1</del>')\n    s = s.replace(/ {2,}\\n/g, \"<br>\\n\")\n    if (o.breaks) s = s.replace(/\\n/g, \"<br>\\n\")\n    s = s.replace(/&(?!#?\\w+;)/g, \"&amp;\").replace(/<(?!\\/?[A-Za-z][^>]*>)/g, \"&lt;\")\n    return smart(s)\n  }\n  let out = ''\n  for (const t of tokens){\n    switch (t.type){\n      case \"paragraph\":\n        out += `<p>${renderInline(t.text)}</p>\\n`\n        break\n      case \"heading\": {\n        const text = renderInline(t.text)\n        const id = o.headerIds ? slug(text.replace(/<[^>]+>/g, '')) : null\n        out += id ? `<h${t.depth} id=\"${id}\">${text}</h${t.depth}>\\n` : `<h${t.depth}>${text}</h${t.depth}>\\n`\n        break\n      }\n      case \"code\": {\n        const cls = t.lang ? ` class=\"language-${escHtml(t.lang)}\"` : ''\n        out += `<pre><code${cls}>${escHtml(t.text)}</code></pre>\\n`\n        break\n      }\n      case \"blockquote\":\n        out += `<blockquote>\\n${t.html.trim()}\\n</blockquote>\\n`\n        break\n      case \"list\": {\n        const tag = t.ordered ? \"ol\" : \"ul\"\n        out += `<${tag}>\\n`\n        for (const it of t.items){\n          const task = it.checked === null ? '' : `<input ${it.checked ? 'checked=\"\" ' : ''}disabled=\"\" type=\"checkbox\"> `\n          const body = it.html.trim().replace(/^<p>/, task + \"<p>\")\n          out += `<li>${body}</li>\\n`\n        }\n        out += `</${tag}>\\n`\n        break\n      }\n      case \"table\": {\n        const ths = t.header.map((h, i) => {\n          const a = t.aligns[i]\n          return a ? `<th align=\"${a}\">${renderInline(h)}</th>` : `<th>${renderInline(h)}</th>`\n        }).join(\"\\n\")\n        let body = ''\n        for (const row of t.rows){\n          const tds = row.map((cell, i) => {\n            const a = t.aligns[i]\n            return a ? `<td align=\"${a}\">${renderInline(cell)}</td>` : `<td>${renderInline(cell)}</td>`\n          }).join(\"\\n\")\n          body += `<tr>\\n${tds}\\n</tr>\\n`\n        }\n        out += `<table>\\n<thead>\\n<tr>\\n${ths}\\n</tr>\\n</thead>\\n` + (body ? `<tbody>\\n${body}</tbody>\\n` : '') + `</table>\\n`\n        break\n      }\n      case \"hr\":\n        out += \"<hr>\\n\"\n        break\n      case \"html\":\n        out += t.text + \"\\n\"\n        break\n    }\n  }\n  return out.trim()\n}"
                    }
                ]
            },
            "numpad": {
                "file": "/srv/control/phlo/resources/DOM/numpad.phlo",
                "class": "numpad",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "On-screen numeric keypad for touch input, bound to a field",
                    "advice": "Put data-numpad=\"<selector>\" on a container; empty containers get the standard keys, containers with their own data-numpad-key buttons keep them. Without a selector the pad writes to the first field of its own form.",
                    "package": "dom",
                    "frontend": "true",
                    "backend": "false",
                    "requires": "@DOM",
                    "tags": "dom numpad keypad touch input pos frontend"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 17,
                        "comments": "Keys and focus are handled on the body rather than per element.\nA pad may be built after the binding pass of the update that created it, and a field\ncan appear at any moment in a single page app, so binding per element would miss both.\nA key press is not a hand on the field. A key takes focus when pressed and hands it\nstraight back, so counting that as the operator touching the field cleared the value on\nevery keystroke and left only the last digit.",
                        "body": "phlo.numpad = {\n\tlayouts: {\n\t\tcalculator: ['7', '8', '9', '4', '5', '6', '1', '2', '3'],\n\t\tphone: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],\n\t},\n\tlabels: {back: '&larr;', clear: 'C', enter: '&crarr;'},\n\toptions: el => ({\n\t\tlayout: el.dataset.numpadLayout || 'calculator',\n\t\tdecimal: el.dataset.numpadDecimal ?? ',',\n\t\textra: el.dataset.numpadExtra ?? '',\n\t\tkeys: (el.dataset.numpadKeys ?? 'back,clear').split(',').map(key => key.trim()).filter(Boolean),\n\t\tfresh: el.dataset.numpadFresh !== undefined,\n\t\tmax: parseInt(el.dataset.numpadMax) || 0,\n\t}),\n\ttarget: el => obj(el.dataset.numpad) || el.closest('form')?.querySelector('input, textarea') || null,\n\tkeys(options){\n\t\tconst key = (value, label, cls) => `<button class=\"numpad__key${cls ? ' ' + cls : ''}\" type=\"button\" data-numpad-key=\"${value}\">${label ?? value}</button>`\n\t\tlet html = (phlo.numpad.layouts[options.layout] || phlo.numpad.layouts.calculator).map(value => key(value)).join('')\n\t\thtml += options.extra ? key(options.extra) : ''\n\t\thtml += key('0')\n\t\thtml += options.decimal ? key(options.decimal) : ''\n\t\toptions.keys.forEach(name => html += key(name, phlo.numpad.labels[name] ?? name, 'numpad__key--' + name))\n\t\treturn html\n\t},\n\tbuild(el){\n\t\tif (el.dataset.numpadReady) return\n\t\tel.dataset.numpadReady = '1'\n\t\tel.classList.add('numpad')\n\t\tif (!el.querySelector('[data-numpad-key]')) app.mod.inner(el, phlo.numpad.keys(phlo.numpad.options(el)))\n\t},\n\twrite(target, value){\n\t\ttarget.value = value\n\t\ttarget.dispatchEvent(new Event('input', {bubbles: true}))\n\t},\n\tpress(target, name, options){\n\t\tif (!target) return\n\t\tif (name === 'clear') return phlo.numpad.write(target, '')\n\t\tif (name === 'back') return phlo.numpad.write(target, target.value.slice(0, -1))\n\t\tif (name === 'enter'){\n\t\t\tconst form = target.closest('form')\n\t\t\ttarget.dispatchEvent(new Event('change', {bubbles: true}))\n\t\t\treturn form?.requestSubmit()\n\t\t}\n\t\tlet value = target.value\n\t\tif (options.fresh && !target.dataset.numpadTyped){\n\t\t\tvalue = ''\n\t\t\ttarget.dataset.numpadTyped = '1'\n\t\t}\n\t\tif (options.decimal && name === options.decimal && value.includes(options.decimal)) return\n\t\tif (options.max && (value + name).length > options.max) return\n\t\tphlo.numpad.write(target, value + name)\n\t},\n}\n\napp.numpad = (selector, options = {}) => `<div class=\"numpad\" data-numpad=\"${selector}\"${Object.entries(options).map(([key, value]) => ` data-numpad-${key.toLowerCase()}=\"${value}\"`).join('')}></div>`\n\nonExist('[data-numpad]', el => phlo.numpad.build(el))\n\non('click', 'body', (body, e) => {\n\tconst el = e.target.closest?.('[data-numpad-key]')\n\tconst pad = el?.closest('[data-numpad]')\n\tif (!pad) return\n\tconst target = phlo.numpad.target(pad)\n\tphlo.numpad.press(target, el.dataset.numpadKey, phlo.numpad.options(pad))\n\ttarget?.focus({preventScroll: true})\n})\n\non('focusin', 'body', (body, e) => {\n\tif (e.relatedTarget?.closest?.('[data-numpad-key]')) return\n\tif (e.target.dataset?.numpadTyped) delete e.target.dataset.numpadTyped\n})"
                    },
                    {
                        "node": "style",
                        "ns": null,
                        "line": 91,
                        "body": ".numpad {\n\tdisplay: grid\n\tgrid-template-columns: repeat(3, 1fr)\n\tgap: var(--numpad-gap, .4rem)\n}\n.numpad__key {\n\tfont: inherit\n\tfont-size: var(--numpad-size, 1.2rem)\n\tpadding: var(--numpad-pad, .7rem)\n\tborder: 1px solid var(--numpad-border, #0002)\n\tborder-radius: var(--numpad-radius, 8px)\n\tbackground: var(--numpad-bg, #0000000a)\n\tcolor: inherit\n\tcursor: pointer\n\ttouch-action: manipulation\n\tuser-select: none\n\t\\:active: background: var(--numpad-bg-active, #00000018)\n}\n.numpad__key--enter {\n\tgrid-column: span 3\n}"
                    }
                ]
            },
            "presentation": {
                "file": "/srv/control/phlo/resources/DOM/presentation.phlo",
                "class": "presentation",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Timeline presentation player for the DOM: timed image/video layers, transitions, subtitles and language alternatives from a presentation.json payload. Exposes the class `PresentationPlayer`; each transition carries its own pp-* CSS animation and the matching canvas curve for deterministic export rendering. boot() wires every .pp-embed, from an inline JSON script child or a data-src payload URL. A document keymap steers the fullscreen, focused or only player: space/k toggles, arrows seek and set volume, m mutes, c toggles subtitles, f fullscreen, home/end jump.",
                    "advice": "Plays a presentation from one JSON payload against its own clock, and seeks the audio and video to match it, so a slow machine drops frames rather than drifting out of step. That also means a presentation without audio runs perfectly well. Alternative languages live in the same payload, so switching language reloads nothing.",
                    "package": "dom",
                    "frontend": "true",
                    "backend": "false",
                    "tags": "presentation player timeline audio video subtitles transitions canvas render keyboard"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 10,
                        "body": "class PresentationPlayer {\n\n\tstatic bezier(x1, y1, x2, y2){\n\t\tconst cx = 3 * x1\n\t\tconst bx = 3 * (x2 - x1) - cx\n\t\tconst ax = 1 - cx - bx\n\t\tconst cy = 3 * y1\n\t\tconst by = 3 * (y2 - y1) - cy\n\t\tconst ay = 1 - cy - by\n\t\tconst sampleX = t => ((ax * t + bx) * t + cx) * t\n\t\tconst sampleY = t => ((ay * t + by) * t + cy) * t\n\t\treturn x => {\n\t\t\tif (x <= 0) return 0\n\t\t\tif (x >= 1) return 1\n\t\t\tlet t = x\n\t\t\tfor (let i = 0; i < 8; i++){\n\t\t\t\tconst err = sampleX(t) - x\n\t\t\t\tif (Math.abs(err) < 0.001) break\n\t\t\t\tconst d = (3 * ax * t + 2 * bx) * t + cx\n\t\t\t\tif (Math.abs(d) < 0.000001) break\n\t\t\t\tt -= err / d\n\t\t\t}\n\t\t\treturn sampleY(Math.min(1, Math.max(0, t)))\n\t\t}\n\t}\n\n\tstatic eases = {\n\t\tlinear: p => p,\n\t\tease: this.bezier(0.25, 0.1, 0.25, 1),\n\t\teaseIn: this.bezier(0.42, 0, 1, 1),\n\t\teaseOut: this.bezier(0, 0, 0.58, 1),\n\t\teaseInOut: this.bezier(0.42, 0, 0.58, 1),\n\t\tzoomB: this.bezier(0.25, 1, 0.5, 1),\n\t\ttvB: this.bezier(1, 0, 0, 1),\n\t\tcardsB: this.bezier(0.2, 0, 0.2, 1),\n\t}\n\n\tstatic instances = new Set()\n\n\tstatic clipRombus(q){\n\t\treturn (ctx, x, y, w, h) => {\n\t\t\tconst cx = x + w / 2\n\t\t\tconst cy = y + h / 2\n\t\t\tconst pts = [[0.5, -0.5], [1.5, 0.5], [0.5, 1.5], [-0.5, 0.5]]\n\t\t\tctx.beginPath()\n\t\t\tpts.forEach(([px, py], i) => {\n\t\t\t\tconst fx = x + px * w\n\t\t\t\tconst fy = y + py * h\n\t\t\t\tconst ix = cx + (fx - cx) * q\n\t\t\t\tconst iy = cy + (fy - cy) * q\n\t\t\t\ti ? ctx.lineTo(ix, iy) : ctx.moveTo(ix, iy)\n\t\t\t})\n\t\t\tctx.closePath()\n\t\t}\n\t}\n\n\tstatic clipCircle(r){\n\t\treturn (ctx, x, y, w, h) => {\n\t\t\tctx.beginPath()\n\t\t\tctx.arc(x + w / 2, y + h / 2, Math.max(0, r * Math.hypot(w, h) / Math.SQRT2), 0, Math.PI * 2)\n\t\t}\n\t}\n\n\tstatic clipInset(l, r){\n\t\treturn (ctx, x, y, w, h) => {\n\t\t\tctx.beginPath()\n\t\t\tctx.rect(x + l * w, y, Math.max(0, (1 - l - r) * w), h)\n\t\t}\n\t}\n\n\tstatic transitions = {\n\t\tnone:      null,\n\t\tfade:      {in: 'pp-fade-in',      out: 'pp-fade-out',      ease: 'ease',\n\t\t\tcurve: {ease: this.eases.ease,\n\t\t\t\tin: q => ({opacity: q}),\n\t\t\t\tout: q => ({opacity: 1 - q})}},\n\t\tzoom:      {in: 'pp-zoom-in',      out: 'pp-zoom-out',      ease: 'cubic-bezier(.25,1,.5,1)',\n\t\t\tcurve: {ease: this.eases.zoomB,\n\t\t\t\tin: q => ({opacity: q, sx: 0.96 + 0.04 * q, sy: 0.96 + 0.04 * q}),\n\t\t\t\tout: q => ({opacity: 1 - q, sx: 1 + 0.05 * q, sy: 1 + 0.05 * q})}},\n\t\tglide:     {in: 'pp-glide-in',     out: 'pp-glide-out',     ease: 'ease-out',\n\t\t\tcurve: {ease: this.eases.easeOut,\n\t\t\t\tin: (q, g) => ({tx: -g.W * (1 - q)}),\n\t\t\t\tout: (q, g) => ({opacity: 1 - q, tx: g.W * q})}},\n\t\tslide:     {in: 'pp-slide-in',     out: 'pp-slide-out',     ease: 'ease-in-out',\n\t\t\tcurve: {ease: this.eases.easeInOut,\n\t\t\t\tin: (q, g) => ({opacity: q, tx: -20 * g.k * (1 - q)}),\n\t\t\t\tout: (q, g) => ({opacity: 1 - q, tx: 20 * g.k * q})}},\n\t\tdrop:      {in: 'pp-drop-in',      out: 'pp-drop-out',      ease: 'ease',\n\t\t\tcurve: {ease: this.eases.ease,\n\t\t\t\tin: (q, g) => ({opacity: q, ty: -50 * g.k * (1 - q)}),\n\t\t\t\tout: (q, g) => ({opacity: 1 - q, ty: 50 * g.k * q})}},\n\t\tskew:      {in: 'pp-skew-in',      out: 'pp-skew-out',      ease: 'ease-in',\n\t\t\tcurve: {ease: this.eases.easeIn,\n\t\t\t\tin: q => ({opacity: q, skew: -15 * (1 - q) * Math.PI / 180}),\n\t\t\t\tout: q => ({opacity: 1 - q, skew: 15 * q * Math.PI / 180})}},\n\t\ttilt:      {in: 'pp-tilt-in',      out: 'pp-tilt-out',      ease: 'ease-in',\n\t\t\tcurve: {ease: this.eases.easeIn,\n\t\t\t\tin: (q, g) => ({opacity: q, rot: -5 * (1 - q) * Math.PI / 180, ty: 20 * g.k * (1 - q)}),\n\t\t\t\tout: (q, g) => ({opacity: 1 - q, rot: 5 * q * Math.PI / 180, ty: -20 * g.k * q})}},\n\t\tspiral:    {in: 'pp-spiral-in',    out: 'pp-spiral-out',    ease: 'ease-out',\n\t\t\tcurve: {ease: this.eases.easeOut,\n\t\t\t\tin: q => ({opacity: q, rot: -2 * Math.PI * (1 - q), sx: q, sy: q}),\n\t\t\t\tout: q => ({opacity: 1 - q, rot: 2 * Math.PI * q, sx: 1 - q, sy: 1 - q})}},\n\t\tripple:    {in: 'pp-ripple-in',    out: 'pp-ripple-out',    ease: 'ease',\n\t\t\tcurve: {ease: this.eases.ease,\n\t\t\t\tin: (q, g) => ({opacity: q, sx: 0.5 + 0.5 * q, sy: 0.5 + 0.5 * q, blur: 3 * g.k * (1 - q)}),\n\t\t\t\tout: (q, g) => ({opacity: 1 - q, sx: 1 + 0.5 * q, sy: 1 + 0.5 * q, blur: 3 * g.k * q})}},\n\t\tcurtain:   {in: 'pp-curtain-in',   out: 'pp-curtain-out',   ease: 'ease-in',\n\t\t\tcurve: {ease: this.eases.easeIn,\n\t\t\t\tin: q => ({opacity: q, sy: Math.max(0.001, q), origin: 'bottom'}),\n\t\t\t\tout: q => ({opacity: 1 - q, sy: Math.max(0.001, 1 - q), origin: 'top'})}},\n\t\ttv:        {in: 'pp-tv-in',        out: 'pp-tv-out',        ease: 'cubic-bezier(1,0,0,1)',\n\t\t\tcurve: {ease: this.eases.tvB,\n\t\t\t\tin: q => q < 0.5\n\t\t\t\t\t? {opacity: 1.6 * q, sx: Math.max(0.001, q * 2), sy: Math.max(0.001, 0.04 * q)}\n\t\t\t\t\t: {opacity: 0.8 + 0.4 * (q - 0.5) * 2, sx: 1, sy: 0.02 + (q - 0.5) * 2 * 0.98},\n\t\t\t\tout: q => q < 0.5\n\t\t\t\t\t? {opacity: 1 - 0.4 * q * 2, sx: 1, sy: Math.max(0.001, 1 - q * 2 * 0.98)}\n\t\t\t\t\t: {opacity: 0.8 - 1.6 * (q - 0.5), sx: Math.max(0.001, 1 - (q - 0.5) * 2), sy: 0.02}}},\n\t\tflip:      {in: 'pp-flip-in',      out: 'pp-flip-out',      ease: 'ease-in-out',\n\t\t\tcurve: {ease: this.eases.easeInOut,\n\t\t\t\tin: q => ({opacity: q < 0.5 ? 0 : 1, sx: Math.max(0.001, Math.abs(Math.cos((1 - q) * Math.PI)))}),\n\t\t\t\tout: q => ({opacity: q < 0.5 ? 1 : 0, sx: Math.max(0.001, Math.abs(Math.cos(q * Math.PI)))})}},\n\t\tcube:      {in: 'pp-cube-in',      out: 'pp-cube-out',      ease: 'ease-in-out', originIn: '0% 50%', originOut: '100% 50%',\n\t\t\tcurve: {ease: this.eases.easeInOut,\n\t\t\t\tin: q => ({opacity: Math.min(1, q * 2), sx: Math.max(0.001, Math.cos((1 - q) * Math.PI / 2)), origin: 'left'}),\n\t\t\t\tout: q => ({opacity: 1 - q, sx: Math.max(0.001, Math.cos(q * Math.PI / 2)), origin: 'right'})}},\n\t\tdiamond:   {in: 'pp-diamond-in',   out: 'pp-diamond-out',   ease: 'ease',\n\t\t\tcurve: {ease: this.eases.ease,\n\t\t\t\tin: q => ({clip: PresentationPlayer.clipRombus(q)}),\n\t\t\t\tout: q => ({clip: PresentationPlayer.clipRombus(1 - q)})}},\n\t\tdiaphragm: {in: 'pp-diaphragm-in', out: 'pp-diaphragm-out', ease: 'linear',\n\t\t\tcurve: {ease: this.eases.linear,\n\t\t\t\tin: q => ({clip: PresentationPlayer.clipCircle(0.9 * q)}),\n\t\t\t\tout: q => ({clip: PresentationPlayer.clipCircle(0.9 * (1 - q))})}},\n\t\tspotlight: {in: 'pp-spotlight-in', out: 'pp-spotlight-out', ease: 'ease',\n\t\t\tcurve: {ease: this.eases.ease,\n\t\t\t\tin: q => ({clip: PresentationPlayer.clipCircle(0.75 * q)}),\n\t\t\t\tout: q => ({clip: PresentationPlayer.clipCircle(0.75 * (1 - q))})}},\n\t\twipe:      {in: 'pp-wipe-in',      out: 'pp-wipe-out',      ease: 'ease-out',\n\t\t\tcurve: {ease: this.eases.easeOut,\n\t\t\t\tin: q => ({opacity: Math.min(1, 0.1 + q * 2.2), clip: PresentationPlayer.clipInset(0, 1 - q)}),\n\t\t\t\tout: q => ({opacity: Math.min(1, 1.1 - q), clip: PresentationPlayer.clipInset(q, 0)})}},\n\t\tglitch:    {in: 'pp-glitch-in',    out: 'pp-glitch-out',    ease: 'linear',\n\t\t\tcurve: {ease: this.eases.linear,\n\t\t\t\tin: (q, g) => ({opacity: Math.min(1, q * 1.6), bands: q < 0.95 ? 5 * g.k * (1 - q) : 0, seed: q}),\n\t\t\t\tout: (q, g) => ({opacity: Math.max(0, 1 - q * 1.2), bands: q > 0.05 ? 5 * g.k * q : 0, seed: q})}},\n\t\tcards:     {in: 'pp-cards-in',     out: 'pp-cards-out',     ease: 'cubic-bezier(0.2,0,0.2,1)',\n\t\t\tcurve: {ease: this.eases.cardsB,\n\t\t\t\tin: (q, g) => ({ty: g.h * (1 - q)}),\n\t\t\t\tout: (q, g) => ({opacity: 1 - q, ty: g.h * q})}},\n\t}\n\n\tstatic progress(tt, dur){\n\t\tif (dur <= 0) return 1\n\t\treturn Math.min(1, Math.max(0, tt / dur))\n\t}\n\n\tstatic wave(tt, period){\n\t\treturn (1 - Math.cos(Math.PI * 2 * ((tt % period) / period))) / 2\n\t}\n\n\tstatic dirVector(dir){\n\t\tconst map = {left: [1, 0], right: [-1, 0], up: [0, 1], down: [0, -1], tl: [1, 1], tr: [-1, 1], bl: [1, -1], br: [-1, -1]}\n\t\treturn map[dir] || [1, 0]\n\t}\n\n\tstatic durings = {\n\t\tnone: null,\n\t\tpush: {css: 'pp-scale', ease: 'linear', amount: 10, dir: '',\n\t\t\tvars(a, d){\n\t\t\t\treturn {'--pp-s0': String(1 - a / 100)}\n\t\t\t},\n\t\t\tcurve(tt, dur, g, a, d){\n\t\t\t\tconst s = 1 - a / 100 * (1 - PresentationPlayer.progress(tt, dur))\n\t\t\t\treturn {sx: s, sy: s}\n\t\t\t}},\n\t\tpull: {css: 'pp-scale', ease: 'linear', amount: 10, dir: '',\n\t\t\tvars(a, d){\n\t\t\t\treturn {'--pp-s0': String(1 + a / 100)}\n\t\t\t},\n\t\t\tcurve(tt, dur, g, a, d){\n\t\t\t\tconst s = 1 + a / 100 * (1 - PresentationPlayer.progress(tt, dur))\n\t\t\t\treturn {sx: s, sy: s}\n\t\t\t}},\n\t\tpan: {css: 'pp-drift', ease: 'linear', amount: 5, dir: 'left',\n\t\t\tvars(a, d){\n\t\t\t\tconst [vx, vy] = PresentationPlayer.dirVector(d)\n\t\t\t\treturn {'--pp-x0': vx * a + '%', '--pp-y0': vy * a + '%'}\n\t\t\t},\n\t\t\tcurve(tt, dur, g, a, d){\n\t\t\t\tconst [vx, vy] = PresentationPlayer.dirVector(d)\n\t\t\t\tconst back = 1 - PresentationPlayer.progress(tt, dur)\n\t\t\t\treturn {tx: vx * a / 100 * g.w * back, ty: vy * a / 100 * g.h * back}\n\t\t\t}},\n\t\tkenburns: {css: 'pp-kenburns', ease: 'linear', amount: 8, dir: 'tr',\n\t\t\tvars(a, d){\n\t\t\t\tconst [vx, vy] = PresentationPlayer.dirVector(d)\n\t\t\t\treturn {'--pp-s0': String(1 - a / 100), '--pp-x0': vx * a / 2 + '%', '--pp-y0': vy * a / 2 + '%'}\n\t\t\t},\n\t\t\tcurve(tt, dur, g, a, d){\n\t\t\t\tconst [vx, vy] = PresentationPlayer.dirVector(d)\n\t\t\t\tconst back = 1 - PresentationPlayer.progress(tt, dur)\n\t\t\t\tconst s = 1 - a / 100 * back\n\t\t\t\treturn {sx: s, sy: s, tx: vx * a / 200 * g.w * back, ty: vy * a / 200 * g.h * back}\n\t\t\t}},\n\t\tfloat: {css: 'pp-float', ease: 'ease-in-out', amount: 3, dir: '', period: 4,\n\t\t\tvars(a, d){\n\t\t\t\treturn {'--pp-y1': -a + '%'}\n\t\t\t},\n\t\t\tcurve(tt, dur, g, a, d){\n\t\t\t\treturn {ty: -a / 100 * g.h * PresentationPlayer.wave(tt, this.period)}\n\t\t\t}},\n\t\tsway: {css: 'pp-sway', ease: 'ease-in-out', amount: 1.5, dir: '', period: 5,\n\t\t\tvars(a, d){\n\t\t\t\treturn {'--pp-r0': -a + 'deg', '--pp-r1': a + 'deg'}\n\t\t\t},\n\t\t\tcurve(tt, dur, g, a, d){\n\t\t\t\treturn {rot: a * (2 * PresentationPlayer.wave(tt, this.period) - 1) * Math.PI / 180}\n\t\t\t}},\n\t\tpulse: {css: 'pp-pulse', ease: 'ease-in-out', amount: 2, dir: '', period: 2.5,\n\t\t\tvars(a, d){\n\t\t\t\treturn {'--pp-s1': String(1 + a / 100)}\n\t\t\t},\n\t\t\tcurve(tt, dur, g, a, d){\n\t\t\t\tconst s = 1 + a / 100 * PresentationPlayer.wave(tt, this.period)\n\t\t\t\treturn {sx: s, sy: s}\n\t\t\t}},\n\t}\n\n\tconstructor(root, data, opts = {}){\n\t\tthis.root = root\n\t\tthis.opts = opts\n\t\tthis.mediaBase = opts.mediaBase || data.mediaBase || ''\n\t\tthis.transcript = data.transcript || null\n\t\tconst pres0 = data.presentation || {}\n\t\tthis.lang = opts.lang ?? pres0.lang ?? (pres0.subtitles || {}).lang ?? null\n\t\tthis.playing = false\n\t\tthis.started = false\n\t\tthis.deferMedia = !opts.edit\n\t\tthis.mediaLoaded = !!opts.edit\n\t\tthis.pendingMedia = []\n\t\tthis.vol = Math.min(1, Math.max(0, parseFloat(localStorage.getItem('pp-vol') ?? '1') || 0))\n\t\tthis.muted = localStorage.getItem('pp-muted') === '1'\n\t\tthis.t = !opts.edit && pres0.poster ? Math.max(0, pres0.poster) : 0\n\t\tthis.stateKey = 'pp:' + (pres0.title || 'presentation')\n\t\tthis.resumePlay = false\n\t\tif (!opts.edit){\n\t\t\tconst saved = this.readState()\n\t\t\tif (saved){\n\t\t\t\tthis.t = saved.t\n\t\t\t\tthis.started = true\n\t\t\t\tthis.resumePlay = true\n\t\t\t}\n\t\t}\n\t\tPresentationPlayer.instances.add(this)\n\t\tthis.clockStart = 0\n\t\tthis.items = []\n\t\tthis.videos = []\n\t\tthis.audio = null\n\t\tthis.audioStart = 0\n\t\tthis.subIndex = -1\n\t\tthis.raf = null\n\t\tthis.blobCache = {}\n\t\tthis.updateGen = 0\n\t\tthis.loaded = false\n\t\tthis.buildShell()\n\t\tthis.update(data.presentation)\n\t}\n\n\tmediaJob(jobs, job){\n\t\tif (this.mediaLoaded) jobs.push(job())\n\t\telse this.pendingMedia.push(job)\n\t}\n\n\tloadMedia(){\n\t\tif (this.mediaLoaded) return Promise.resolve()\n\t\tthis.mediaLoaded = true\n\t\tthis.root.classList.add('pp-loading')\n\t\treturn Promise.all(this.pendingMedia.splice(0).map(job => job())).then(() => {\n\t\t\tthis.root.classList.remove('pp-loading')\n\t\t\tthis.applyTime(this.t, true)\n\t\t\tthis.updateTime()\n\t\t})\n\t}\n\n\tloadBlob(url){\n\t\tif (!this.blobCache[url]) this.blobCache[url] = fetch(url)\n\t\t\t.then(r => {\n\t\t\t\tif (!r.ok) throw Error(String(r.status))\n\t\t\t\treturn r.blob()\n\t\t\t})\n\t\t\t.then(b => URL.createObjectURL(b))\n\t\t\t.catch(() => url)\n\t\treturn this.blobCache[url]\n\t}\n\n\tguard(promise, ms){\n\t\treturn Promise.race([promise, new Promise(res => setTimeout(res, ms))])\n\t}\n\n\tsrc(name){\n\t\treturn this.mediaBase + name\n\t}\n\n\tsrcFor(item){\n\t\tif (item && this.lang && Array.isArray(item.alts)){\n\t\t\tconst alt = item.alts.find(a => a && a.src && a.lang === this.lang)\n\t\t\tif (alt) return alt.src\n\t\t}\n\t\treturn item ? item.src : ''\n\t}\n\n\treadState(){\n\t\ttry {\n\t\t\tconst saved = JSON.parse(sessionStorage.getItem(this.stateKey))\n\t\t\tif (saved && saved.t > 0.5 && Date.now() - saved.ts < 600000) return saved\n\t\t}\n\t\tcatch {}\n\t\treturn null\n\t}\n\n\tsaveState(){\n\t\ttry { sessionStorage.setItem(this.stateKey, JSON.stringify({t: this.t, ts: Date.now()})) }\n\t\tcatch {}\n\t}\n\n\tdestroy(){\n\t\tif (this.playing && !this.opts.edit) this.saveState()\n\t\tthis.playing = false\n\t\tclearTimeout(this.idleTimer)\n\t\tif (this.raf) cancelAnimationFrame(this.raf)\n\t\tif (this.ticker) clearInterval(this.ticker)\n\t\tif (this.audio) this.audio.pause()\n\t\tfor (const v of this.videos) v.pause()\n\t\tthis.audio = null\n\t\tthis.videos = []\n\t\tfor (const entry of Object.values(this.blobCache)) Promise.resolve(entry).then(url => typeof url === 'string' && url.startsWith('blob:') && URL.revokeObjectURL(url))\n\t\tthis.blobCache = {}\n\t}\n\n\tclearState(){\n\t\ttry { sessionStorage.removeItem(this.stateKey) }\n\t\tcatch {}\n\t}\n\n\teffVol(){\n\t\treturn this.muted ? 0 : this.vol\n\t}\n\n\tapplyVolume(){\n\t\tconst eff = this.effVol()\n\t\tif (this.audio) this.audio.volume = eff\n\t\tfor (const v of this.videos) v.volume = (v._baseVol ?? 0) * eff\n\t\tif (this.volBtn) this.volBtn.innerHTML = this.icon(eff > 0 ? 'vol' : 'mute')\n\t\tif (this.volBtn) this.volBtn.setAttribute('aria-label', eff > 0 ? 'Mute' : 'Unmute')\n\t\tif (this.volEl && !this.muted) this.volEl.value = Math.round(this.vol * 100)\n\t}\n\n\tsetVol(vol){\n\t\tthis.vol = Math.min(1, Math.max(0, vol))\n\t\tthis.muted = false\n\t\ttry {\n\t\t\tlocalStorage.setItem('pp-vol', String(this.vol))\n\t\t\tlocalStorage.setItem('pp-muted', '0')\n\t\t}\n\t\tcatch {}\n\t\tthis.applyVolume()\n\t}\n\n\ttoggleMute(){\n\t\tthis.muted = !this.muted\n\t\ttry { localStorage.setItem('pp-muted', this.muted ? '1' : '0') }\n\t\tcatch {}\n\t\tthis.applyVolume()\n\t}\n\n\tfullscreen(){\n\t\tif (document.fullscreenElement) document.exitFullscreen()\n\t\telse (this.root.closest('.pp-standalone') || this.root).requestFullscreen()\n\t}\n\n\tcontentFor(item){\n\t\tif (item && this.lang && Array.isArray(item.alts)){\n\t\t\tconst alt = item.alts.find(a => a && a.content && a.lang === this.lang)\n\t\t\tif (alt) return alt.content\n\t\t}\n\t\treturn item && item.content ? item.content : ''\n\t}\n\n\tlanguages(){\n\t\tconst set = new Set()\n\t\tconst p = this.pres || {}\n\t\tconst add = it => {\n\t\t\tif (!it) return\n\t\t\tfor (const a of it.alts || []) if (a && a.lang) set.add(a.lang)\n\t\t}\n\t\tadd(p.audio)\n\t\tfor (const v of p.videos || []) add(v)\n\t\tfor (const i of p.images || []) add(i)\n\t\tfor (const t of p.texts || []) add(t)\n\t\tif (this.transcript && this.transcript.translations) for (const l of Object.keys(this.transcript.translations)) set.add(l)\n\t\treturn [...set].filter(l => l && l !== p.lang)\n\t}\n\n\tsetLang(lang){\n\t\tthis.lang = lang || null\n\t\tthis.update(this.pres)\n\t}\n\n\tget duration(){\n\t\tconst p = this.pres\n\t\tlet end = 0\n\t\tfor (const img of p.images || []) end = Math.max(end, (img.start || 0) + (img.duration || 0))\n\t\tfor (const tx of p.texts || []) end = Math.max(end, (tx.start || 0) + (tx.duration || 0))\n\t\tfor (const v of this.videos) if (v.duration) end = Math.max(end, v._start + v.duration)\n\t\tif (p.audio && p.audio.duration) end = Math.max(end, (p.audio.start || 0) + p.audio.duration)\n\t\treturn end || 10\n\t}\n\n\tbuildShell(){\n\t\tthis.root.classList.add('pp-root', 'pp-paused')\n\t\tif (this.opts.edit) this.root.classList.add('pp-edit')\n\t\tthis.stage = document.createElement('div')\n\t\tthis.stage.className = 'pp-stage'\n\t\tthis.root.appendChild(this.stage)\n\t\tthis.subsEl = document.createElement('div')\n\t\tthis.subsEl.className = 'pp-subs'\n\t\tthis.subsEl.style.display = 'none'\n\t\tthis.root.appendChild(this.subsEl)\n\t\tif (this.opts.controls !== false && !this.opts.edit) this.buildControls()\n\t\tconst fit = w => this.root.style.fontSize = Math.max(9, w / 48) + 'px'\n\t\tnew ResizeObserver(entries => fit(entries[0].contentRect.width)).observe(this.root)\n\t}\n\n\ticon(name){\n\t\tconst paths = {\n\t\t\tplay: 'M8 5v14l11-7z',\n\t\t\tpause: 'M6 5h4v14H6zM14 5h4v14h-4z',\n\t\t\tcc: 'M4 5h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zm4.5 5.5a2 2 0 0 0-2-1.5c-1.4 0-2.5 1.3-2.5 3s1.1 3 2.5 3a2 2 0 0 0 2-1.5H6.8a1 1 0 0 1-.8.5c-.7 0-1.5-.8-1.5-2s.8-2 1.5-2a1 1 0 0 1 .8.5zm8 0a2 2 0 0 0-2-1.5c-1.4 0-2.5 1.3-2.5 3s1.1 3 2.5 3a2 2 0 0 0 2-1.5h-1.7a1 1 0 0 1-.8.5c-.7 0-1.5-.8-1.5-2s.8-2 1.5-2a1 1 0 0 1 .8.5z',\n\t\t\tvol: 'M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z',\n\t\t\tmute: 'M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4 9.91 6.09 12 8.18V4z',\n\t\t\tfull: 'M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z',\n\t\t}\n\t\treturn '<svg viewBox=\"0 0 24 24\"><path d=\"' + paths[name] + '\"/></svg>'\n\t}\n\n\tbuildControls(){\n\t\tthis.root.tabIndex = 0\n\t\tthis.bigPlay = document.createElement('button')\n\t\tthis.bigPlay.className = 'pp-bigplay'\n\t\tthis.bigPlay.setAttribute('aria-label', 'Play')\n\t\tthis.bigPlay.innerHTML = this.icon('play')\n\t\tthis.bigPlay.addEventListener('click', () => this.toggle())\n\t\tthis.root.appendChild(this.bigPlay)\n\t\tconst bar = document.createElement('div')\n\t\tbar.className = 'pp-controls'\n\t\tthis.playBtn = document.createElement('button')\n\t\tthis.playBtn.setAttribute('aria-label', 'Play')\n\t\tthis.playBtn.innerHTML = this.icon('play')\n\t\tthis.playBtn.addEventListener('click', () => this.toggle())\n\t\tthis.timeEl = document.createElement('span')\n\t\tthis.timeEl.className = 'pp-time'\n\t\tthis.seekEl = document.createElement('input')\n\t\tthis.seekEl.type = 'range'\n\t\tthis.seekEl.className = 'pp-seek'\n\t\tthis.seekEl.setAttribute('aria-label', 'Seek')\n\t\tthis.seekEl.min = 0\n\t\tthis.seekEl.max = 1000\n\t\tthis.seekEl.value = 0\n\t\tthis.seekEl.addEventListener('input', () => this.seek(this.seekEl.value / 1000 * this.duration))\n\t\tthis.volBtn = document.createElement('button')\n\t\tthis.volBtn.setAttribute('aria-label', 'Mute')\n\t\tthis.volBtn.innerHTML = this.icon('vol')\n\t\tthis.volBtn.addEventListener('click', () => this.toggleMute())\n\t\tthis.volEl = document.createElement('input')\n\t\tthis.volEl.type = 'range'\n\t\tthis.volEl.className = 'pp-vol'\n\t\tthis.volEl.setAttribute('aria-label', 'Volume')\n\t\tthis.volEl.min = 0\n\t\tthis.volEl.max = 100\n\t\tthis.volEl.value = Math.round(this.vol * 100)\n\t\tthis.volEl.addEventListener('input', () => this.setVol(this.volEl.value / 100))\n\t\tthis.ccBtn = document.createElement('button')\n\t\tthis.ccBtn.setAttribute('aria-label', 'Subtitles')\n\t\tthis.ccBtn.innerHTML = this.icon('cc')\n\t\tthis.ccBtn.addEventListener('click', () => this.setSubs(!this.showSubs))\n\t\tthis.langSel = document.createElement('select')\n\t\tthis.langSel.className = 'pp-lang'\n\t\tthis.langSel.setAttribute('aria-label', 'Language')\n\t\tthis.langSel.style.display = 'none'\n\t\tthis.langSel.addEventListener('change', () => this.setLang(this.langSel.value === 'original' ? null : this.langSel.value))\n\t\tthis.fsBtn = document.createElement('button')\n\t\tthis.fsBtn.setAttribute('aria-label', 'Fullscreen')\n\t\tthis.fsBtn.innerHTML = this.icon('full')\n\t\tthis.fsBtn.addEventListener('click', () => this.fullscreen())\n\t\tbar.append(this.playBtn, this.timeEl, this.seekEl, this.volBtn, this.volEl, this.ccBtn, this.langSel, this.fsBtn)\n\t\tthis.root.appendChild(bar)\n\t\tthis.stage.addEventListener('click', e => {\n\t\t\tif (!e.target.closest('a')) this.toggle()\n\t\t})\n\t\tconst wake = () => {\n\t\t\tthis.root.classList.remove('pp-idle')\n\t\t\tclearTimeout(this.idleTimer)\n\t\t\tthis.idleTimer = setTimeout(() => this.playing && this.root.classList.add('pp-idle'), 2800)\n\t\t}\n\t\tthis.wake = wake\n\t\tthis.root.addEventListener('mousemove', wake)\n\t\tthis.root.addEventListener('touchstart', wake, {passive: true})\n\t}\n\n\tupdate(pres){\n\t\tthis.pres = pres\n\t\tconst gen = ++this.updateGen\n\t\tconst jobs = []\n\t\tthis.pendingMedia = []\n\t\tthis.root.style.setProperty('--pp-ar', (pres.size.w / pres.size.h).toFixed(4))\n\t\tthis.stage.innerHTML = ''\n\t\tthis.items = []\n\t\tthis.videos = []\n\t\tconst wantAudio = pres.audio && pres.audio.src ? this.src(this.srcFor(pres.audio)) : ''\n\t\tthis.audioStart = pres.audio && pres.audio.start ? pres.audio.start : 0\n\t\tif (this.audio && this.audioSrc !== wantAudio){\n\t\t\tthis.audio.pause()\n\t\t\tthis.audio = null\n\t\t}\n\t\tif (wantAudio && !this.audio){\n\t\t\tthis.audioSrc = wantAudio\n\t\t\tthis.audio = new Audio()\n\t\t\tthis.audio.preload = 'auto'\n\t\t\tthis.audio.volume = this.effVol()\n\t\t\tthis.mediaJob(jobs, () => this.guard(this.loadBlob(this.audioSrc).then(url => new Promise(res => {\n\t\t\t\tthis.audio.addEventListener('loadedmetadata', () => {\n\t\t\t\t\tconst d = this.audio.duration\n\t\t\t\t\tif (Number.isFinite(d) && d > 0 && this.opts.onMeta) this.opts.onMeta(d)\n\t\t\t\t\tres()\n\t\t\t\t}, {once: true})\n\t\t\t\tthis.audio.addEventListener('error', () => res(), {once: true})\n\t\t\t\tthis.audio.src = url\n\t\t\t})), 20000))\n\t\t}\n\t\tfor (const cfg of pres.videos || []){\n\t\t\tconst v = document.createElement('video')\n\t\t\tv.className = 'pp-video'\n\t\t\tv.preload = 'auto'\n\t\t\tv.playsInline = true\n\t\t\tv._baseVol = Math.min(1, Math.max(0, (cfg.volume ?? 100) / 100))\n\t\t\tv.volume = v._baseVol * this.effVol()\n\t\t\tv.muted = !cfg.volume\n\t\t\tv.style.objectFit = cfg.fit || 'cover'\n\t\t\tv._start = cfg.start || 0\n\t\t\tv._inDur = cfg.in && cfg.in.dur ? cfg.in.dur : 0\n\t\t\tv._outDur = cfg.out && cfg.out.dur ? cfg.out.dur : 0\n\t\t\tif (cfg.x != null || cfg.y != null || cfg.w != null){\n\t\t\t\tv.classList.add('pp-video-box')\n\t\t\t\tv.style.left = (cfg.x || 0) + '%'\n\t\t\t\tv.style.top = (cfg.y || 0) + '%'\n\t\t\t\tv.style.width = (cfg.w || 30) + '%'\n\t\t\t\tv.style.zIndex = 10 + (cfg.z || 0)\n\t\t\t}\n\t\t\telse v.style.zIndex = 1000\n\t\t\tif (cfg.frame){\n\t\t\t\tv.style.borderRadius = '20px'\n\t\t\t\tv.style.boxShadow = '0 26px 70px rgba(0,0,0,.55)'\n\t\t\t}\n\t\t\tthis.mediaJob(jobs, () => this.guard(this.loadBlob(this.src(this.srcFor(cfg))).then(url => new Promise(res => {\n\t\t\t\tv.addEventListener('loadedmetadata', () => res(), {once: true})\n\t\t\t\tv.addEventListener('error', () => res(), {once: true})\n\t\t\t\tv.src = url\n\t\t\t})), 30000))\n\t\t\tthis.stage.appendChild(v)\n\t\t\tthis.videos.push(v)\n\t\t}\n\t\tconst sorted = (pres.images || []).map((cfg, idx) => ({cfg, idx})).sort((a, b) => (a.cfg.z || 0) - (b.cfg.z || 0))\n\t\tfor (const {cfg, idx} of sorted){\n\t\t\tconst el = document.createElement('div')\n\t\t\tel.className = 'pp-item'\n\t\t\tel.dataset.idx = idx\n\t\t\tel.style.left = (cfg.x || 0) + '%'\n\t\t\tel.style.top = (cfg.y || 0) + '%'\n\t\t\tel.style.width = (cfg.w || 30) + '%'\n\t\t\tel.style.zIndex = 10 + (cfg.z || 0)\n\t\t\tconst du = cfg.during && PresentationPlayer.durings[cfg.during.name]\n\t\t\tif (du) for (const [key, val] of Object.entries(du.vars(cfg.during.amount ?? du.amount, cfg.during.dir || du.dir))) el.style.setProperty(key, val)\n\t\t\tconst img = document.createElement('img')\n\t\t\timg.src = this.src(this.srcFor(cfg))\n\t\t\timg.alt = cfg.alt || ''\n\t\t\timg.draggable = false\n\t\t\timg.addEventListener('load', () => {\n\t\t\t\timg.width = img.naturalWidth\n\t\t\t\timg.height = img.naturalHeight\n\t\t\t}, {once: true})\n\t\t\tif (!img.complete) jobs.push(this.guard(new Promise(res => {\n\t\t\t\timg.addEventListener('load', () => res(), {once: true})\n\t\t\t\timg.addEventListener('error', () => res(), {once: true})\n\t\t\t}), 15000))\n\t\t\tif (cfg.link && cfg.link.href && !this.opts.edit){\n\t\t\t\tconst a = document.createElement('a')\n\t\t\t\ta.href = cfg.link.href\n\t\t\t\tif (cfg.link.target === '_blank'){\n\t\t\t\t\ta.target = '_blank'\n\t\t\t\t\ta.rel = 'noopener'\n\t\t\t\t}\n\t\t\t\ta.appendChild(img)\n\t\t\t\tel.appendChild(a)\n\t\t\t}\n\t\t\telse el.appendChild(img)\n\t\t\tthis.stage.appendChild(el)\n\t\t\tthis.items.push({cfg, el, phase: null})\n\t\t}\n\t\tconst sortedTexts = (pres.texts || []).map((cfg, idx) => ({cfg, idx})).sort((a, b) => (a.cfg.z || 0) - (b.cfg.z || 0))\n\t\tfor (const {cfg, idx} of sortedTexts){\n\t\t\tconst el = document.createElement('div')\n\t\t\tel.className = 'pp-text'\n\t\t\tel.dataset.idx = idx\n\t\t\tel.style.left = (cfg.x || 0) + '%'\n\t\t\tel.style.top = (cfg.y || 0) + '%'\n\t\t\tel.style.width = (cfg.w || 80) + '%'\n\t\t\tel.style.zIndex = 2000 + (cfg.z || 0)\n\t\t\tel.style.fontFamily = cfg.font || 'system-ui, sans-serif'\n\t\t\tel.style.fontSize = (cfg.size || 6) + 'cqh'\n\t\t\tel.style.color = cfg.color || '#ffffff'\n\t\t\tel.style.textAlign = cfg.align || 'center'\n\t\t\tif (cfg.bold) el.style.fontWeight = 'bold'\n\t\t\tconst du = cfg.during && PresentationPlayer.durings[cfg.during.name]\n\t\t\tif (du) for (const [key, val] of Object.entries(du.vars(cfg.during.amount ?? du.amount, cfg.during.dir || du.dir))) el.style.setProperty(key, val)\n\t\t\tlet words = null\n\t\t\tif (cfg.kinetic){\n\t\t\t\twords = []\n\t\t\t\tlet wi = 0\n\t\t\t\tfor (const tok of this.contentFor(cfg).split(/(\\s+)/)){\n\t\t\t\t\tif (tok === '') continue\n\t\t\t\t\tif (/^\\s+$/.test(tok)){\n\t\t\t\t\t\tel.appendChild(document.createTextNode(tok))\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tconst span = document.createElement('span')\n\t\t\t\t\tspan.className = 'pp-word'\n\t\t\t\t\tspan.textContent = tok\n\t\t\t\t\tel.appendChild(span)\n\t\t\t\t\twords.push({span, i: wi})\n\t\t\t\t\twi++\n\t\t\t\t}\n\t\t\t}\n\t\t\telse el.textContent = this.contentFor(cfg)\n\t\t\tthis.stage.appendChild(el)\n\t\t\tthis.items.push({cfg, el, phase: null, words})\n\t\t}\n\t\tthis.showSubs = (pres.subtitles || {}).show !== false\n\t\tthis.subIndex = -1\n\t\tthis.loaded = false\n\t\tthis.root.classList.add('pp-loading')\n\t\tPromise.all(jobs).then(() => {\n\t\t\tif (gen !== this.updateGen) return\n\t\t\tthis.loaded = true\n\t\t\tthis.root.classList.remove('pp-loading')\n\t\t\tthis.applyTime(this.t, true)\n\t\t\tthis.updateTime()\n\t\t\tif (this.resumePlay){\n\t\t\t\tthis.resumePlay = false\n\t\t\t\tthis.play()\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\tif (this.playing && this.audio && this.audio.paused) this.pause()\n\t\t\t\t}, 350)\n\t\t\t}\n\t\t\tif (this.opts.onLoaded) this.opts.onLoaded()\n\t\t})\n\t\tthis.applyTime(this.t, true)\n\t\tthis.updateTime()\n\t\tthis.refreshLangSel()\n\t\tif (this.ccBtn){\n\t\t\tthis.ccBtn.style.display = (this.segments() || []).length ? '' : 'none'\n\t\t\tthis.ccBtn.classList.toggle('pp-off', !this.showSubs)\n\t\t}\n\t\tthis.applyVolume()\n\t}\n\n\tsegments(){\n\t\tif (!this.transcript) return null\n\t\tconst tr = this.transcript.translations || {}\n\t\tif (this.lang && tr[this.lang]) return tr[this.lang]\n\t\treturn this.transcript.segments || null\n\t}\n\n\tsetSubs(show){\n\t\tthis.showSubs = show\n\t\tif (this.ccBtn) this.ccBtn.classList.toggle('pp-off', !show)\n\t\tthis.subIndex = -1\n\t\tthis.applySubs(this.t)\n\t}\n\n\trefreshLangSel(){\n\t\tif (!this.langSel) return\n\t\tconst langs = this.opts.langSelector === false ? [] : this.languages()\n\t\tif (!langs.length){\n\t\t\tthis.langSel.style.display = 'none'\n\t\t\treturn\n\t\t}\n\t\tthis.langSel.innerHTML = ''\n\t\tconst opt = (value, label) => {\n\t\t\tconst o = document.createElement('option')\n\t\t\to.value = value\n\t\t\to.textContent = label\n\t\t\tthis.langSel.appendChild(o)\n\t\t}\n\t\topt('original', (this.pres.lang || 'original').toUpperCase())\n\t\tfor (const l of langs) opt(l, l.toUpperCase())\n\t\tthis.langSel.value = (this.lang && this.lang !== this.pres.lang) ? this.lang : 'original'\n\t\tthis.langSel.style.display = ''\n\t}\n\n\ttoggle(){\n\t\tthis.playing ? this.pause() : this.play()\n\t}\n\n\tplay(){\n\t\tif (!this.loaded) return\n\t\tif (!this.mediaLoaded){\n\t\t\tthis.loadMedia().then(() => this.play())\n\t\t\treturn\n\t\t}\n\t\tif (!this.started && this.t > 0) this.seek(0)\n\t\tthis.started = true\n\t\tif (this.t >= this.duration - 0.05) this.seek(0)\n\t\tthis.playing = true\n\t\tthis.root.classList.remove('pp-paused')\n\t\tif (this.wake) this.wake()\n\t\tif (this.bigPlay) this.bigPlay.style.display = 'none'\n\t\tif (this.playBtn) this.playBtn.innerHTML = this.icon('pause')\n\t\tif (this.playBtn) this.playBtn.setAttribute('aria-label', 'Pause')\n\t\tthis.clockStart = performance.now() - this.t * 1000\n\t\tthis.syncVideos(true)\n\t\tif (this.audio) this.syncAudio(true)\n\t\tfor (const item of this.items) item.el.style.animationPlayState = 'running'\n\t\tconst step = () => {\n\t\t\tif (!this.playing) return\n\t\t\tthis.t = (performance.now() - this.clockStart) / 1000\n\t\t\tconst tick = Math.floor(this.t)\n\t\t\tif (tick !== this.savedTick){\n\t\t\t\tthis.savedTick = tick\n\t\t\t\tthis.saveState()\n\t\t\t}\n\t\t\tif (this.t >= this.duration){\n\t\t\t\tthis.t = this.duration\n\t\t\t\tthis.pause(true)\n\t\t\t}\n\t\t\telse this.applyTime(this.t)\n\t\t\tthis.updateTime()\n\t\t\tif (this.opts.onTime) this.opts.onTime(this.t)\n\t\t\tif (this.playing) this.raf = requestAnimationFrame(step)\n\t\t}\n\t\tthis.raf = requestAnimationFrame(step)\n\t\tthis.ticker = setInterval(() => { if (document.hidden) step() }, 250)\n\t}\n\n\tpause(ended){\n\t\tthis.playing = false\n\t\tclearTimeout(this.idleTimer)\n\t\tthis.root.classList.remove('pp-idle')\n\t\tif (!this.opts.edit) this.clearState()\n\t\tthis.root.classList.add('pp-paused')\n\t\tif (this.raf) cancelAnimationFrame(this.raf)\n\t\tif (this.ticker) clearInterval(this.ticker)\n\t\tif (this.audio) this.audio.pause()\n\t\tfor (const v of this.videos) v.pause()\n\t\tfor (const item of this.items) item.el.style.animationPlayState = 'paused'\n\t\tif (this.bigPlay) this.bigPlay.style.display = ''\n\t\tif (this.playBtn) this.playBtn.innerHTML = this.icon('play')\n\t\tif (this.playBtn) this.playBtn.setAttribute('aria-label', 'Play')\n\t\tif (ended && this.opts.onEnded) this.opts.onEnded()\n\t}\n\n\tseek(t){\n\t\tthis.t = Math.min(Math.max(0, t), this.duration)\n\t\tthis.clockStart = performance.now() - this.t * 1000\n\t\tthis.applyTime(this.t, true)\n\t\tthis.updateTime()\n\t\tif (this.opts.onTime) this.opts.onTime(this.t)\n\t}\n\n\tapplyTime(t, force){\n\t\tfor (const item of this.items){\n\t\t\tconst cfg = item.cfg\n\t\t\tconst start = cfg.start || 0\n\t\t\tconst end = start + (cfg.duration || 0)\n\t\t\tconst inDur = cfg.in && PresentationPlayer.transitions[cfg.in.name] ? (cfg.in.dur || 0) : 0\n\t\t\tconst outDur = cfg.out && PresentationPlayer.transitions[cfg.out.name] ? (cfg.out.dur || 0) : 0\n\t\t\tlet phase = 'hidden'\n\t\t\tif (t >= start && t < end){\n\t\t\t\tif (t < start + inDur) phase = 'in'\n\t\t\t\telse if (t > end - outDur) phase = 'out'\n\t\t\t\telse phase = 'on'\n\t\t\t}\n\t\t\tif (phase !== item.phase || force) this.setPhase(item, phase, t)\n\t\t}\n\t\tfor (const item of this.items){\n\t\t\tif (!item.words) continue\n\t\t\tconst start = item.cfg.start || 0\n\t\t\tif (t < start || t >= start + (item.cfg.duration || 0)) continue\n\t\t\tfor (const {span, i} of item.words){\n\t\t\t\tconst st = PresentationPlayer.popState(t - start - i * 0.06)\n\t\t\t\tspan.style.opacity = st.o\n\t\t\t\tspan.style.transform = 'translateY(' + st.y + 'em) scale(' + st.s + ')'\n\t\t\t}\n\t\t}\n\t\tthis.syncVideos(force)\n\t\tif (this.audio) this.syncAudio(force)\n\t\tthis.applySubs(t)\n\t}\n\n\tstatic popState(dt){\n\t\tconst D = 0.34\n\t\tif (dt <= 0) return {o: 0, s: 0.82, y: 0.28}\n\t\tif (dt >= D) return {o: 1, s: 1, y: 0}\n\t\tconst p = dt / D\n\t\tconst e = 1 + 2.70158 * Math.pow(p - 1, 3) + 1.70158 * Math.pow(p - 1, 2)\n\t\treturn {o: Math.min(1, p * 1.8), s: 0.82 + 0.18 * e, y: 0.28 * (1 - e)}\n\t}\n\n\tsetPhase(item, phase, t){\n\t\tconst el = item.el\n\t\tconst cfg = item.cfg\n\t\titem.phase = phase\n\t\tif (phase === 'hidden'){\n\t\t\tel.style.display = 'none'\n\t\t\treturn\n\t\t}\n\t\tel.style.display = 'block'\n\t\tconst start = cfg.start || 0\n\t\tconst end = start + (cfg.duration || 0)\n\t\tconst names = []\n\t\tconst durs = []\n\t\tconst delays = []\n\t\tconst eases = []\n\t\tconst counts = []\n\t\tel.style.transformOrigin = ''\n\t\tif (phase !== 'on'){\n\t\t\tconst dir = phase === 'in' ? 'in' : 'out'\n\t\t\tconst spec = PresentationPlayer.transitions[cfg[dir].name]\n\t\t\tconst dur = cfg[dir].dur || 0.5\n\t\t\tconst elapsed = dir === 'in' ? t - start : t - (end - dur)\n\t\t\tnames.push(spec[dir])\n\t\t\tdurs.push(dur + 's')\n\t\t\tdelays.push((-Math.max(0, elapsed)).toFixed(3) + 's')\n\t\t\teases.push(spec.ease)\n\t\t\tcounts.push('1')\n\t\t\tel.style.transformOrigin = (dir === 'in' ? spec.originIn : spec.originOut) || ''\n\t\t}\n\t\tconst du = cfg.during && PresentationPlayer.durings[cfg.during.name]\n\t\tif (du){\n\t\t\tnames.push(du.css)\n\t\t\tdurs.push((du.period || Math.max(0.1, end - start)) + 's')\n\t\t\tdelays.push((-Math.max(0, t - start)).toFixed(3) + 's')\n\t\t\teases.push(du.ease)\n\t\t\tcounts.push(du.period ? 'infinite' : '1')\n\t\t}\n\t\tif (!names.length){\n\t\t\tel.style.animation = 'none'\n\t\t\treturn\n\t\t}\n\t\tel.style.animationName = names.join(', ')\n\t\tel.style.animationDuration = durs.join(', ')\n\t\tel.style.animationDelay = delays.join(', ')\n\t\tel.style.animationTimingFunction = eases.join(', ')\n\t\tel.style.animationIterationCount = counts.join(', ')\n\t\tel.style.animationFillMode = 'both'\n\t\tel.style.animationPlayState = this.playing ? 'running' : 'paused'\n\t}\n\n\tsyncVideos(force){\n\t\tfor (const v of this.videos) this.syncMedia(v, v._start, force)\n\t}\n\n\tsyncAudio(force){\n\t\tthis.syncMedia(this.audio, this.audioStart, force)\n\t}\n\n\tsyncMedia(el, start, force){\n\t\tconst known = Number.isFinite(el.duration) && el.duration > 0\n\t\tconst show = known && this.t >= start && this.t < start + el.duration\n\t\tif (el.style) el.style.display = show ? '' : 'none'\n\t\tif (el.style && el.tagName === 'VIDEO'){\n\t\t\tconst end = start + (el.duration || 0)\n\t\t\tlet op = 1\n\t\t\tif (el._inDur && this.t < start + el._inDur) op = (this.t - start) / el._inDur\n\t\t\telse if (el._outDur && this.t > end - el._outDur) op = (end - this.t) / el._outDur\n\t\t\tel.style.opacity = Math.max(0, Math.min(1, op)).toFixed(3)\n\t\t}\n\t\tif (el.readyState < 1) return\n\t\tif (!show){\n\t\t\tif (!el.paused) el.pause()\n\t\t\treturn\n\t\t}\n\t\tconst target = this.t - start\n\t\tif (force || Math.abs(el.currentTime - target) > 0.35) el.currentTime = Math.min(Math.max(0, target), el.duration || target)\n\t\tif (this.playing && el.paused) el.play().catch(() => {})\n\t\tif (!this.playing && !el.paused) el.pause()\n\t}\n\n\tapplySubs(t){\n\t\tconst segs = this.showSubs ? this.segments() : null\n\t\tif (!segs || !segs.length){\n\t\t\tthis.subsEl.style.display = 'none'\n\t\t\treturn\n\t\t}\n\t\tlet current = null\n\t\tfor (const seg of segs){\n\t\t\tif (t >= seg.start && t <= seg.end){\n\t\t\t\tcurrent = seg\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif (current){\n\t\t\tthis.subsEl.textContent = current.text\n\t\t\tthis.subsEl.style.display = ''\n\t\t}\n\t\telse this.subsEl.style.display = 'none'\n\t}\n\n\tfmt(t){\n\t\tconst m = Math.floor(t / 60)\n\t\tconst s = Math.floor(t - m * 60)\n\t\treturn m + ':' + String(s).padStart(2, '0')\n\t}\n\n\tupdateTime(){\n\t\tconst shown = this.started ? this.t : 0\n\t\tif (this.timeEl) this.timeEl.textContent = this.fmt(shown) + ' / ' + this.fmt(this.duration)\n\t\tif (this.seekEl && !this.seeking) this.seekEl.value = this.duration ? Math.round(shown / this.duration * 1000) : 0\n\t}\n\n\tstatic sweep(){\n\t\tfor (const player of this.instances){\n\t\t\tif (player.root.isConnected) continue\n\t\t\tplayer.destroy()\n\t\t\tthis.instances.delete(player)\n\t\t}\n\t\tthis.boot()\n\t}\n\n\tstatic boot(){\n\t\tfor (const root of document.querySelectorAll('.pp-embed')){\n\t\t\tif (root.dataset.ppBooted) continue\n\t\t\tconst data = root.querySelector('script[type=\"application/json\"]')\n\t\t\tif (data){\n\t\t\t\troot.dataset.ppBooted = '1'\n\t\t\t\tthis.wire(root, JSON.parse(data.textContent))\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif (!root.dataset.src) continue\n\t\t\troot.dataset.ppBooted = '1'\n\t\t\tfetch(root.dataset.src)\n\t\t\t\t.then(r => r.json())\n\t\t\t\t.then(json => {\n\t\t\t\t\tif (!root.isConnected) return\n\t\t\t\t\tconst payload = json.presentation ? json : {presentation: json, mediaBase: root.dataset.src.replace(/[^\\/]*$/, '') + 'media/'}\n\t\t\t\t\tthis.wire(root, payload)\n\t\t\t\t})\n\t\t\t\t.catch(e => console.warn('PresentationPlayer: failed to load', root.dataset.src, e))\n\t\t}\n\t}\n\n\tstatic wire(root, payload){\n\t\tconst forced = 'lang' in root.dataset\n\t\tnew PresentationPlayer(root, payload, {controls: true, lang: root.dataset.lang || null, langSelector: !forced})\n\t}\n\n\tstatic keys = {\n\t\t' ': player => player.toggle(),\n\t\tk: player => player.toggle(),\n\t\tm: player => player.toggleMute(),\n\t\tc: player => (player.segments() || []).length && player.setSubs(!player.showSubs),\n\t\tf: player => player.fullscreen(),\n\t\thome: player => player.seek(0),\n\t\tend: player => player.seek(player.duration),\n\t\tarrowleft: player => player.seek(player.t - 5),\n\t\tarrowright: player => player.seek(player.t + 5),\n\t\tarrowup: player => player.setVol(player.vol + 0.05),\n\t\tarrowdown: player => player.setVol(player.vol - 0.05),\n\t}\n\n\tstatic keyTarget(){\n\t\tconst players = [...this.instances].filter(p => p.root.isConnected && p.playBtn)\n\t\tif (document.fullscreenElement){\n\t\t\tconst full = players.find(p => document.fullscreenElement.contains(p.root))\n\t\t\tif (full) return full\n\t\t}\n\t\treturn players.find(p => p.root.contains(document.activeElement)) || (players.length === 1 ? players[0] : null)\n\t}\n\n\tstatic key(e){\n\t\tif (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return\n\t\tconst action = this.keys[e.key.toLowerCase()]\n\t\tif (!action) return\n\t\tconst el = e.target\n\t\tif (el.closest && el.closest('input, textarea, select, [contenteditable]') && !el.classList.contains('pp-seek') && !el.classList.contains('pp-vol')) return\n\t\tconst player = this.keyTarget()\n\t\tif (!player) return\n\t\te.preventDefault()\n\t\taction(player)\n\t\tif (player.wake) player.wake()\n\t}\n}\n\nif (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => PresentationPlayer.boot())\nelse PresentationPlayer.boot()\nnew MutationObserver(() => PresentationPlayer.sweep()).observe(document.documentElement, {childList: true, subtree: true})\ndocument.addEventListener('keydown', e => PresentationPlayer.key(e))"
                    },
                    {
                        "node": "style",
                        "ns": null,
                        "line": 1022,
                        "body": ".pp-root {\n\tposition: relative\n\tbackground: #000\n\toverflow: hidden\n\tuser-select: none\n\tfont-family: system-ui, sans-serif\n}\n.pp-stage {\n\tposition: absolute\n\tinset: 0\n\toverflow: hidden\n\tcontainer-type: size\n}\n.pp-video {\n\tposition: absolute\n\tinset: 0\n\twidth: 100%\n\theight: 100%\n\tobject-fit: cover\n}\n.pp-video-box {\n\tright: auto\n\tbottom: auto\n\theight: auto\n}\n.pp-item {\n\tposition: absolute\n\twill-change: transform, opacity, clip-path\n\timg {\n\t\tdisplay: block\n\t\twidth: 100%\n\t\theight: auto\n\t}\n\ta {\n\t\tdisplay: block\n\t\tcursor: pointer\n\t}\n}\n.pp-text {\n\tposition: absolute\n\twill-change: transform, opacity\n\twhite-space: pre-wrap\n\toverflow-wrap: break-word\n\tline-height: 1.25\n\ttext-shadow: 0 1px 3px rgba(0,0,0,.55)\n\t.pp-word {\n\t\tdisplay: inline-block\n\t\twill-change: transform, opacity\n\t}\n}\n.pp-subs {\n\tposition: absolute\n\tleft: 50%\n\tbottom: 5%\n\ttransform: translateX(-50%)\n\tmax-width: 84%\n\tpadding: .3em .8em\n\tbackground: rgba(0,0,0,.55)\n\tcolor: #fff\n\tfont-size: 1.5em\n\tline-height: 1.35\n\ttext-align: center\n\tborder-radius: .3em\n\tpointer-events: none\n\tz-index: 3000\n}\n.pp-bigplay {\n\tposition: absolute\n\tleft: 50%\n\ttop: 50%\n\ttransform: translate(-50%,-50%)\n\twidth: 5em\n\theight: 5em\n\tborder-radius: 50%\n\tbackground: rgba(0,0,0,.6)\n\tborder: 2px solid rgba(255,255,255,.8)\n\tcolor: #fff\n\tcursor: pointer\n\tz-index: 3050\n\tdisplay: flex\n\talign-items: center\n\tjustify-content: center\n\ttransition: transform .15s ease\n\t\\:hover: transform: translate(-50%,-50%) scale(1.08)\n\tsvg {\n\t\twidth: 2em\n\t\theight: 2em\n\t\tfill: #fff\n\t\tmargin-left: .3em\n\t}\n}\n.pp-controls {\n\tposition: absolute\n\tleft: 0\n\tright: 0\n\tbottom: 0\n\tdisplay: flex\n\talign-items: center\n\tgap: .8em\n\tpadding: 2.2em .9em .5em\n\tbackground: linear-gradient(transparent, rgba(0,0,0,.75))\n\tz-index: 3040\n\topacity: 0\n\ttransition: opacity .25s ease\n\tfont-size: .9em\n}\n.pp-root:hover:not(.pp-idle) .pp-controls, .pp-root.pp-paused .pp-controls: opacity: 1\n.pp-root.pp-idle: cursor: none\n.pp-root:focus-visible {\n\toutline: 2px solid #FFB347\n\toutline-offset: -2px\n}\n.pp-controls button {\n\tbackground: none\n\tborder: 0\n\tcolor: #fff\n\tcursor: pointer\n\tfont-size: 1em\n\tpadding: .2em .4em\n\topacity: .9\n\t\\:hover: opacity: 1\n\tsvg {\n\t\twidth: 1.4em\n\t\theight: 1.4em\n\t\tfill: #fff\n\t\tdisplay: block\n\t}\n}\n.pp-controls button.pp-off: opacity: .35\n.pp-controls input.pp-vol {\n\taccent-color: #FFB347\n\tcursor: pointer\n\twidth: 64px\n}\n.pp-controls select.pp-lang {\n\tbackground: rgba(0,0,0,.45)\n\tcolor: #fff\n\tborder: 1px solid rgba(255,255,255,.3)\n\tborder-radius: .3em\n\tfont: inherit\n\tfont-size: .82em\n\tpadding: .15em .35em\n\tcursor: pointer\n\toption: color: #000\n}\n.pp-time {\n\tcolor: #fff\n\tfont-variant-numeric: tabular-nums\n\tfont-size: .95em\n\twhite-space: nowrap\n}\n.pp-seek {\n\tflex: 1\n\taccent-color: #FFB347\n\tcursor: pointer\n\theight: 1.2em\n\tmargin: 0\n}\n.pp-loading .pp-bigplay {\n\tpointer-events: none\n\topacity: .6\n\tborder-top-color: #FFB347\n\tanimation: pp-spin 1s linear infinite\n\tsvg: opacity: .3\n}\n.pp-edit {\n\toutline: 1px solid rgba(108,192,255,.7)\n\tbox-shadow: 0 0 0 1px rgba(0,0,0,.6), 0 0 0 4px rgba(108,192,255,.12), 0 10px 44px rgba(0,0,0,.6)\n}\n.pp-edit .pp-item {\n\tcursor: move\n\toutline: 1px dashed transparent\n\t\\:hover: outline: 1px dashed rgba(255,179,71,.6)\n}\n.pp-edit .pp-item.pp-sel {\n\toutline: 2px solid #FFB347\n\tz-index: 899\n}\n.pp-handle {\n\tposition: absolute\n\tright: -7px\n\tbottom: -7px\n\twidth: 14px\n\theight: 14px\n\tbackground: #FFB347\n\tborder-radius: 3px\n\tcursor: nwse-resize\n\tz-index: 901\n}\n.pp-edit .pp-text {\n\tcursor: move\n\toutline: 1px dashed transparent\n\t\\:hover: outline: 1px dashed rgba(255,179,71,.6)\n}\n.pp-edit .pp-text.pp-sel {\n\toutline: 2px solid #FFB347\n\tz-index: 899\n}\n.pp-standalone {\n\tposition: fixed\n\tinset: 0\n\tdisplay: flex\n\talign-items: center\n\tjustify-content: center\n\tbackground: #000\n\t.pp-root {\n\t\twidth: 100vw\n\t\theight: 100vh\n\t}\n\t@supports (aspect-ratio: 16 / 9) {\n\t\t.pp-root {\n\t\t\twidth: min(100vw, calc(100dvh * var(--pp-ar, 1.7778)))\n\t\t\theight: min(100dvh, calc(100vw / var(--pp-ar, 1.7778)))\n\t\t}\n\t}\n}\n@keyframes pp-spin {\n\t0%: transform: translate(-50%,-50%) rotate(0deg)\n\t100%: transform: translate(-50%,-50%) rotate(360deg)\n}\n@keyframes pp-fade-in {\n\t0%: opacity: 0\n\t100%: opacity: 1\n}\n@keyframes pp-fade-out {\n\t0%: opacity: 1\n\t100%: opacity: 0\n}\n@keyframes pp-zoom-in {\n\t0% {\n\t\topacity: 0\n\t\ttransform: scale(.96)\n\t}\n\t100% {\n\t\topacity: 1\n\t\ttransform: scale(1)\n\t}\n}\n@keyframes pp-zoom-out {\n\t0% {\n\t\topacity: 1\n\t\ttransform: scale(1)\n\t}\n\t100% {\n\t\topacity: 0\n\t\ttransform: scale(1.05)\n\t}\n}\n@keyframes pp-glide-in {\n\t0%: transform: translateX(-100dvw)\n\t100%: transform: translateX(0)\n}\n@keyframes pp-glide-out {\n\t0% {\n\t\topacity: 1\n\t\ttransform: translateX(0)\n\t}\n\t100% {\n\t\topacity: 0\n\t\ttransform: translateX(100dvw)\n\t}\n}\n@keyframes pp-slide-in {\n\t0% {\n\t\topacity: 0\n\t\ttransform: translateX(-20px)\n\t}\n\t100% {\n\t\topacity: 1\n\t\ttransform: translateX(0)\n\t}\n}\n@keyframes pp-slide-out {\n\t0% {\n\t\topacity: 1\n\t\ttransform: translateX(0)\n\t}\n\t100% {\n\t\topacity: 0\n\t\ttransform: translateX(20px)\n\t}\n}\n@keyframes pp-drop-in {\n\t0% {\n\t\topacity: 0\n\t\ttransform: translateY(-50px)\n\t}\n\t100% {\n\t\topacity: 1\n\t\ttransform: translateY(0)\n\t}\n}\n@keyframes pp-drop-out {\n\t0% {\n\t\topacity: 1\n\t\ttransform: translateY(0)\n\t}\n\t100% {\n\t\topacity: 0\n\t\ttransform: translateY(50px)\n\t}\n}\n@keyframes pp-skew-in {\n\t0% {\n\t\topacity: 0\n\t\ttransform: skewX(-15deg)\n\t}\n\t100% {\n\t\topacity: 1\n\t\ttransform: skewX(0deg)\n\t}\n}\n@keyframes pp-skew-out {\n\t0% {\n\t\topacity: 1\n\t\ttransform: skewX(0deg)\n\t}\n\t100% {\n\t\topacity: 0\n\t\ttransform: skewX(15deg)\n\t}\n}\n@keyframes pp-tilt-in {\n\t0% {\n\t\topacity: 0\n\t\ttransform: rotateZ(-5deg) translateY(20px)\n\t}\n\t100% {\n\t\topacity: 1\n\t\ttransform: rotateZ(0deg) translateY(0)\n\t}\n}\n@keyframes pp-tilt-out {\n\t0% {\n\t\topacity: 1\n\t\ttransform: rotateZ(0deg) translateY(0)\n\t}\n\t100% {\n\t\topacity: 0\n\t\ttransform: rotateZ(5deg) translateY(-20px)\n\t}\n}\n@keyframes pp-spiral-in {\n\t0% {\n\t\topacity: 0\n\t\ttransform: rotate(-360deg) scale(0)\n\t}\n\t100% {\n\t\topacity: 1\n\t\ttransform: rotate(0deg) scale(1)\n\t}\n}\n@keyframes pp-spiral-out {\n\t0% {\n\t\topacity: 1\n\t\ttransform: rotate(0deg) scale(1)\n\t}\n\t100% {\n\t\topacity: 0\n\t\ttransform: rotate(360deg) scale(0)\n\t}\n}\n@keyframes pp-ripple-in {\n\t0% {\n\t\topacity: 0\n\t\ttransform: scale(.5)\n\t\tfilter: blur(3px)\n\t}\n\t100% {\n\t\topacity: 1\n\t\ttransform: scale(1)\n\t\tfilter: blur(0px)\n\t}\n}\n@keyframes pp-ripple-out {\n\t0% {\n\t\topacity: 1\n\t\ttransform: scale(1)\n\t\tfilter: blur(0px)\n\t}\n\t100% {\n\t\topacity: 0\n\t\ttransform: scale(1.5)\n\t\tfilter: blur(3px)\n\t}\n}\n@keyframes pp-curtain-in {\n\t0% {\n\t\topacity: 0\n\t\ttransform: scaleY(0)\n\t\ttransform-origin: bottom\n\t}\n\t100% {\n\t\topacity: 1\n\t\ttransform: scaleY(1)\n\t\ttransform-origin: bottom\n\t}\n}\n@keyframes pp-curtain-out {\n\t0% {\n\t\topacity: 1\n\t\ttransform: scaleY(1)\n\t\ttransform-origin: top\n\t}\n\t100% {\n\t\topacity: 0\n\t\ttransform: scaleY(0)\n\t\ttransform-origin: top\n\t}\n}\n@keyframes pp-tv-in {\n\t0% {\n\t\topacity: 0\n\t\ttransform: scale(0, 0)\n\t}\n\t50% {\n\t\topacity: 0.8\n\t\ttransform: scale(1, 0.02)\n\t}\n\t100% {\n\t\topacity: 1\n\t\ttransform: scale(1, 1)\n\t}\n}\n@keyframes pp-tv-out {\n\t0% {\n\t\topacity: 1\n\t\ttransform: scale(1, 1)\n\t}\n\t50% {\n\t\topacity: 0.8\n\t\ttransform: scale(1, 0.02)\n\t}\n\t100% {\n\t\topacity: 0\n\t\ttransform: scale(0, 0)\n\t}\n}\n@keyframes pp-flip-in {\n\t0% {\n\t\ttransform: perspective(1600px) rotateY(-180deg)\n\t\topacity: 0\n\t}\n\t50%: opacity: 0\n\t50.1%: opacity: 1\n\t100% {\n\t\ttransform: perspective(1600px) rotateY(0deg)\n\t\topacity: 1\n\t}\n}\n@keyframes pp-flip-out {\n\t0% {\n\t\ttransform: perspective(1600px) rotateY(0deg)\n\t\topacity: 1\n\t}\n\t49.9%: opacity: 1\n\t50%: opacity: 0\n\t100% {\n\t\ttransform: perspective(1600px) rotateY(180deg)\n\t\topacity: 0\n\t}\n}\n@keyframes pp-cube-in {\n\t0% {\n\t\ttransform: rotateY(90deg) translateZ(0)\n\t\topacity: 0\n\t}\n\t100% {\n\t\ttransform: rotateY(0deg) translateZ(0)\n\t\topacity: 1\n\t}\n}\n@keyframes pp-cube-out {\n\t0% {\n\t\ttransform: rotateY(0deg) translateZ(0)\n\t\topacity: 1\n\t}\n\t100% {\n\t\ttransform: rotateY(-90deg) translateZ(0)\n\t\topacity: 0\n\t}\n}\n@keyframes pp-diamond-in {\n\t0%: clip-path: polygon(50% 50%,50% 50%,50% 50%,50% 50%)\n\t100%: clip-path: polygon(50% -50%,150% 50%,50% 150%,-50% 50%)\n}\n@keyframes pp-diamond-out {\n\t0%: clip-path: polygon(50% -50%,150% 50%,50% 150%,-50% 50%)\n\t100%: clip-path: polygon(50% 50%,50% 50%,50% 50%,50% 50%)\n}\n@keyframes pp-diaphragm-in {\n\t0%: clip-path: circle(0% at 50% 50%)\n\t100%: clip-path: circle(90% at 50% 50%)\n}\n@keyframes pp-diaphragm-out {\n\t0%: clip-path: circle(90% at 50% 50%)\n\t100%: clip-path: circle(0% at 50% 50%)\n}\n@keyframes pp-spotlight-in {\n\t0%: clip-path: circle(0% at 50% 50%)\n\t100%: clip-path: circle(75% at 50% 50%)\n}\n@keyframes pp-spotlight-out {\n\t0%: clip-path: circle(75% at 50% 50%)\n\t100%: clip-path: circle(0% at 50% 50%)\n}\n@keyframes pp-wipe-in {\n\t0% {\n\t\tclip-path: inset(0% 100% 0% 0%)\n\t\topacity: 0\n\t}\n\t40% {\n\t\tclip-path: inset(0% 40% 0% 0%)\n\t\topacity: .9\n\t}\n\t100% {\n\t\tclip-path: inset(0% 0% 0% 0%)\n\t\topacity: 1\n\t}\n}\n@keyframes pp-wipe-out {\n\t0% {\n\t\tclip-path: inset(0% 0% 0% 0%)\n\t\topacity: 1\n\t}\n\t60% {\n\t\tclip-path: inset(0% 0% 0% 60%)\n\t\topacity: .3\n\t}\n\t100% {\n\t\tclip-path: inset(0% 0% 0% 100%)\n\t\topacity: 0\n\t}\n}\n@keyframes pp-glitch-in {\n\t0% {\n\t\tclip-path: inset(100% 0 0 0)\n\t\ttransform: translate(10px, 0)\n\t\topacity: 0\n\t}\n\t20% {\n\t\tclip-path: inset(10% 0 60% 0)\n\t\ttransform: translate(-10px, 5px)\n\t}\n\t40% {\n\t\tclip-path: inset(80% 0 5% 0)\n\t\ttransform: translate(5px, -10px)\n\t}\n\t60% {\n\t\tclip-path: inset(0 0 20% 0)\n\t\ttransform: translate(-5px, 5px)\n\t}\n\t80% {\n\t\tclip-path: inset(40% 0 40% 0)\n\t\ttransform: translate(5px, 0)\n\t}\n\t100% {\n\t\tclip-path: inset(0 0 0 0)\n\t\ttransform: translate(0)\n\t\topacity: 1\n\t}\n}\n@keyframes pp-glitch-out {\n\t0% {\n\t\tclip-path: inset(0 0 0 0)\n\t\ttransform: translate(0)\n\t}\n\t20% {\n\t\tclip-path: inset(20% 0 80% 0)\n\t\ttransform: translate(-5px, 5px)\n\t}\n\t40% {\n\t\tclip-path: inset(80% 0 5% 0)\n\t\ttransform: translate(5px, -5px)\n\t}\n\t60% {\n\t\tclip-path: inset(10% 0 60% 0)\n\t\ttransform: translate(-5px, 0)\n\t}\n\t80% {\n\t\tclip-path: inset(50% 0 20% 0)\n\t\ttransform: translate(5px, 5px)\n\t}\n\t100% {\n\t\tclip-path: inset(50% 50% 50% 50%)\n\t\ttransform: translate(0)\n\t\topacity: 0\n\t}\n}\n@keyframes pp-cards-in {\n\t0%: transform: translateY(100%)\n\t100%: transform: translateY(0)\n}\n@keyframes pp-cards-out {\n\t0% {\n\t\ttransform: translateY(0)\n\t\topacity: 1\n\t}\n\t100% {\n\t\ttransform: translateY(100%)\n\t\topacity: 0\n\t}\n}\n@keyframes pp-scale {\n\t0%: scale: var(--pp-s0, .9)\n\t100%: scale: 1\n}\n@keyframes pp-drift {\n\t0%: translate: var(--pp-x0, 5%) var(--pp-y0, 0%)\n\t100%: translate: 0% 0%\n}\n@keyframes pp-kenburns {\n\t0% {\n\t\tscale: var(--pp-s0, .92)\n\t\ttranslate: var(--pp-x0, -4%) var(--pp-y0, 4%)\n\t}\n\t100% {\n\t\tscale: 1\n\t\ttranslate: 0% 0%\n\t}\n}\n@keyframes pp-float {\n\t0%: translate: 0% 0%\n\t50%: translate: 0% var(--pp-y1, -3%)\n\t100%: translate: 0% 0%\n}\n@keyframes pp-sway {\n\t0%: rotate: var(--pp-r0, -1.5deg)\n\t50%: rotate: var(--pp-r1, 1.5deg)\n\t100%: rotate: var(--pp-r0, -1.5deg)\n}\n@keyframes pp-pulse {\n\t0%: scale: 1\n\t50%: scale: var(--pp-s1, 1.02)\n\t100%: scale: 1\n}"
                    }
                ]
            },
            "recorder": {
                "file": "/srv/control/phlo/resources/DOM/recorder.phlo",
                "class": "recorder",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Record the screen, a camera/mic or a canvas via MediaRecorder -> Blob. Optionally transcodes to MP4 with the DOM/ffmpeg resource when it is loaded. Exposes the ready singleton `recorder` (and class `Recorder`).",
                    "advice": "Records the screen, a camera or a canvas through MediaRecorder and answers with a blob. The browser decides the container, which is usually WebM and on Safari is not, so transcode when the file has to be played anywhere; loading the ffmpeg resource gives you MP4. Recording needs a real gesture from the visitor and a secure origin, so it cannot be started from a script alone.",
                    "package": "dom",
                    "frontend": "true",
                    "backend": "false",
                    "requires": "DOM/ffmpeg?",
                    "tags": "video recorder mediarecorder screen capture getdisplaymedia webcam canvas"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 11,
                        "body": "class Recorder {\n\n\tconstructor(opts = {}){\n\t\tthis.mimeType = opts.mimeType || ''\n\t\tthis.log = opts.log || (() => {})\n\t}\n\n\tsupported(){\n\t\treturn typeof MediaRecorder !== 'undefined' && !!(navigator.mediaDevices)\n\t}\n\n\tpickMime(prefs){\n\t\tif (typeof MediaRecorder === 'undefined') return ''\n\t\tfor (const m of prefs) if (m && MediaRecorder.isTypeSupported(m)) return m\n\t\treturn ''\n\t}\n\n\tmixed(video, audioStreams){\n\t\tconst ctx = new AudioContext()\n\t\tconst dest = ctx.createMediaStreamDestination()\n\t\tconst sources = []\n\t\tfor (const s of audioStreams){\n\t\t\tif (!s.getAudioTracks().length) continue\n\t\t\tctx.createMediaStreamSource(s).connect(dest)\n\t\t\tsources.push(...s.getAudioTracks())\n\t\t}\n\t\tconst out = new MediaStream([...video.getVideoTracks(), ...dest.stream.getAudioTracks()])\n\t\tout.phloSources = sources\n\t\tout.phloCtx = ctx\n\t\treturn out\n\t}\n\n\tasync stream(opts = {}){\n\t\tif (opts.stream) return opts.stream\n\t\tif (opts.canvas){\n\t\t\tconst s = opts.canvas.captureStream(opts.fps || 30)\n\t\t\tif (opts.mic){\n\t\t\t\tconst m = await navigator.mediaDevices.getUserMedia({audio: true})\n\t\t\t\tm.getAudioTracks().forEach(t => s.addTrack(t))\n\t\t\t}\n\t\t\treturn s\n\t\t}\n\t\tif (opts.screen){\n\t\t\tconst s = await navigator.mediaDevices.getDisplayMedia({video: opts.video == null ? true : opts.video, audio: opts.audio == null ? false : opts.audio})\n\t\t\tif (opts.mic){\n\t\t\t\tconst m = await navigator.mediaDevices.getUserMedia({audio: true})\n\t\t\t\tif (s.getAudioTracks().length) return this.mixed(s, [s, m])\n\t\t\t\tm.getAudioTracks().forEach(t => s.addTrack(t))\n\t\t\t}\n\t\t\treturn s\n\t\t}\n\t\treturn navigator.mediaDevices.getUserMedia({video: opts.video == null ? true : opts.video, audio: opts.audio == null ? true : opts.audio})\n\t}\n\n\tasync record(opts = {}){\n\t\tif (!this.supported()) throw new Error('MediaRecorder not supported here')\n\t\tconst stream = await this.stream(opts)\n\t\tconst ownTracks = !opts.stream\n\t\tconst mime = opts.mimeType || this.mimeType || this.pickMime(['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'])\n\t\tconst recOpts = {}\n\t\tif (mime) recOpts.mimeType = mime\n\t\tif (opts.videoBitsPerSecond) recOpts.videoBitsPerSecond = opts.videoBitsPerSecond\n\t\tif (opts.audioBitsPerSecond) recOpts.audioBitsPerSecond = opts.audioBitsPerSecond\n\t\tconst rec = new MediaRecorder(stream, recOpts)\n\t\tconst chunks = []\n\t\tlet stopResolve, stopReject\n\t\tconst stopped = new Promise((res, rej) => {\n\t\t\tstopResolve = res\n\t\t\tstopReject = rej\n\t\t})\n\t\trec.ondataavailable = e => {\n\t\t\tif (e.data && e.data.size){\n\t\t\t\tchunks.push(e.data)\n\t\t\t\tif (opts.onData) opts.onData(e.data)\n\t\t\t}\n\t\t}\n\t\trec.onstop = () => {\n\t\t\tif (ownTracks){\n\t\t\t\tfor (const t of stream.getTracks()) t.stop()\n\t\t\t\tfor (const t of stream.phloSources || []) t.stop()\n\t\t\t\tif (stream.phloCtx) stream.phloCtx.close().catch(() => {})\n\t\t\t}\n\t\t\tstopResolve(new Blob(chunks, {type: (rec.mimeType || mime || 'video/webm').split(';')[0]}))\n\t\t}\n\t\trec.onerror = e => stopReject((e && e.error) || new Error('recording error'))\n\t\tstream.getVideoTracks().forEach(t => t.addEventListener('ended', () => { if (rec.state !== 'inactive') rec.stop() }, {once: true}))\n\t\trec.start(opts.timeslice || undefined)\n\t\tthis.log('[recorder] started ' + (rec.mimeType || mime || 'default'))\n\t\treturn {\n\t\t\tstream,\n\t\t\trecorder: rec,\n\t\t\tget state(){ return rec.state },\n\t\t\tpause(){ if (rec.state === 'recording') rec.pause() },\n\t\t\tresume(){ if (rec.state === 'paused') rec.resume() },\n\t\t\tasync stop(o = {}){\n\t\t\t\tif (rec.state !== 'inactive') rec.stop()\n\t\t\t\tconst blob = await stopped\n\t\t\t\tif (o.mp4 && typeof ffmpeg !== 'undefined' && blob.type.indexOf('mp4') === -1){\n\t\t\t\t\treturn ffmpeg.transcode(blob, {to: 'mp4', crf: o.crf, args: o.args, onProgress: o.onProgress, log: o.log})\n\t\t\t\t}\n\t\t\t\treturn blob\n\t\t\t},\n\t\t}\n\t}\n}\n\nconst recorder = new Recorder"
                    }
                ]
            },
            "shorthands": {
                "file": "/srv/control/phlo/resources/DOM/shorthands.phlo",
                "class": "shorthands",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "onChange, onClick and onInput event shorthands",
                    "advice": "Nothing more than onClick, onChange and onInput for the three most common cases of on(). Same behaviour and the same limitation: they bind to what exists at that moment, so use onExist for anything that appears later.",
                    "package": "dom",
                    "frontend": "true",
                    "backend": "false",
                    "requires": "@DOM",
                    "tags": "dom events shorthand frontend"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 11,
                        "body": "function onChange(els, cb){ on('change', els, cb) }\nfunction onClick(els, cb){ on('click', els, cb) }\nfunction onInput(els, cb){ on('input', els, cb) }"
                    }
                ]
            },
            "store": {
                "file": "/srv/control/phlo/resources/DOM/store.phlo",
                "class": "store",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Stateful binding engine",
                    "advice": "Bind an element to a value with data-bind and it follows every change, in both directions on an input. data-bind-attr does the same for an attribute, data-each repeats a template over a list, and app.calc holds values derived from others and recalculated on their own. app.persist keeps a path across a reload and app.sync keeps it equal across tabs or over a websocket. On a first render the DOM wins over an empty store, so server-rendered content is not blanked before the store has been filled.",
                    "package": "dom",
                    "frontend": "true",
                    "backend": "false",
                    "requires": "@DOM",
                    "tags": "dom store binding state signals calc reactive each persist websocket sync"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 11,
                        "body": "phlo.store = {\n\tsignals: {},\n\tlisteners: {},\n\tcalcs: {},\n\tcalcDeps: {},\n\tcalcVals: {},\n\tcalcTick: false,\n\tformats: {},\n\tpersists: {},\n\tsyncs: {},\n\tquiet: 0,\n\tscope: 'page',\n\tsplit: path => path.replace(/\\]/g, '').split(/\\.|\\[/),\n\tget(path){\n\t\tif (!path) return undefined\n\t\tlet ctx = phlo.store.signals\n\t\tconst keys = phlo.store.split(path)\n\t\tfor (let i = 0; i < keys.length; i++){\n\t\t\tif (ctx == null) return undefined\n\t\t\tctx = ctx[keys[i]]\n\t\t}\n\t\treturn ctx\n\t},\n\tsetPath(path, value){\n\t\tlet keys = phlo.store.split(path)\n\t\tlet ctx = phlo.store.signals\n\t\twhile (keys.length > 1){\n\t\t\tconst k = keys.shift()\n\t\t\tctx[k] ??= isNaN(keys[0]) ? {} : []\n\t\t\tctx = ctx[k]\n\t\t}\n\t\tconst k = keys[0]\n\t\tconst old = ctx[k]\n\t\tif (old === value) return false\n\t\tctx[k] = value\n\t\treturn true\n\t},\n\tset(path, value){\n\t\tif (!phlo.store.setPath(path, value)) return\n\t\tphlo.store.notify(path, phlo.store.get(path))\n\t\tphlo.store.recalc(path)\n\t\tphlo.store.schedule()\n\t\tphlo.store.save(path)\n\t\tif (!phlo.store.quiet) phlo.store.push(path)\n\t},\n\ton(path, cb, el = null){\n\t\t(phlo.store.listeners[path] ??= new Set).add(cb)\n\t\tif (el) phlo.store.owners.set(cb, el)\n\t},\n\toff(path, cb){\n\t\tphlo.store.listeners[path]?.delete(cb)\n\t\tphlo.store.owners.delete(cb)\n\t},\n\towners: new WeakMap,\n\tsweep(){\n\t\tObject.keys(phlo.store.listeners).forEach(path => {\n\t\t\tphlo.store.listeners[path].forEach(cb => {\n\t\t\t\tconst el = phlo.store.owners.get(cb)\n\t\t\t\tif (el && !el.isConnected) phlo.store.off(path, cb)\n\t\t\t})\n\t\t\tif (!phlo.store.listeners[path].size) delete phlo.store.listeners[path]\n\t\t})\n\t},\n\treset(prefix = ''){\n\t\tif (!prefix){\n\t\t\tphlo.store.signals = {}\n\t\t\tphlo.store.listeners = {}\n\t\t\tphlo.store.calcs = {}\n\t\t\tphlo.store.calcDeps = {}\n\t\t\tphlo.store.calcVals = {}\n\t\t\tphlo.store.calcTick = false\n\t\t\treturn\n\t\t}\n\t\tconst keys = phlo.store.split(prefix)\n\t\tlet ctx = phlo.store.signals\n\t\tfor (let i = 0; i < keys.length - 1; i++) ctx = ctx?.[keys[i]]\n\t\tif (ctx) delete ctx[keys[keys.length - 1]]\n\t\tphlo.store.notify(prefix, undefined)\n\t\tObject.keys(phlo.store.listeners).forEach(path => phlo.store.match(prefix, path) && delete phlo.store.listeners[path])\n\t},\n\treplace(path, value){\n\t\tconst keys = phlo.store.split(path)\n\t\tlet ctx = phlo.store.signals\n\t\tfor (let i = 0; i < keys.length - 1; i++) ctx = ctx?.[keys[i]]\n\t\tif (ctx) delete ctx[keys[keys.length - 1]]\n\t\tphlo.store.set(path, value)\n\t},\n\tsignal(path, initial){\n\t\tif (phlo.store.get(path) === undefined) phlo.store.set(path, initial)\n\t\treturn { subscribe: cb => phlo.store.on(path, cb), unsubscribe: cb => phlo.store.off(path, cb) }\n\t},\n\tnotify(path, val){\n\t\tObject.keys(phlo.store.listeners).forEach(dep => {\n\t\t\tif (!phlo.store.match(dep, path)) return\n\t\t\tconst set = phlo.store.listeners[dep]\n\t\t\tif (!set) return\n\t\t\tconst value = dep === path ? val : phlo.store.get(dep)\n\t\t\t;[...set].forEach(cb => {\n\t\t\t\ttry { cb(value) }\n\t\t\t\tcatch(e){ phlo.log('store binding', dep, e) }\n\t\t\t})\n\t\t})\n\t},\n\tmatch(dep, changed){\n\t\tif (!dep) return false\n\t\tif (dep === changed) return true\n\t\treturn changed.startsWith(dep + '.') || changed.startsWith(dep + '[') || dep.startsWith(changed + '.') || dep.startsWith(changed + '[')\n\t},\n\tdepsReady(list){\n\t\tconst arr = Array.isArray(list) ? list : (list ? [list] : [])\n\t\treturn arr.every(d => phlo.store.get(d) !== undefined)\n\t},\n\tevalCalc(name){\n\t\tconst fn = phlo.store.calcs[name]\n\t\tif (!fn) return\n\t\tlet deps = []\n\t\tlet val\n\t\ttry {\n\t\t\tconst out = fn()\n\t\t\tif (Array.isArray(out) && out.length === 2) deps = out[0], val = out[1]\n\t\t\telse val = out\n\t\t}\n\t\tcatch(e){\n\t\t\tdeps = []\n\t\t\tval = undefined\n\t\t}\n\t\tconst list = Array.isArray(deps) ? deps : (deps ? [deps] : [])\n\t\tphlo.store.calcDeps[name] = list\n\t\tif (!phlo.store.depsReady(list)) return\n\t\tconst old = phlo.store.calcVals[name]\n\t\tif (old !== val){\n\t\t\tphlo.store.calcVals[name] = val\n\t\t\tconst p = `calc.${name}`\n\t\t\tphlo.store.setPath(p, val)\n\t\t\tphlo.store.notify(p, val)\n\t\t}\n\t},\n\trecalc(changed){\n\t\tconst names = Object.keys(phlo.store.calcs)\n\t\tfor (let i = 0; i < names.length; i++){\n\t\t\tconst name = names[i]\n\t\t\tconst deps = phlo.store.calcDeps[name] || []\n\t\t\tfor (let j = 0; j < deps.length; j++){\n\t\t\t\tif (phlo.store.match(deps[j], changed)){\n\t\t\t\t\tphlo.store.evalCalc(name)\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t},\n\trecalcAll(){\n\t\tconst names = Object.keys(phlo.store.calcs)\n\t\tfor (let i = 0; i < names.length; i++) phlo.store.evalCalc(names[i])\n\t},\n\tschedule(){\n\t\tif (phlo.store.calcTick) return\n\t\tphlo.store.calcTick = true\n\t\tsetTimeout(() => {\n\t\t\tphlo.store.calcTick = false\n\t\t\tphlo.store.recalcAll()\n\t\t})\n\t},\n\n\tformat(name, value){\n\t\tconst fn = phlo.store.formats[name]\n\t\treturn fn ? fn(value) : value\n\t},\n\n\tadapters: {\n\t\tlocal: {\n\t\t\tread: key => { try { return JSON.parse(localStorage.getItem('phlo.' + key)) } catch(e){ return undefined } },\n\t\t\twrite: (key, value) => { try { localStorage.setItem('phlo.' + key, JSON.stringify(value)) } catch(e){} },\n\t\t},\n\t\tsession: {\n\t\t\tread: key => { try { return JSON.parse(sessionStorage.getItem('phlo.' + key)) } catch(e){ return undefined } },\n\t\t\twrite: (key, value) => { try { sessionStorage.setItem('phlo.' + key, JSON.stringify(value)) } catch(e){} },\n\t\t},\n\t},\n\tpersist(path, adapter = 'local'){\n\t\tconst store = typeof adapter === 'string' ? phlo.store.adapters[adapter] : adapter\n\t\tif (!store) return\n\t\tphlo.store.persists[path] = store\n\t\tconst saved = store.read(path)\n\t\tif (saved !== undefined && saved !== null) app.mod.store(path, saved)\n\t},\n\tsave(changed){\n\t\tObject.keys(phlo.store.persists).forEach(path => {\n\t\t\tif (phlo.store.match(path, changed)) phlo.store.persists[path].write(path, phlo.store.get(path))\n\t\t})\n\t},\n\n\tsync(path, options = {}){\n\t\tphlo.store.syncs[path] = {ws: options.ws ?? true, post: options.post ?? null, delay: options.delay ?? 200}\n\t},\n\tpush(changed){\n\t\tObject.keys(phlo.store.syncs).forEach(path => {\n\t\t\tif (!phlo.store.match(path, changed)) return\n\t\t\tconst sync = phlo.store.syncs[path]\n\t\t\tdelay('store-' + path, sync.delay, () => {\n\t\t\t\tconst value = phlo.store.get(path)\n\t\t\t\tif (sync.ws && app.websocket?.ready) app.websocket.send({sync: {[path]: value}})\n\t\t\t\tif (sync.post) app.post(sync.post, {path, value}, false)\n\t\t\t})\n\t\t})\n\t},\n\tmirror(path, value){\n\t\tphlo.store.quiet++\n\t\ttry { app.mod.store(path, value) }\n\t\tfinally { phlo.store.quiet-- }\n\t},\n\n\tproxy(base){\n\t\treturn new Proxy({}, {\n\t\t\tget(t, k){\n\t\t\t\tif (typeof k === 'symbol') return undefined\n\t\t\t\tconst seg = /^\\d+$/.test(k) ? `[${k}]` : (base ? `.${k}` : String(k))\n\t\t\t\tconst path = base + seg\n\t\t\t\tconst v = phlo.store.get(path)\n\t\t\t\tif (v !== undefined && (typeof v !== 'object' || v === null)) return v\n\t\t\t\treturn phlo.store.proxy(path)\n\t\t\t},\n\t\t\tset(t, k, v){\n\t\t\t\tconst seg = /^\\d+$/.test(k) ? `[${k}]` : (base ? `.${k}` : String(k))\n\t\t\t\tphlo.store.set(base + seg, v)\n\t\t\t\treturn true\n\t\t\t},\n\t\t\thas(t, k){ return phlo.store.get(base + (base ? '.' : '') + String(k)) !== undefined },\n\t\t\townKeys(){ return Object.keys(phlo.store.get(base) || {}) },\n\t\t\tgetOwnPropertyDescriptor(){ return { enumerable: true, configurable: true } }\n\t\t})\n\t}\n}\n\napp.store = phlo.store.proxy('')\n\napp.mod.store = (key, value) => {\n\tif (JSON.stringify(phlo.store.get(key)) === JSON.stringify(value)) return\n\tconst walk = (base, obj) => {\n\t\tif (Array.isArray(obj)) return phlo.store.set(base, obj)\n\t\tif (typeof obj !== 'object' || obj === null) return phlo.store.set(base, obj)\n\t\tObject.entries(obj).forEach(([k, v]) => walk(isNaN(k) ? `${base}.${k}` : `${base}[${k}]`, v))\n\t}\n\twalk(key, value)\n}\n\napp.mod.sync = (key, value) => phlo.store.mirror(key, value)\n\nphlo.calc = new Proxy({}, {\n\tset(t, k, fn){\n\t\tif (typeof fn !== 'function') return false\n\t\tphlo.store.calcs[k] = fn\n\t\tphlo.store.evalCalc(k)\n\t\tsetTimeout(() => phlo.store.evalCalc(k))\n\t\treturn true\n\t},\n\tget(t, k){ return phlo.store.calcs[k] },\n\thas(t, k){ return k in phlo.store.calcs },\n\tdeleteProperty(t, k){\n\t\tdelete phlo.store.calcs[k]\n\t\tdelete phlo.store.calcDeps[k]\n\t\tdelete phlo.store.calcVals[k]\n\t\treturn true\n\t}\n})\n\napp.calc = new Proxy({}, {\n\tget(t, k){ return phlo.store.calcVals[k] },\n\thas(t, k){ return k in phlo.store.calcVals },\n\townKeys(){ return Object.keys(phlo.store.calcVals) },\n\tgetOwnPropertyDescriptor(){ return { enumerable: true, configurable: true } }\n})\n\napp.format = (name, fn) => phlo.store.formats[name] = fn\napp.persist = (path, adapter) => phlo.store.persist(path, adapter)\napp.sync = (path, options) => phlo.store.sync(path, options)\napp.push = path => phlo.store.push(path)\napp.replace = (path, value) => phlo.store.replace(path, value)\n\nonExist('[data-bind]', el => {\n\tconst not = el.dataset.bind.startsWith('!')\n\tconst key = not ? el.dataset.bind.slice(1) : el.dataset.bind\n\tconst isCalc = key.startsWith('calc.')\n\tconst isInput = el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA'\n\tconst fromDom = isInput ? el.value : el.textContent\n\tconst fromStore = phlo.store.get(key)\n\tconst domNonEmpty = (fromDom ?? '').trim() !== ''\n\tconst storeEmpty = fromStore === undefined || (typeof fromStore === 'string' && fromStore.trim() === '')\n\tconst domLeads = !isCalc && domNonEmpty && storeEmpty\n\tconst format = el.dataset.bindFormat\n\tconst S = v => v == null ? '' : (typeof v === 'object' ? '' : String(v))\n\tconst apply = raw => {\n\t\tconst v = not ? !raw : raw\n\t\tconst s = S(format ? phlo.store.format(format, v) : v)\n\t\tif (isInput) el.value = s\n\t\telse el.textContent = s\n\t}\n\tphlo.store.on(key, apply, el)\n\tif (domLeads) phlo.store.set(key, fromDom)\n\tconst initial = domLeads ? fromDom : fromStore\n\tapply(initial)\n\tif (!isCalc && isInput) el.oninput = e => phlo.store.set(key, e.target.value)\n})\n\nonExist('[data-bind-attr]', el => {\n\tconst spec = el.getAttribute('data-bind-attr')\n\tif (!spec) return\n\tconst BOOL = new Set(['disabled','checked','hidden','required','readonly','selected','autofocus','multiple'])\n\tlet meta = phlo.existing.get(el)\n\tif (!meta || typeof meta !== 'object'){\n\t\tmeta = { exist: true }\n\t\tphlo.existing.set(el, meta)\n\t}\n\tmeta.attr || (meta.attr = {})\n\tmeta.attr.cls || (meta.attr.cls = [])\n\tconst format = el.dataset.bindFormat\n\tconst owned = (el.dataset.bindClass || '').split(/\\s+/).filter(Boolean)\n\tspec.split(/\\s*,\\s*/).filter(Boolean).forEach(pair => {\n\t\tconst m = pair.match(/^\\s*([^:]+)\\s*:\\s*(.+)\\s*$/)\n\t\tif (!m) return\n\t\tconst name = m[1]\n\t\tconst not = m[2].startsWith('!')\n\t\tconst path = not ? m[2].slice(1) : m[2]\n\t\tconst isCalc = path.startsWith('calc.')\n\t\tconst isInputVal = name === 'value' && (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA')\n\t\tconst domVal =\n\t\t\tname === 'text' ? el.textContent :\n\t\t\tname === 'html' ? el.innerHTML :\n\t\t\tname === 'value' ? el.value :\n\t\t\t(name === 'class' ? null : el.getAttribute(name))\n\t\tconst fromStore = phlo.store.get(path)\n\t\tconst domNonEmpty = (domVal ?? '').trim() !== ''\n\t\tconst storeEmpty = fromStore === undefined || (typeof fromStore === 'string' && fromStore.trim() === '')\n\t\tconst domLeads = !isCalc && name !== 'class' && domNonEmpty && storeEmpty\n\t\tconst S = v => v == null ? '' : (typeof v === 'object' ? '' : String(v))\n\t\tconst apply = raw => {\n\t\t\tconst v = not ? !raw : raw\n\t\t\tif (name === 'text') el.textContent = S(format ? phlo.store.format(format, v) : v)\n\t\t\telse if (name === 'html') app.mod.inner(el, S(v))\n\t\t\telse if (name === 'value') app.mod.value(el, S(format ? phlo.store.format(format, v) : v))\n\t\t\telse if (name === 'class'){\n\t\t\t\tconst next = Array.isArray(v) ? v : (v && typeof v === 'object') ? Object.keys(v).filter(k => v[k]) : String(v ?? '').split(/\\s+/)\n\t\t\t\tconst uniq = [...new Set(next.filter(Boolean))]\n\t\t\t\tconst prev = [...new Set([...meta.attr.cls, ...owned])]\n\t\t\t\tfor (let i = 0; i < prev.length; i++) el.classList.remove(prev[i])\n\t\t\t\tfor (let i = 0; i < uniq.length; i++) el.classList.add(uniq[i])\n\t\t\t\tmeta.attr.cls = uniq\n\t\t\t}\n\t\t\telse if (BOOL.has(name)){\n\t\t\t\tconst on = !!v\n\t\t\t\tapp.mod.attr(el, { [name]: on ? '' : null })\n\t\t\t\tif (name in el) el[name] = on\n\t\t\t}\n\t\t\telse app.mod.attr(el, { [name]: S(v) })\n\t\t}\n\t\tphlo.store.on(path, apply, el)\n\t\tif (domLeads) phlo.store.set(path, domVal)\n\t\tconst initial = domLeads ? domVal : fromStore\n\t\tapply(initial)\n\t\tif (!isCalc && isInputVal) el.oninput = e => phlo.store.set(path, e.target.value)\n\t})\n})\n\nonExist('[data-each]', el => {\n\tconst path = el.dataset.each\n\tconst templates = [...objects('template', el)]\n\tif (!templates.length) return\n\tconst key = el.dataset.key ? (el.dataset.key.startsWith('.') ? el.dataset.key : '.' + el.dataset.key) : ''\n\tconst pick = el.dataset.eachTemplate ? (el.dataset.eachTemplate.startsWith('.') ? el.dataset.eachTemplate : '.' + el.dataset.eachTemplate) : ''\n\tconst fallback = templates.find(t => t.dataset.template === undefined) || templates[0]\n\tconst template = index => {\n\t\tif (!pick) return fallback\n\t\tconst name = String(phlo.store.get(`${path}[${index}]${pick}`) ?? '')\n\t\treturn templates.find(t => t.dataset.template === name) || fallback\n\t}\n\tconst rows = new Map\n\tconst address = (node, index) => {\n\t\tconst base = `${path}[${index}]`\n\t\tconst bound = [...objects('[data-bind], [data-bind-attr]', node)]\n\t\tif (node.matches('[data-bind], [data-bind-attr]')) bound.push(node)\n\t\tbound.forEach(item => {\n\t\t\tif (item.dataset.bind !== undefined && item.dataset.eachBind === undefined) item.dataset.eachBind = item.dataset.bind\n\t\t\tconst attr = item.getAttribute('data-bind-attr')\n\t\t\tif (attr !== null && item.dataset.eachBindAttr === undefined) item.dataset.eachBindAttr = attr\n\t\t\tif (item.dataset.eachBind !== undefined){\n\t\t\t\tconst not = item.dataset.eachBind.startsWith('!') ? '!' : ''\n\t\t\t\tconst rel = not ? item.dataset.eachBind.slice(1) : item.dataset.eachBind\n\t\t\t\titem.dataset.bind = not + (rel === '.' ? base : base + rel)\n\t\t\t}\n\t\t\tif (item.dataset.eachBindAttr !== undefined) item.setAttribute('data-bind-attr', item.dataset.eachBindAttr.replace(/:\\s*(!?)\\./g, (all, not) => ': ' + not + base + '.'))\n\t\t})\n\t}\n\tconst draw = () => {\n\t\tconst list = phlo.store.get(path)\n\t\tconst items = Array.isArray(list) ? list : (list && typeof list === 'object' ? Object.values(list) : [])\n\t\tlet changed = false\n\t\titems.forEach((item, index) => {\n\t\t\tconst id = key ? String(phlo.store.get(`${path}[${index}]${key}`) ?? index) : String(index)\n\t\t\tconst tpl = template(index)\n\t\t\tconst row = rows.get(index)\n\t\t\tif (row && row.id === id && row.tpl === tpl) return\n\t\t\trow?.node.remove()\n\t\t\tconst node = tpl.content.firstElementChild.cloneNode(true)\n\t\t\taddress(node, index)\n\t\t\trows.set(index, {id, tpl, node})\n\t\t\tel.appendChild(node)\n\t\t\tchanged = true\n\t\t})\n\t\tfor (const [index, row] of [...rows]){\n\t\t\tif (index < items.length) continue\n\t\t\trow.node.remove()\n\t\t\trows.delete(index)\n\t\t\tchanged = true\n\t\t}\n\t\tif (changed) app.update()\n\t}\n\tphlo.store.on(path, draw, el)\n\tdraw()\n})\n\nonExist('[data-store-post]', el => {\n\tconst path = el.dataset.bind || el.dataset.storePath\n\tif (!path) return\n\tphlo.store.sync(path, {ws: el.dataset.storeWs !== undefined, post: el.dataset.storePost, delay: parseInt(el.dataset.storeDelay) || 200})\n})\n\napp.updates.push(() => phlo.store.sweep())\naddEventListener('popstate', () => phlo.store.reset(phlo.store.scope))"
                    }
                ]
            },
            "template": {
                "file": "/srv/control/phlo/resources/DOM/template.phlo",
                "class": "template",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Single Page App client-side templating",
                    "advice": "Add cb's to the templates object and output via apply(template: [$name => $rows, $name2 => $rows2, etc])",
                    "package": "dom",
                    "frontend": "true",
                    "backend": "false",
                    "tags": "dom template spa frontend render"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 10,
                        "body": "app.mod.template = (template, rows) => rows.forEach(row => templates[template](...Object.values(row)))\nconst templates = {}"
                    }
                ]
            },
            "timestamps": {
                "file": "/srv/control/phlo/resources/DOM/timestamps.phlo",
                "class": "timestamps",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "DOM live timestamps",
                    "advice": "Create an app.tsLabels array to overwrite the tsBase labels in any language",
                    "package": "dom",
                    "frontend": "true",
                    "backend": "false",
                    "tags": "dom timestamps time live frontend"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 10,
                        "body": "app.tsBase = {seconds: 60, minutes: 60, hours: 24, days: 7, weeks: 4, months: 13, years: 1}\nconst tsUpdate = () => (ranges = app.tsLabels && (tsValues = Object.values(app.tsBase)) ? Object.fromEntries(app.tsLabels.map((k, i) => [k, tsValues[i]])) : app.tsBase) && objects('[data-ts]').forEach(el => {\n\tlet age = Math.round(Date.now() / 1000) - Number(el.dataset.ts), text = ''\n\tconst future = age < 0\n\tif (future) age = -age\n\tfor (const [range, multiplier] of Object.entries(ranges)){\n\t\tif (text) continue\n\t\tif (age / multiplier < 1.6583) text = `${Math.round(age)} ${range}`\n\t\tage /= multiplier\n\t}\n\ttext ||= `${Math.round(age)} ${Object.keys(ranges).at(-1)}`\n\ttext = `${future ? '-' : ''}${text}`\n\tel.innerText === text || (el.innerText = text)\n})\nsetInterval(() => document.hidden || tsUpdate(), 1000)\nsetTimeout(tsUpdate, 1)"
                    }
                ]
            },
            "toasts": {
                "file": "/srv/control/phlo/resources/DOM/toasts.phlo",
                "class": "toasts",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Simple toast resource",
                    "advice": "app.mod.toast(msg) for a short message that needs no answer, gone after four seconds or on a click. Because it is a command, the server can raise one from a route without a line of frontend code. Anything a visitor must confirm belongs in the dialog resource instead.",
                    "package": "dom",
                    "frontend": "true",
                    "backend": "false",
                    "requires": "@DOM",
                    "tags": "dom toast notification frontend"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 11,
                        "body": "app.mod.toast = msg => {\n\tobj('#toasts') || app.mod.append('body', '<div id=\"toasts\"></div>')\n\tconst toast = document.createElement('div')\n\ttoast.textContent = msg\n\ttoast.onclick = () => toast.remove()\n\tobj('#toasts').insertAdjacentElement('beforeend', toast)\n\tsetTimeout(() => toast.remove(), 4000)\n}"
                    },
                    {
                        "node": "style",
                        "ns": null,
                        "line": 22,
                        "body": "#toasts {\n\tposition: fixed\n\tright: 10px\n\ttop: 5px\n\tz-index: 1001\n\t> * {\n\t\tbackground-color: #000A\n\t\tborder-radius: 10px\n\t\tclear: both\n\t\tcolor: white\n\t\tcursor: zoom-out\n\t\tfloat: right\n\t\tmargin-top: 5px\n\t\tpadding: 3px 6px\n\t}\n}"
                    }
                ]
            },
            "visible": {
                "file": "/srv/control/phlo/resources/DOM/visible.phlo",
                "class": "visible",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "onVisible and onVisibleIn helpers for DOM visibility",
                    "advice": "Reacts to an element entering or leaving the viewport at ten percent visible, which is what you want for lazy loading, counting a view or starting an animation at the right moment. Give only cbOut and it fires once and stops watching, so a one-off costs nothing afterwards. onVisibleIn watches inside a scrolling container rather than the window.",
                    "package": "dom",
                    "frontend": "true",
                    "backend": "false",
                    "requires": "@DOM",
                    "tags": "dom visible intersection observer frontend"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 11,
                        "body": "phlo.observe = []\nphlo.observing = new WeakMap\n\nconst onVisible = (els, cbIn, cbOut) => onVisibleIn(els, null, cbIn, cbOut)\nconst onVisibleIn = (els, root, cbIn, cbOut) => phlo.observe.push({els, root, cbIn, cbOut})\n\napp.updates.push(() => {\n\tconst observers = []\n\tphlo.observe.forEach(item => objects(item.els).forEach(el => phlo.observing.has(el) || observers.push({el, root: item.root, cbIn: item.cbIn, cbOut: item.cbOut})))\n\tobservers.forEach(item => [phlo.observing.has(item.el) || phlo.observing.set(item.el, 'observe'), (observer = new IntersectionObserver(entries => entries.forEach(entry => entry.isIntersecting ? !item.cbIn && item.cbOut ? [observer.unobserve(entry.target), item.cbOut(entry.target)] : item.cbIn(entry.target) : item.cbIn && item.cbOut && item.cbOut(entry.target)), {root: obj(item.root), threshold: .1})).observe(item.el)])\n})"
                    }
                ]
            },
            "websocket": {
                "file": "/srv/control/phlo/resources/DOM/websocket.phlo",
                "class": "websocket",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Client-side WebSocket handler",
                    "advice": "Opens the connection and keeps it open, reconnecting on its own, and treats what arrives as commands, so the server can update a page with the same instructions a route uses. It survives a page swap, which a hand-bound on() does not. A token belongs in a cookie rather than in the URL, since a URL ends up in logs.",
                    "package": "realtime",
                    "frontend": "true",
                    "backend": "false",
                    "requires": "@DOM",
                    "tags": "websocket realtime frontend dom"
                },
                "nodes": [],
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 11,
                        "body": "app.websocket = {\n\tget open(){\n\t\tapp.options.contains('wss') && delay('websocket', app.websocket.retry, () => {\n\t\t\tphlo.wss?.close()\n\t\t\tphlo.wss = new WebSocket(`wss://${location.host}/${app.websocket.path}`)\n\t\t\tphlo.wss.onmessage = e => [{trans, state, ...cmds} = JSON.parse(e.data), apply(cmds, trans, state)]\n\t\t\tphlo.wss.onopen = e => [app.websocket.emit('connect', e), phlo.log('🖧 Websocket connected', e), app.websocket.retry = 333]\n\t\t\tphlo.wss.onerror = e => [app.websocket.emit('error', e), phlo.log('🖧 Websocket error', e)]\n\t\t\tphlo.wss.onclose = e => [app.websocket.emit('close', e), phlo.log('🖧 Websocket close', e), app.websocket.retry && [app.websocket.open, app.websocket.retry *= 3]]\n\t\t})\n\t},\n\tpath: 'websocket',\n\tget ready(){ return phlo.wss?.readyState === 1 },\n\tsubs: {},\n\ton(event, cb, el = null){\n\t\t(app.websocket.subs[event] ??= []).push({cb, el})\n\t\tevent === 'connect' && app.websocket.ready && cb()\n\t\treturn () => app.websocket.off(event, cb)\n\t},\n\toff(event, cb){ app.websocket.subs[event] = (app.websocket.subs[event] || []).filter(sub => sub.cb !== cb) },\n\temit(event, e){\n\t\tconst single = app.websocket[event]\n\t\tsingle && single(e)\n\t\tapp.websocket.subs[event] = (app.websocket.subs[event] || []).filter(sub => !sub.el || sub.el.isConnected)\n\t\tapp.websocket.subs[event].forEach(sub => {\n\t\t\ttry { sub.cb(e) }\n\t\t\tcatch(err){ phlo.log('🖧 Websocket ' + event, err) }\n\t\t})\n\t},\n\tsend: data => phlo.wss?.readyState === 1 ? [phlo.log('🖧 app.websocket.send', '\\n', data), phlo.wss.send(JSON.stringify(data))] : phlo.error('🖧 Could not send websocket data over closed socket'),\n\tretry: 333,\n}\napp.websocket.open"
                    }
                ]
            }
        }
    },
    "fields": {
        "objs": {
            "field": {
                "file": "/srv/control/phlo/resources/fields/field.phlo",
                "class": "field",
                "meta": {
                    "type": "abstract class",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Base ORM field",
                    "advice": "Every field type extends this one and is made with field(type: 'x'), which resolves to the resource field_x; anything else you pass becomes a property, so title, required, length, pattern and enum need no declaration. Override input() for the form and label() for the list, and let objColumns name the columns the field owns, so a field that stores nothing returns an empty array.",
                    "package": "fields",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "field orm"
                },
                "nodes": {
                    "__handle": {
                        "node": "static",
                        "visibility": null,
                        "name": "__handle",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "null",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "title": {
                        "node": "prop",
                        "visibility": null,
                        "name": "title",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "ucfirst($this->name)",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "input": {
                        "node": "method",
                        "visibility": null,
                        "name": "input",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "input(type: $this->type, name: $this->name, value: $record->{$this->name} ?? $this->default, maxlength: $this->length, placeholder: $this->placeholder, class: 'field')",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "label": {
                        "node": "method",
                        "visibility": null,
                        "name": "label",
                        "args": "$record",
                        "type": null,
                        "operator": "arrow",
                        "body": "$record->{$this->name};",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "objColumns": {
                        "node": "prop",
                        "visibility": null,
                        "name": "objColumns",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[]",
                        "line": 20,
                        "bodyLine": 20
                    },
                    "objValidate": {
                        "node": "method",
                        "visibility": null,
                        "name": "objValidate",
                        "args": "$value",
                        "type": "?string",
                        "operator": "method",
                        "body": "\tif (($value === null || $value === void) && $this->required) return $this->title.' is required'\n\tif ($value === null || $value === void) return null\n\tif ($this->length && is_string($value) && mb_strlen($value) > $this->length) return $this->title.' is too long (max '.$this->length.')'\n\tif ($this->pattern && is_string($value) && !preg_match('/'.str_replace('/', '\\\\/', $this->pattern).'/', $value)) return $this->title.' has invalid format'\n\tif ($this->enum && !in_array($value, (array)$this->enum, true)) return $this->title.' must be one of: '.implode(', ', (array)$this->enum)\n\treturn null",
                        "line": 22,
                        "bodyLine": 23
                    }
                },
                "functions": {
                    "field": {
                        "node": "function",
                        "name": "field",
                        "args": "$type, ...$args",
                        "type": "field",
                        "operator": "arrow",
                        "body": "phlo(\"field_$type\", ...$args, type: $type)",
                        "line": 11,
                        "bodyLine": 11
                    }
                },
                "assets": []
            },
            "field_bool": {
                "file": "/srv/control/phlo/resources/fields/bool.phlo",
                "class": "field_bool",
                "meta": {
                    "extends": "field",
                    "class": "field_bool",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Boolean field",
                    "advice": "Stores 1 or 0 and never null, so a checkbox that is left alone still saves a value. Set true and false to change the two symbols a list shows.",
                    "package": "fields",
                    "frontend": "true",
                    "backend": "true",
                    "tags": "field boolean input"
                },
                "nodes": {
                    "true": {
                        "node": "prop",
                        "visibility": null,
                        "name": "true",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'✅'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "false": {
                        "node": "prop",
                        "visibility": null,
                        "name": "false",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'❌'",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "label": {
                        "node": "method",
                        "visibility": null,
                        "name": "label",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "$record->{$this->name} ? $this->true : $this->false",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "input": {
                        "node": "method",
                        "visibility": null,
                        "name": "input",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "tag('label', inner: input(type: 'checkbox', name: $this->name, value: 1, checked: $record->{$this->name} ? true : null).tag('span', class: 'slider', inner: void))",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "parse": {
                        "node": "method",
                        "visibility": null,
                        "name": "parse",
                        "args": "$record",
                        "type": "int",
                        "operator": "arrow",
                        "body": "$record->{$this->name} = %payload->{$this->name} ? 1 : 0",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "nullable": {
                        "node": "method",
                        "visibility": null,
                        "name": "nullable",
                        "args": null,
                        "type": "bool",
                        "operator": "arrow",
                        "body": "false",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "objColumns": {
                        "node": "prop",
                        "visibility": null,
                        "name": "objColumns",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[$this->name]",
                        "line": 20,
                        "bodyLine": 20
                    }
                },
                "functions": [],
                "assets": []
            },
            "field_child": {
                "file": "/srv/control/phlo/resources/fields/child.phlo",
                "class": "field_child",
                "meta": {
                    "extends": "field",
                    "class": "field_child",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Child relation field",
                    "advice": "The mirror of a parent field elsewhere: it reads the records that point back at this one and owns no column itself. It cannot be edited here, only followed, so change a child from its own record. Set key when the foreign key is not named after the parent model.",
                    "package": "fields",
                    "frontend": "true",
                    "backend": "true",
                    "tags": "field relation child"
                },
                "nodes": {
                    "list": {
                        "node": "prop",
                        "visibility": null,
                        "name": "list",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'count'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "change": {
                        "node": "prop",
                        "visibility": null,
                        "name": "change",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "false",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "create": {
                        "node": "prop",
                        "visibility": null,
                        "name": "create",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "false",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "record": {
                        "node": "prop",
                        "visibility": null,
                        "name": "record",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'list'",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "count": {
                        "node": "method",
                        "visibility": null,
                        "name": "count",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "tag('a', href: slash.($record::$uriRecord ?? $record::class).slash.$record->{$record::idColumn()}.slash.$this->name, class: 'async', inner: $record->getCount($this->name).space.$this->title)",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "last": {
                        "node": "method",
                        "visibility": null,
                        "name": "last",
                        "args": "$record",
                        "type": null,
                        "operator": "arrow",
                        "body": "$record->getLast($this->name)",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "label": {
                        "node": "method",
                        "visibility": null,
                        "name": "label",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "implode(loop($record->{$this->name}, fn($child) => $this->link($child))) ?: dash",
                        "line": 19,
                        "bodyLine": 19
                    },
                    "input": {
                        "node": "method",
                        "visibility": null,
                        "name": "input",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "$this->label($record)",
                        "line": 20,
                        "bodyLine": 20
                    },
                    "link": {
                        "node": "method",
                        "visibility": null,
                        "name": "link",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "tag('a', href: slash.($record::$uriRecord ?? $record::class).slash.$record->{$record::idColumn()}, class: 'async', inner: $record)",
                        "line": 21,
                        "bodyLine": 21
                    },
                    "objKey": {
                        "node": "method",
                        "visibility": null,
                        "name": "objKey",
                        "args": "$parentModel",
                        "type": "string",
                        "operator": "arrow",
                        "body": "$this->key ?? $parentModel",
                        "line": 23,
                        "bodyLine": 23
                    },
                    "objOwns": {
                        "node": "method",
                        "visibility": null,
                        "name": "objOwns",
                        "args": "$record, $parentId, $parentModel",
                        "type": "bool",
                        "operator": "method",
                        "body": "\t$value = $record->{$this->objKey($parentModel)};\n\tis_object($value) && $value = $value->{$value::idColumn()};\n\treturn (string)$value === (string)$parentId",
                        "line": 25,
                        "comments": "Does $record belong to parent $parentId via this relation? (direct foreign key).",
                        "bodyLine": 26
                    }
                },
                "functions": [],
                "assets": []
            },
            "field_date": {
                "file": "/srv/control/phlo/resources/fields/date.phlo",
                "class": "field_date",
                "meta": {
                    "extends": "field",
                    "class": "field_date",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Date field",
                    "advice": "Renders in the reader's language through IntlDateFormatter, so a Dutch visitor sees a Dutch date without any work. Without the intl extension it falls back to a built-in Dutch and English month list, so any other language reads as English there. Set format to a date() pattern when the notation has to be fixed rather than local.",
                    "package": "fields",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "field date"
                },
                "nodes": {
                    "handle": {
                        "node": "prop",
                        "visibility": null,
                        "name": "handle",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "true",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "format": {
                        "node": "prop",
                        "visibility": null,
                        "name": "format",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "null",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "months": {
                        "node": "prop",
                        "visibility": null,
                        "name": "months",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[\n\t'nl' => [1 => 'januari', 2 => 'februari', 3 => 'maart', 4 => 'april', 5 => 'mei', 6 => 'juni', 7 => 'juli', 8 => 'augustus', 9 => 'september', 10 => 'oktober', 11 => 'november', 12 => 'december'],\n\t'en' => [1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April', 5 => 'May', 6 => 'June', 7 => 'July', 8 => 'August', 9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December'],\n]",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "label": {
                        "node": "method",
                        "visibility": null,
                        "name": "label",
                        "args": "$record",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$v = $record->{$this->name};\n\tif ($v === null || $v === void) return dash\n\t$ts = is_numeric($v) ? (int)$v : strtotime($v)\n\tif (!$ts) return esc($v)\n\tif ($this->format) return date($this->format, $ts)\n\t$lang = substr((string)(%app->lang ?? 'en'), 0, 2)\n\tif (class_exists('IntlDateFormatter')) return (new \\IntlDateFormatter($lang, \\IntlDateFormatter::LONG, \\IntlDateFormatter::NONE))->format($ts)\n\treturn date('j', $ts).space.(($this->months[$lang] ?? $this->months['en'])[(int)date('n', $ts)]).space.date('Y', $ts)",
                        "line": 19,
                        "bodyLine": 20
                    },
                    "objColumns": {
                        "node": "prop",
                        "visibility": null,
                        "name": "objColumns",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[$this->name]",
                        "line": 30,
                        "bodyLine": 30
                    }
                },
                "functions": [],
                "assets": []
            },
            "field_datetime": {
                "file": "/srv/control/phlo/resources/fields/datetime.phlo",
                "class": "field_datetime",
                "meta": {
                    "extends": "field",
                    "class": "field_datetime",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Date-time field",
                    "advice": "Stores a unix timestamp rather than a formatted string, and shows an age icon that runs blue under an hour, yellow under a day and red beyond. Fields called created and changed are kept out of the form because the model writes those itself.",
                    "package": "fields",
                    "frontend": "true",
                    "backend": "true",
                    "tags": "field datetime"
                },
                "nodes": {
                    "handle": {
                        "node": "prop",
                        "visibility": null,
                        "name": "handle",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "true",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "change": {
                        "node": "prop",
                        "visibility": null,
                        "name": "change",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "!in_array($this->name, ['created', 'changed'])",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "create": {
                        "node": "prop",
                        "visibility": null,
                        "name": "create",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "!in_array($this->name, ['created', 'changed'])",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "label": {
                        "node": "method",
                        "visibility": null,
                        "name": "label",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "($value = $record->{$this->name}) ? tag('i', class: 'icon clock-'.$this->labelIconClass(time() - $value), inner: void).tag('span', data_ts: $record->{$this->name}, inner: time_human($record->{$this->name})) : dash",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "labelIconClass": {
                        "node": "method",
                        "visibility": null,
                        "name": "labelIconClass",
                        "args": "$value",
                        "type": "string",
                        "operator": "arrow",
                        "body": "$value > 86400 ? 'red' : ($value > 3600 ? 'yellow' : 'blue')",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "input": {
                        "node": "method",
                        "visibility": null,
                        "name": "input",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "input(type: 'datetime-local', name: $this->name, value: $record->{$this->name} ? date('Y-m-d\\TH:i', $record->{$this->name}) : void, class: 'field')",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "parse": {
                        "node": "method",
                        "visibility": null,
                        "name": "parse",
                        "args": "$record",
                        "type": "void",
                        "operator": "method",
                        "body": "\tif ($this->name === 'created') $record->created ??= time()\n\telseif ($this->name === 'changed') $record->{$this->name} = time()\n\telseif ($payload = %payload->{$this->name}) $record->{$this->name} = strtotime($payload)",
                        "line": 19,
                        "bodyLine": 20
                    },
                    "objColumns": {
                        "node": "prop",
                        "visibility": null,
                        "name": "objColumns",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[$this->name]",
                        "line": 25,
                        "bodyLine": 25
                    }
                },
                "functions": [],
                "assets": []
            },
            "field_email": {
                "file": "/srv/control/phlo/resources/fields/email.phlo",
                "class": "field_email",
                "meta": {
                    "extends": "field_text",
                    "class": "field_email",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Email field",
                    "advice": "Only changes how a stored address is shown, as a mailto link. It validates nothing, so add pattern or required when the address has to be real.",
                    "package": "fields",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "field email"
                },
                "nodes": {
                    "label": {
                        "node": "view",
                        "visibility": null,
                        "name": "label",
                        "args": "$record",
                        "type": null,
                        "operator": "view",
                        "body": "<a href=\"mailto:{{ $record->{$this->name} }}\">{{ $record->{$this->name} }}</a>",
                        "line": 13
                    }
                },
                "functions": [],
                "assets": []
            },
            "field_file": {
                "file": "/srv/control/phlo/resources/fields/file.phlo",
                "class": "field_file",
                "meta": {
                    "extends": "field",
                    "class": "field_file",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "File field",
                    "advice": "An upload is stored under a random token instead of its own name, so two people can send the same filename and no visitor can guess a neighbouring URL. That takes two columns, name and name_token, which objColumns already claims. Set accept to narrow the picker, and path and uri when the files live somewhere other than the default.",
                    "package": "fields",
                    "frontend": "true",
                    "backend": "true",
                    "tags": "field file upload"
                },
                "nodes": {
                    "canDelete": {
                        "node": "prop",
                        "visibility": null,
                        "name": "canDelete",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "true",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "delete": {
                        "node": "prop",
                        "visibility": null,
                        "name": "delete",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'Delete file?'",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "path": {
                        "node": "prop",
                        "visibility": null,
                        "name": "path",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "files",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "uri": {
                        "node": "prop",
                        "visibility": null,
                        "name": "uri",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'/files/'",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "length": {
                        "node": "prop",
                        "visibility": null,
                        "name": "length",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "100",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "accept": {
                        "node": "prop",
                        "visibility": null,
                        "name": "accept",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "null",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "label": {
                        "node": "method",
                        "visibility": null,
                        "name": "label",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "($filename = $record->{$this->name}) ? tag('a', href: $this->uri.$record->{$this->name.'_token'}.slash.rawurlencode($filename), target: 'file', inner: tag('i', class: 'icon '.pathinfo($filename, PATHINFO_EXTENSION), inner: void).esc($filename)) : dash",
                        "line": 19,
                        "bodyLine": 19
                    },
                    "input": {
                        "node": "method",
                        "visibility": null,
                        "name": "input",
                        "args": "$record",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$input = void\n\t$file = ($filename = $record->{$this->name}) ? $this->read($record) : null\n\t$input .= lf.tab.tag('div', class: 'file', inner: $file ? tag('i', class: \"icon $file->ext\", inner: void).esc($filename) : '-select-')\n\t$input .= lf.tab.input(type: 'file', class: 'file-input', name: $this->name, accept: $this->accept)\n\t$file && $this->canDelete && $input .= lf.tab.tag('div', inner: tag('label', inner: input(type: 'checkbox', name: $this->name.'Delete').\" $this->delete\"))\n\treturn $input.lf",
                        "line": 20,
                        "bodyLine": 21
                    },
                    "parse": {
                        "node": "method",
                        "visibility": null,
                        "name": "parse",
                        "args": "$record",
                        "type": "void",
                        "operator": "method",
                        "body": "\t$file = %payload->{$this->name};\n\tif ($delete = %payload->{$this->name.'Delete'}) unset(%payload->{$this->name.'Delete'})\n\tif ($record->{$this->name} && $delete && $this->canDelete) $record->{$this->name} = $record->{$this->name.'_token'} = null\n\tif (is_a($file, 'file')){\n\t\t$token = $file->token\n\t\tif (!$this->write($file)) return\n\t\t$record->{$this->name} = $file->shortenTo($this->length)\n\t\t$record->{$this->name.'_token'} = $token\n\t}",
                        "line": 29,
                        "bodyLine": 30
                    },
                    "read": {
                        "node": "method",
                        "visibility": null,
                        "name": "read",
                        "args": "$record, $path = null",
                        "type": "file",
                        "operator": "method",
                        "body": "\t$filename = $record->{$this->name};\n\t$token = $record->{$this->name.'_token'};\n\treturn %file(($path ?? $this->path).($info = substr($token, 0, 2).slash.substr($token, 2).dot.pathinfo($filename, PATHINFO_EXTENSION)), name: $filename, info: $info)",
                        "line": 41,
                        "bodyLine": 42
                    },
                    "write": {
                        "node": "method",
                        "visibility": null,
                        "name": "write",
                        "args": "$file",
                        "type": null,
                        "operator": "arrow",
                        "body": "file_exists($dest = $this->writePath($file)) || $file->move($dest) || error(\"Couldn't write: $dest\")",
                        "line": 47,
                        "bodyLine": 47
                    },
                    "writePath": {
                        "node": "method",
                        "visibility": null,
                        "name": "writePath",
                        "args": "$file, $path = null",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$token = $file->token\n\t$path = ($path ?? $this->path).substr($token, 0, 2)\n\tis_dir($path) || mkdir($path) || error(\"Couldn't create: $path\")\n\treturn $path.slash.substr($token, 2).dot.$file->ext",
                        "line": 48,
                        "bodyLine": 49
                    },
                    "objColumns": {
                        "node": "prop",
                        "visibility": null,
                        "name": "objColumns",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[$this->name, $this->name.'_token']",
                        "line": 55,
                        "bodyLine": 55
                    }
                },
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 57,
                        "body": "on('click', '.input.file .file', file => file.nextElementSibling.click())\non('change', '.input.file .file-input', input => input.previousElementSibling.innerText = input.files[0].name)"
                    }
                ]
            },
            "field_image": {
                "file": "/srv/control/phlo/resources/fields/image.phlo",
                "class": "field_image",
                "meta": {
                    "extends": "field_file",
                    "class": "field_image",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Image field",
                    "advice": "A file field that also writes a thumbnail, and the browser scales the picture down before it is sent, so a phone photo does not travel at full size. thumbSize is the thumbnail edge in pixels; a list shows the thumbnail and a record shows the full image.",
                    "package": "fields",
                    "frontend": "true",
                    "backend": "true",
                    "tags": "field image upload"
                },
                "nodes": {
                    "delete": {
                        "node": "prop",
                        "visibility": null,
                        "name": "delete",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'Delete image?'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "uri": {
                        "node": "prop",
                        "visibility": null,
                        "name": "uri",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'/images/'",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "path": {
                        "node": "prop",
                        "visibility": null,
                        "name": "path",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "images",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "thumbPath": {
                        "node": "prop",
                        "visibility": null,
                        "name": "thumbPath",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "thumbs",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "thumbSize": {
                        "node": "prop",
                        "visibility": null,
                        "name": "thumbSize",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "64",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "thumbUri": {
                        "node": "prop",
                        "visibility": null,
                        "name": "thumbUri",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'/thumbs/'",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "placeholder": {
                        "node": "prop",
                        "visibility": null,
                        "name": "placeholder",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiM4ODgiIHN0cm9rZS13aWR0aD0iMS40Ij48cmVjdCB4PSIzIiB5PSIzIiB3aWR0aD0iMTgiIGhlaWdodD0iMTgiIHJ4PSIyIi8+PGNpcmNsZSBjeD0iOC41IiBjeT0iOC41IiByPSIxLjYiLz48cGF0aCBkPSJNMjEgMTVsLTUtNUw1IDIxIi8+PC9zdmc+'",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "record": {
                        "node": "prop",
                        "visibility": null,
                        "name": "record",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'preview'",
                        "line": 20,
                        "bodyLine": 20
                    },
                    "label": {
                        "node": "method",
                        "visibility": null,
                        "name": "label",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "($filename = $record->{$this->name}) ? tag('a', href: $this->uri.($url = $record->{$this->name.'_token'}.slash.rawurlencode($filename)), target: 'image', inner: tag('img', src: $this->thumbUri.$url)) : dash",
                        "line": 22,
                        "bodyLine": 22
                    },
                    "preview": {
                        "node": "method",
                        "visibility": null,
                        "name": "preview",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "($filename = $record->{$this->name}) ? tag('a', href: $this->uri.($url = $record->{$this->name.'_token'}.slash.rawurlencode($filename)), target: 'image', inner: tag('img', src: $this->uri.$url, class: 'preview')) : dash",
                        "line": 23,
                        "bodyLine": 23
                    },
                    "input": {
                        "node": "method",
                        "visibility": null,
                        "name": "input",
                        "args": "$record",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$input = void\n\t$file = $record->{$this->name} ? $this->read($record, $this->thumbPath) : null\n\t$input .= lf.tab.tag('img', src: $file ? $this->thumbUri.$record->{$this->name.'_token'}.slash.rawurlencode($record->{$this->name}) : $this->placeholder, class: 'image')\n\t$input .= lf.tab.input(type: 'file', class: 'image-input',  name: $this->name, accept: 'image/*', data_size: $this->thumbSize)\n\t$file && $this->canDelete && $input .= lf.tab.tag('div', inner: tag('label', inner: input(type: 'checkbox', name: $this->name.'Delete').\" $this->delete\"))\n\treturn $input.lf",
                        "line": 24,
                        "bodyLine": 25
                    },
                    "write": {
                        "node": "method",
                        "visibility": null,
                        "name": "write",
                        "args": "$file",
                        "type": null,
                        "operator": "arrow",
                        "body": "%img($file->file)->scale($this->thumbSize, $this->thumbSize)->save($this->writePath($file, $this->thumbPath)) && $file->move($this->writePath($file))",
                        "line": 33,
                        "bodyLine": 33
                    },
                    "objColumns": {
                        "node": "prop",
                        "visibility": null,
                        "name": "objColumns",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[$this->name, $this->name.'_token']",
                        "line": 35,
                        "bodyLine": 35
                    }
                },
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 37,
                        "body": "on('click', '.input.image .image', img => img.nextElementSibling.click())\non('change', '.input.image .image-input', input => imageResizer(input.files[0], input.dataset.size, input.dataset.size, image => input.previousElementSibling.src = image))"
                    }
                ]
            },
            "field_many": {
                "file": "/srv/control/phlo/resources/fields/many.phlo",
                "class": "field_many",
                "meta": {
                    "extends": "field",
                    "class": "field_many",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Many-to-many relation field",
                    "advice": "Editing is opt-in: a model that sets create/change on a many field gets the checkbox picker from input(); CMS.API::syncMany() then rewrites the pivot table on save.",
                    "package": "fields",
                    "frontend": "true",
                    "backend": "true",
                    "tags": "field relation many"
                },
                "nodes": {
                    "list": {
                        "node": "prop",
                        "visibility": null,
                        "name": "list",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'label'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "record": {
                        "node": "prop",
                        "visibility": null,
                        "name": "record",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'label'",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "create": {
                        "node": "prop",
                        "visibility": null,
                        "name": "create",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "false",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "change": {
                        "node": "prop",
                        "visibility": null,
                        "name": "change",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "false",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "count": {
                        "node": "method",
                        "visibility": null,
                        "name": "count",
                        "args": "$record",
                        "type": "int",
                        "operator": "arrow",
                        "body": "$record->getCount($this->name)",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "label": {
                        "node": "method",
                        "visibility": null,
                        "name": "label",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "loop($record->{$this->name}, fn($relation) => $this->link($relation), lf) ?: dash",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "link": {
                        "node": "method",
                        "visibility": null,
                        "name": "link",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "tag('a', href: slash.($record::$uriRecord ?? $record::class).slash.$record->{$record::idColumn()}, class: 'async', inner: $record)",
                        "line": 19,
                        "bodyLine": 19
                    },
                    "input": {
                        "node": "method",
                        "visibility": null,
                        "name": "input",
                        "args": "$record",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$pk = $this->obj::idColumn()\n\t$current = array_keys($record->{$this->name} ?: [])\n\t$boxes = loop($this->obj::records(), fn($o) => tag('label', tag('input', type: 'checkbox', name: $this->name.'[]', value: $o->$pk, checked: in_array($o->$pk, $current) ? true : null).esc((string)$o), class: 'ms-opt'), void)\n\treturn tag('div', $boxes ?: tag('span', 'no options', class: 'muted'), class: 'ms-grid')",
                        "line": 21,
                        "bodyLine": 22
                    },
                    "sync": {
                        "node": "method",
                        "visibility": null,
                        "name": "sync",
                        "args": "$model, $parentId",
                        "type": null,
                        "operator": "method",
                        "body": "\t$rel = $model::objMany()[$this->name]\n\t$ids = array_values(array_unique(array_filter((array)(%payload->{$this->name} ?? []), fn($id) => (string)$id !== '')))\n\t$db = $model::DB()\n\t$db->delete($rel['table'], $rel['localKey'].'=?', $parentId)\n\tforeach ($ids AS $id) $db->create($rel['table'], ...[$rel['localKey'] => $parentId, $rel['foreignKey'] => $id])",
                        "line": 28,
                        "bodyLine": 29
                    },
                    "objOwns": {
                        "node": "method",
                        "visibility": null,
                        "name": "objOwns",
                        "args": "$record, $parentId, $parentModel",
                        "type": "bool",
                        "operator": "method",
                        "body": "\t$rel = $parentModel::objMany()[$this->name]\n\treturn (bool)$parentModel::DB()->record($rel['table'], ...[$rel['localKey'] => $parentId, $rel['foreignKey'] => $record->{$record::idColumn()}])",
                        "line": 36,
                        "bodyLine": 37
                    },
                    "objColumns": {
                        "node": "prop",
                        "visibility": null,
                        "name": "objColumns",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[]",
                        "line": 41,
                        "bodyLine": 41
                    }
                },
                "functions": [],
                "assets": []
            },
            "field_multiselect": {
                "file": "/srv/control/phlo/resources/fields/multiselect.phlo",
                "class": "field_multiselect",
                "meta": {
                    "extends": "field",
                    "class": "field_multiselect",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Multi-select via checkboxes; stores the choice as CSV in one hidden field (no save change needed).",
                    "advice": "Joins the checked boxes into one comma separated column, so it needs no pivot table and no extra save logic. Reach for many instead when the choices are records with a life of their own.",
                    "package": "fields",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "field select multi"
                },
                "nodes": {
                    "label": {
                        "node": "method",
                        "visibility": null,
                        "name": "label",
                        "args": "$record",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$values = array_filter(array_map('trim', explode(comma, (string)($record->{$this->name} ?? void))))\n\treturn $values ? loop($values, fn($v) => tag('span', esc($v), class: 'ms-tag'), space) : dash",
                        "line": 12,
                        "bodyLine": 13
                    },
                    "input": {
                        "node": "method",
                        "visibility": null,
                        "name": "input",
                        "args": "$record",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$current = array_filter(array_map('trim', explode(comma, (string)($record->{$this->name} ?? void))))\n\t$js = \"var f=this.closest('.field');f.querySelector('input.msv').value=[...f.querySelectorAll('input.msc:checked')].map(c=>c.value).join(',')\"\n\t$boxes = loop((array)$this->options, fn($o) => tag('label', tag('input', type: 'checkbox', value: (string)$o, class: 'msc', checked: in_array((string)$o, $current) ? true : null, onchange: $js).esc((string)$o), class: 'ms-opt'), void)\n\treturn tag('input', type: 'hidden', name: $this->name, value: implode(comma, $current), class: 'msv').tag('div', $boxes ?: tag('span', 'no options', class: 'muted'), class: 'ms-grid')",
                        "line": 17,
                        "bodyLine": 18
                    },
                    "objColumns": {
                        "node": "prop",
                        "visibility": null,
                        "name": "objColumns",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[$this->name]",
                        "line": 24,
                        "bodyLine": 24
                    }
                },
                "functions": [],
                "assets": []
            },
            "field_number": {
                "file": "/srv/control/phlo/resources/fields/number.phlo",
                "class": "field_number",
                "meta": {
                    "extends": "field",
                    "class": "field_number",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Number field",
                    "advice": "decimals sets both the step of the input and the formatting of the list. min is 0 out of the box, so state it yourself when negative values are allowed.",
                    "package": "fields",
                    "frontend": "true",
                    "backend": "true",
                    "tags": "field number"
                },
                "nodes": {
                    "decimals": {
                        "node": "prop",
                        "visibility": null,
                        "name": "decimals",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "0",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "length": {
                        "node": "prop",
                        "visibility": null,
                        "name": "length",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "5",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "min": {
                        "node": "prop",
                        "visibility": null,
                        "name": "min",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "0",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "label": {
                        "node": "method",
                        "visibility": null,
                        "name": "label",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "($value = $record->{$this->name}) === null || $value === void ? dash : number_format($value, $this->decimals, comma, dot)",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "input": {
                        "node": "method",
                        "visibility": null,
                        "name": "input",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "input(type: 'number', name: $this->name, value: $record->{$this->name} ?? $this->default, step: $this->decimals ? dot.str_repeat('0', $this->decimals - 1).'1' : null, min: $this->min, class: 'field')",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "parse": {
                        "node": "method",
                        "visibility": null,
                        "name": "parse",
                        "args": "$record",
                        "type": "void",
                        "operator": "method",
                        "body": "\t$name = $this->name\n\tif (!%payload->hasData($name)) return\n\t$value = %payload->$name\n\t$record->$name = ($value === null || (is_string($value) && trim($value) === void)) ? ($this->default ?? 0) : $value",
                        "line": 19,
                        "bodyLine": 20
                    },
                    "objColumns": {
                        "node": "prop",
                        "visibility": null,
                        "name": "objColumns",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[$this->name]",
                        "line": 26,
                        "bodyLine": 26
                    }
                },
                "functions": [],
                "assets": []
            },
            "field_parent": {
                "file": "/srv/control/phlo/resources/fields/parent.phlo",
                "class": "field_parent",
                "meta": {
                    "extends": "field",
                    "class": "field_parent",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Parent relation field",
                    "advice": "Points at another model through obj and offers every record of it in one select, so keep it to sets a person can still scroll. Reading the field gives you the record itself, not the id.",
                    "package": "fields",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "field relation parent"
                },
                "nodes": {
                    "label": {
                        "node": "method",
                        "visibility": null,
                        "name": "label",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "is_a($obj = $record->{$this->name}, 'model') ? $this->link($obj) : dash",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "input": {
                        "node": "method",
                        "visibility": null,
                        "name": "input",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "select(name: $this->name, inner: loop($this->options, fn($parent) => '<option'.($parent->id === $record->{$this->name}?->id ? ' selected' : void).' value=\"'.$parent->id.'\">'.$parent, void))",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "link": {
                        "node": "method",
                        "visibility": null,
                        "name": "link",
                        "args": "$record, $content = null",
                        "type": "string",
                        "operator": "arrow",
                        "body": "tag('a', href: slash.($record::$uriRecord ?? $record::class).slash.$record->id, class: 'async', inner: $content ?? $record)",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "options": {
                        "node": "prop",
                        "visibility": null,
                        "name": "options",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "$this->obj::records()",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "objColumns": {
                        "node": "prop",
                        "visibility": null,
                        "name": "objColumns",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[$this->name]",
                        "line": 17,
                        "bodyLine": 17
                    }
                },
                "functions": [],
                "assets": []
            },
            "field_password": {
                "file": "/srv/control/phlo/resources/fields/password.phlo",
                "class": "field_password",
                "meta": {
                    "extends": "field",
                    "class": "field_password",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Password field",
                    "advice": "Never gives the stored value back: a list prints dots and an empty form field leaves the current password untouched. It hashes with bcrypt on save, so keep the plain value nowhere.",
                    "package": "fields",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "field password"
                },
                "nodes": {
                    "list": {
                        "node": "prop",
                        "visibility": null,
                        "name": "list",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "false",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "required": {
                        "node": "prop",
                        "visibility": null,
                        "name": "required",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "true",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "minlength": {
                        "node": "prop",
                        "visibility": null,
                        "name": "minlength",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "8",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "placeholder": {
                        "node": "prop",
                        "visibility": null,
                        "name": "placeholder",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'New password'",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "input": {
                        "node": "method",
                        "visibility": null,
                        "name": "input",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "input(type: $this->type, name: $this->name, placeholder: $this->placeholder, class: 'field')",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "label": {
                        "node": "method",
                        "visibility": null,
                        "name": "label",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "'••••••••'",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "parse": {
                        "node": "method",
                        "visibility": null,
                        "name": "parse",
                        "args": "$record",
                        "type": null,
                        "operator": "arrow",
                        "body": "($password = trim((string)%payload->{$this->name})) && $record->{$this->name} = password_hash($password, PASSWORD_BCRYPT)",
                        "line": 19,
                        "bodyLine": 19
                    },
                    "objColumns": {
                        "node": "prop",
                        "visibility": null,
                        "name": "objColumns",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[$this->name]",
                        "line": 21,
                        "bodyLine": 21
                    }
                },
                "functions": [],
                "assets": []
            },
            "field_price": {
                "file": "/srv/control/phlo/resources/fields/price.phlo",
                "class": "field_price",
                "meta": {
                    "class": "field_price",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Price field",
                    "advice": "A number field fixed at two decimals. Store the amount in the unit you bill in and leave the formatting to the field.",
                    "package": "fields",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "field price money",
                    "extends": "field_number"
                },
                "nodes": {
                    "decimals": {
                        "node": "prop",
                        "visibility": null,
                        "name": "decimals",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "2",
                        "line": 12,
                        "bodyLine": 12
                    }
                },
                "functions": [],
                "assets": []
            },
            "field_select": {
                "file": "/srv/control/phlo/resources/fields/select.phlo",
                "class": "field_select",
                "meta": {
                    "extends": "field",
                    "class": "field_select",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Select field",
                    "advice": "Stores the chosen option itself rather than a key, so renaming an option later leaves older records pointing at wording you no longer offer. Pass the accepted values as options.",
                    "package": "fields",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "field select"
                },
                "nodes": {
                    "input": {
                        "node": "method",
                        "visibility": null,
                        "name": "input",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "select(name: $this->name, inner: loop($this->options, fn($option) => \"<option\".($record->{$this->name} === $option ? ' selected' : void).\">$option\", void))",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "objColumns": {
                        "node": "prop",
                        "visibility": null,
                        "name": "objColumns",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[$this->name]",
                        "line": 14,
                        "bodyLine": 14
                    }
                },
                "functions": [],
                "assets": []
            },
            "field_text": {
                "file": "/srv/control/phlo/resources/fields/text.phlo",
                "class": "field_text",
                "meta": {
                    "extends": "field",
                    "class": "field_text",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Text field",
                    "advice": "length is both the limit and the choice of input: over 250 characters the field renders a textarea instead of a single line.",
                    "package": "fields",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "field text"
                },
                "nodes": {
                    "length": {
                        "node": "prop",
                        "visibility": null,
                        "name": "length",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "100",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "multiline": {
                        "node": "prop",
                        "visibility": null,
                        "name": "multiline",
                        "args": null,
                        "type": "bool",
                        "operator": "arrow",
                        "body": "$this->length > 250",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "label": {
                        "node": "method",
                        "visibility": null,
                        "name": "label",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "($value = $record->{$this->name}) === null ? dash : strtr(esc(strlen($value) > 100 ? substr($value, 0, 80).'...' : $value), [lf => br])",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "input": {
                        "node": "method",
                        "visibility": null,
                        "name": "input",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "$this->multiline ? $this->inputMulti($record) : $this->inputField($record)",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "inputField": {
                        "node": "method",
                        "visibility": null,
                        "name": "inputField",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "input(type: $this->type, name: $this->name, value: ($value = $record->{$this->name}) ? esc($value) : $this->default, maxlength: $this->length, placeholder: $this->placeholder, class: 'field')",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "inputMulti": {
                        "node": "method",
                        "visibility": null,
                        "name": "inputMulti",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "textarea(name: $this->name, inner: ($value = $record->{$this->name}) ? esc($value) : $this->default ?? void, placeholder: $this->placeholder, class: 'field')",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "objColumns": {
                        "node": "prop",
                        "visibility": null,
                        "name": "objColumns",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[$this->name]",
                        "line": 20,
                        "bodyLine": 20
                    }
                },
                "functions": [],
                "assets": []
            },
            "field_token": {
                "file": "/srv/control/phlo/resources/fields/token.phlo",
                "class": "field_token",
                "meta": {
                    "extends": "field",
                    "class": "field_token",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Token field",
                    "advice": "Fills itself with a random token when a record is made and refuses to change afterwards, which makes it the id to use for anything that ends up in a URL. handle is true, so a record can be looked up by this column instead of by its id.",
                    "package": "fields",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "field token"
                },
                "nodes": {
                    "length": {
                        "node": "prop",
                        "visibility": null,
                        "name": "length",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "8",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "default": {
                        "node": "prop",
                        "visibility": null,
                        "name": "default",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "token($this->length)",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "create": {
                        "node": "prop",
                        "visibility": null,
                        "name": "create",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "false",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "change": {
                        "node": "prop",
                        "visibility": null,
                        "name": "change",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "false",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "search": {
                        "node": "prop",
                        "visibility": null,
                        "name": "search",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "true",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "handle": {
                        "node": "prop",
                        "visibility": null,
                        "name": "handle",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "true",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "label": {
                        "node": "method",
                        "visibility": null,
                        "name": "label",
                        "args": "$record",
                        "type": "string",
                        "operator": "arrow",
                        "body": "tag('div', inner: esc($record->{$this->name}))",
                        "line": 19,
                        "bodyLine": 19
                    },
                    "parse": {
                        "node": "method",
                        "visibility": null,
                        "name": "parse",
                        "args": "$record",
                        "type": null,
                        "operator": "arrow",
                        "body": "$record->{$this->name} ??= $this->default",
                        "line": 20,
                        "bodyLine": 20
                    },
                    "objColumns": {
                        "node": "prop",
                        "visibility": null,
                        "name": "objColumns",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[$this->name]",
                        "line": 22,
                        "bodyLine": 22
                    }
                },
                "functions": [],
                "assets": []
            },
            "field_virtual": {
                "file": "/srv/control/phlo/resources/fields/virtual.phlo",
                "class": "field_virtual",
                "meta": {
                    "extends": "field",
                    "class": "field_virtual",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Virtual field",
                    "advice": "Owns no column and is never saved, so use it to show something derived next to the real fields. The record supplies the value through a prop or method of the same name.",
                    "package": "fields",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "field virtual"
                },
                "nodes": {
                    "create": {
                        "node": "prop",
                        "visibility": null,
                        "name": "create",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "false",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "change": {
                        "node": "prop",
                        "visibility": null,
                        "name": "change",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "false",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "objColumns": {
                        "node": "prop",
                        "visibility": null,
                        "name": "objColumns",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[]",
                        "line": 15,
                        "bodyLine": 15
                    }
                },
                "functions": [],
                "assets": []
            },
            "field_wysiwyg": {
                "file": "/srv/control/phlo/resources/fields/wysiwyg.phlo",
                "class": "field_wysiwyg",
                "meta": {
                    "extends": "field",
                    "class": "field_wysiwyg",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "WYSIWYG field",
                    "advice": "Stores whatever HTML the editor produces, including anything a user pasted in, so clean or escape it before it reaches a public page.",
                    "package": "fields",
                    "frontend": "true",
                    "backend": "true",
                    "tags": "field wysiwyg editor"
                },
                "nodes": {
                    "input": {
                        "node": "method",
                        "visibility": null,
                        "name": "input",
                        "args": "$record",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$value = $record->{$this->name} ?? $this->default ?? void\n\t$toolbar = tag('div', class: 'toolbar', inner: tag('button', type: 'button', data_command: 'bold', inner: '<b>B</b>').tag('button', type: 'button', data_command: 'italic', inner: '<i>I</i>').tag('button', type: 'button', data_command: 'underline', inner: '<u>U</u>').tag('button', type: 'button', data_command: 'insertUnorderedList', inner: '•').tag('button', type: 'button', data_command: 'insertOrderedList', inner: '1.'))\n\t$editor = tag('div', class: 'editor', contenteditable: 'true', inner: $value)\n\t$hiddenInput = textarea(name: $this->name, class: 'hidden-value', inner: $value)\n\treturn tag('div', class: 'wysiwyg-container', inner: \"$toolbar$editor$hiddenInput\")",
                        "line": 12,
                        "bodyLine": 13
                    },
                    "objColumns": {
                        "node": "prop",
                        "visibility": null,
                        "name": "objColumns",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[$this->name]",
                        "line": 20,
                        "bodyLine": 20
                    }
                },
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 22,
                        "body": "on('click', '.wysiwyg-container .toolbar button', (button, e) => {\n\te.preventDefault()\n\tconst command = button.dataset.command\n\tdocument.execCommand(command, false, null)\n})\n\non('input', '.wysiwyg-container .editor', editor => {\n\tconst container = editor.closest('.wysiwyg-container')\n\tconst hiddenInput = container.querySelector('textarea.hidden-value')\n\thiddenInput.value = editor.innerHTML\n})\non('click', '.wysiwyg-container .editor a', a => window.open(a.href))"
                    }
                ]
            }
        },
        "functions": {
            "field": {
                "args": "$type, ...$args",
                "return": "field",
                "body": "phlo(\"field_$type\", ...$args, type: $type)",
                "meta": {
                    "type": "abstract class",
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Base ORM field",
                    "advice": "Every field type extends this one and is made with field(type: 'x'), which resolves to the resource field_x; anything else you pass becomes a property, so title, required, length, pattern and enum need no declaration. Override input() for the form and label() for the list, and let objColumns name the columns the field owns, so a field that stores nothing returns an empty array.",
                    "package": "fields",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "field orm"
                },
                "file": "/srv/control/phlo/resources/fields/field.phlo",
                "line": 11,
                "source": "function"
            }
        }
    },
    "files": {
        "objs": {
            "CSV": {
                "file": "/srv/control/phlo/resources/files/CSV.phlo",
                "class": "CSV",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "CSV reader resource",
                    "advice": "Reads the first line as the header and picks its own separator by counting: more commas than semicolons and it is a comma, otherwise a semicolon. Every row comes back keyed by header name, so a file with duplicate or empty headers loses columns. It reads, it does not write.",
                    "package": "files",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "file csv reader import"
                },
                "nodes": {
                    "__handle": {
                        "node": "static",
                        "visibility": null,
                        "name": "__handle",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "\"CSV/$path$filename\"",
                        "line": 10,
                        "bodyLine": 10
                    },
                    "__construct": {
                        "node": "method",
                        "visibility": null,
                        "name": "__construct",
                        "args": "string $filename, ?string $path = null",
                        "type": null,
                        "operator": "method",
                        "body": "\t$path ??= data\n\t$this->objFile = $path.strtr($filename, [slash => dot]).'.csv'\n\tif (is_readable($this->objFile)) $this->objRead()",
                        "line": 11,
                        "bodyLine": 12
                    },
                    "objFile": {
                        "node": "readonly",
                        "visibility": null,
                        "name": "objFile",
                        "args": null,
                        "type": "string",
                        "operator": null,
                        "body": null,
                        "line": 17
                    },
                    "objRead": {
                        "node": "method",
                        "visibility": null,
                        "name": "objRead",
                        "args": null,
                        "type": "void",
                        "operator": "method",
                        "body": "\t$fp = fopen($this->objFile, 'r+')\n\t$headers = str_replace([dq, cr, lf], void, fgets($fp))\n\t$delimiter = substr_count($headers, comma) > substr_count($headers, semi) ? comma : semi\n\t$headers =  explode($delimiter, $headers)\n\twhile ($row = fgetcsv($fp, null, $delimiter, dq, void)) $this->objData[] = array_combine($headers, $row)\n\tfclose($fp)",
                        "line": 19,
                        "bodyLine": 20
                    }
                },
                "functions": [],
                "assets": []
            },
            "DOCX": {
                "file": "/srv/control/phlo/resources/files/DOCX.phlo",
                "class": "DOCX",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "DOCX reader resource",
                    "advice": "Pulls the plain text out of a .docx and nothing more: no styling, no tables as tables, no images. Paragraphs come out as a list, which is what makes it usable for search and for feeding a model, and DOCX::toText() is the one-liner for that.",
                    "package": "files",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "php-ext:zip",
                    "tags": "file docx word reader"
                },
                "nodes": {
                    "__construct": {
                        "node": "method",
                        "visibility": null,
                        "name": "__construct",
                        "args": "string $file",
                        "type": null,
                        "operator": "method",
                        "body": "\t$zip = new ZipArchive()\n\tif ($zip->open($file) !== true) dx('error opening docx', $file)\n\t$xml = $zip->getFromName('word/document.xml')\n\t$zip->close()\n\tif (!$xml) dx('error reading document.xml')\n\t$text = preg_replace('/<\\/w:p>/', lf, $xml)\n\t$text = strip_tags($text)\n\t$text = html_entity_decode($text, ENT_QUOTES | ENT_XML1, 'UTF-8')\n\t$this->text = trim(preg_replace('/[ \\t]+/', space, $text))\n\t$this->paragraphs = array_values(array_filter(explode(lf, $this->text), fn($p) => trim($p) !== void))",
                        "line": 11,
                        "bodyLine": 12
                    },
                    "toText": {
                        "node": "static",
                        "visibility": null,
                        "name": "toText",
                        "args": "string $file",
                        "type": "string",
                        "operator": "arrow",
                        "body": "(new static($file))->text",
                        "line": 24,
                        "bodyLine": 24
                    }
                },
                "functions": [],
                "assets": []
            },
            "file": {
                "file": "/srv/control/phlo/resources/files/file.phlo",
                "class": "file",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "File resource",
                    "advice": "Wrap a path in %file and everything about it is one call away: contents, size, mime, hashes, human dates, and a token() derived from the file's sha1, so the same content always yields the same token. Watch the difference between file and name: file is where it sits, name is what it is called, and ext and mime read the name. An upload therefore keeps its extension while the temp path has none.",
                    "package": "files",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "file filesystem io"
                },
                "nodes": {
                    "__handle": {
                        "node": "static",
                        "visibility": null,
                        "name": "__handle",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "\"file/$file\".($name ? \"/$name\" : void)",
                        "line": 10,
                        "bodyLine": 10
                    },
                    "__construct": {
                        "node": "method",
                        "visibility": null,
                        "name": "__construct",
                        "args": "public string $file, ?string $name = null, $contents = null, ...$args",
                        "type": null,
                        "operator": "method",
                        "body": "\t$name && $this->name = $name\n\tis_string($contents) && $this->write($contents)\n\t$args && $this->objImport(...$args)",
                        "line": 11,
                        "bodyLine": 12
                    },
                    "append": {
                        "node": "method",
                        "visibility": null,
                        "name": "append",
                        "args": "string $data",
                        "type": "int|false",
                        "operator": "arrow",
                        "body": "file_put_contents($this->file, $data, FILE_APPEND | LOCK_EX)",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "basename": {
                        "node": "prop",
                        "visibility": null,
                        "name": "basename",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "pathinfo($this->file, PATHINFO_BASENAME)",
                        "line": 18,
                        "bodyLine": 18
                    },
                    "base64": {
                        "node": "method",
                        "visibility": null,
                        "name": "base64",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "base64_encode($this->contents)",
                        "line": 19,
                        "bodyLine": 19
                    },
                    "contents": {
                        "node": "method",
                        "visibility": null,
                        "name": "contents",
                        "args": null,
                        "type": "string|false",
                        "operator": "arrow",
                        "body": "file_get_contents($this->file)",
                        "line": 20,
                        "bodyLine": 20
                    },
                    "contentsINI": {
                        "node": "method",
                        "visibility": null,
                        "name": "contentsINI",
                        "args": "bool $parse = true",
                        "type": "array|false",
                        "operator": "arrow",
                        "body": "parse_ini_string($this->contents, true, $parse ? INI_SCANNER_TYPED : INI_SCANNER_RAW)",
                        "line": 21,
                        "bodyLine": 21
                    },
                    "contentsJSON": {
                        "node": "method",
                        "visibility": null,
                        "name": "contentsJSON",
                        "args": "$assoc = null",
                        "type": null,
                        "operator": "arrow",
                        "body": "json_decode($this->contents, $assoc)",
                        "line": 22,
                        "bodyLine": 22
                    },
                    "copy": {
                        "node": "method",
                        "visibility": null,
                        "name": "copy",
                        "args": "$to",
                        "type": "bool",
                        "operator": "arrow",
                        "body": "copy($this->file, $to)",
                        "line": 23,
                        "bodyLine": 23
                    },
                    "created": {
                        "node": "method",
                        "visibility": null,
                        "name": "created",
                        "args": null,
                        "type": "int|false",
                        "operator": "arrow",
                        "body": "filectime($this->file)",
                        "line": 24,
                        "bodyLine": 24
                    },
                    "createdAge": {
                        "node": "method",
                        "visibility": null,
                        "name": "createdAge",
                        "args": null,
                        "type": "int",
                        "operator": "arrow",
                        "body": "age($this->created)",
                        "line": 25,
                        "bodyLine": 25
                    },
                    "createdHuman": {
                        "node": "method",
                        "visibility": null,
                        "name": "createdHuman",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "time_human($this->created)",
                        "line": 26,
                        "bodyLine": 26
                    },
                    "curl": {
                        "node": "method",
                        "visibility": null,
                        "name": "curl",
                        "args": "$type = null, $filename = null",
                        "type": "CURLFile",
                        "operator": "arrow",
                        "body": "new CURLFile($this->file, $type, $filename)",
                        "line": 27,
                        "bodyLine": 27
                    },
                    "delete": {
                        "node": "method",
                        "visibility": null,
                        "name": "delete",
                        "args": null,
                        "type": "bool",
                        "operator": "arrow",
                        "body": "first($deleted = $this->exists && unlink($this->file), debug($deleted ? \"Deleted $this->basename\" : \"Could not delete $this->basename\"))",
                        "line": 28,
                        "bodyLine": 28
                    },
                    "exists": {
                        "node": "method",
                        "visibility": null,
                        "name": "exists",
                        "args": null,
                        "type": "bool",
                        "operator": "arrow",
                        "body": "file_exists($this->file)",
                        "line": 29,
                        "bodyLine": 29
                    },
                    "ext": {
                        "node": "prop",
                        "visibility": null,
                        "name": "ext",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "pathinfo($this->name, PATHINFO_EXTENSION)",
                        "line": 30,
                        "bodyLine": 30
                    },
                    "filename": {
                        "node": "prop",
                        "visibility": null,
                        "name": "filename",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "pathinfo($this->file, PATHINFO_FILENAME)",
                        "line": 31,
                        "bodyLine": 31
                    },
                    "getLine": {
                        "node": "method",
                        "visibility": null,
                        "name": "getLine",
                        "args": null,
                        "type": "string|false",
                        "operator": "arrow",
                        "body": "($line = fgets($this->pointer)) === false ? false : rtrim($line)",
                        "line": 32,
                        "bodyLine": 32
                    },
                    "getLength": {
                        "node": "method",
                        "visibility": null,
                        "name": "getLength",
                        "args": "int $length",
                        "type": "string|false",
                        "operator": "arrow",
                        "body": "fread($this->pointer, $length)",
                        "line": 33,
                        "bodyLine": 33
                    },
                    "is": {
                        "node": "method",
                        "visibility": null,
                        "name": "is",
                        "args": "string $file",
                        "type": "bool",
                        "operator": "arrow",
                        "body": "$file === $this->file",
                        "line": 34,
                        "bodyLine": 34
                    },
                    "md5": {
                        "node": "method",
                        "visibility": null,
                        "name": "md5",
                        "args": null,
                        "type": "string|false",
                        "operator": "arrow",
                        "body": "md5_file($this->file)",
                        "line": 35,
                        "bodyLine": 35
                    },
                    "mime": {
                        "node": "prop",
                        "visibility": null,
                        "name": "mime",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "mime($this->name)",
                        "line": 36,
                        "bodyLine": 36
                    },
                    "modified": {
                        "node": "method",
                        "visibility": null,
                        "name": "modified",
                        "args": null,
                        "type": "int|false",
                        "operator": "arrow",
                        "body": "filemtime($this->file)",
                        "line": 37,
                        "bodyLine": 37
                    },
                    "modifiedAge": {
                        "node": "method",
                        "visibility": null,
                        "name": "modifiedAge",
                        "args": null,
                        "type": "int",
                        "operator": "arrow",
                        "body": "age($this->modified)",
                        "line": 38,
                        "bodyLine": 38
                    },
                    "modifiedHuman": {
                        "node": "method",
                        "visibility": null,
                        "name": "modifiedHuman",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "time_human($this->modified)",
                        "line": 39,
                        "bodyLine": 39
                    },
                    "move": {
                        "node": "method",
                        "visibility": null,
                        "name": "move",
                        "args": "$to",
                        "type": "bool",
                        "operator": "arrow",
                        "body": "rename($this->file, $to) && $this->file = $to",
                        "line": 40,
                        "bodyLine": 40
                    },
                    "name": {
                        "node": "prop",
                        "visibility": null,
                        "name": "name",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "$this->basename",
                        "line": 41,
                        "bodyLine": 41
                    },
                    "output": {
                        "node": "method",
                        "visibility": null,
                        "name": "output",
                        "args": "$download = false",
                        "type": null,
                        "operator": "arrow",
                        "body": "output($this->contents, $this->name, $download)",
                        "line": 42,
                        "bodyLine": 42
                    },
                    "path": {
                        "node": "prop",
                        "visibility": null,
                        "name": "path",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "realpath(pathinfo($this->file, PATHINFO_DIRNAME)).slash",
                        "line": 43,
                        "bodyLine": 43
                    },
                    "pathRel": {
                        "node": "prop",
                        "visibility": null,
                        "name": "pathRel",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "str_starts_with($this->file, app) ? substr($this->file, strlen(app)) : $this->file",
                        "line": 44,
                        "bodyLine": 44
                    },
                    "pointer": {
                        "node": "prop",
                        "visibility": null,
                        "name": "pointer",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "fopen($this->file, 'r+')",
                        "line": 45,
                        "bodyLine": 45
                    },
                    "readable": {
                        "node": "method",
                        "visibility": null,
                        "name": "readable",
                        "args": null,
                        "type": "bool",
                        "operator": "arrow",
                        "body": "is_readable($this->file)",
                        "line": 46,
                        "bodyLine": 46
                    },
                    "src": {
                        "node": "method",
                        "visibility": null,
                        "name": "src",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "\"data:$this->mime;base64,$this->base64\"",
                        "line": 47,
                        "bodyLine": 47
                    },
                    "size": {
                        "node": "method",
                        "visibility": null,
                        "name": "size",
                        "args": null,
                        "type": "int|false",
                        "operator": "arrow",
                        "body": "filesize($this->file)",
                        "line": 48,
                        "bodyLine": 48
                    },
                    "sizeHuman": {
                        "node": "method",
                        "visibility": null,
                        "name": "sizeHuman",
                        "args": "int $precision = 0",
                        "type": "string",
                        "operator": "arrow",
                        "body": "size_human($this->size, $precision)",
                        "line": 49,
                        "bodyLine": 49
                    },
                    "sha1": {
                        "node": "method",
                        "visibility": null,
                        "name": "sha1",
                        "args": null,
                        "type": "string|false",
                        "operator": "arrow",
                        "body": "sha1_file($this->file)",
                        "line": 50,
                        "bodyLine": 50
                    },
                    "shortenTo": {
                        "node": "method",
                        "visibility": null,
                        "name": "shortenTo",
                        "args": "int $length",
                        "type": "string",
                        "operator": "arrow",
                        "body": "strlen($this->name) <= $length ? $this->name : substr($this->name, 0, $length - strlen($this->ext) - 3).dot.dot.dot.$this->ext",
                        "line": 51,
                        "bodyLine": 51
                    },
                    "title": {
                        "node": "method",
                        "visibility": null,
                        "name": "title",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "ucfirst(strtr(pathinfo($this->name, PATHINFO_FILENAME), [us => space]))",
                        "line": 52,
                        "bodyLine": 52
                    },
                    "token": {
                        "node": "method",
                        "visibility": null,
                        "name": "token",
                        "args": "$length = 20",
                        "type": "string",
                        "operator": "arrow",
                        "body": "token($length, $this->sha1)",
                        "line": 53,
                        "bodyLine": 53
                    },
                    "type": {
                        "node": "method",
                        "visibility": null,
                        "name": "type",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "substr($this->mime, 0, strpos($this->mime, slash))",
                        "line": 54,
                        "bodyLine": 54
                    },
                    "touch": {
                        "node": "method",
                        "visibility": null,
                        "name": "touch",
                        "args": null,
                        "type": "bool",
                        "operator": "arrow",
                        "body": "touch($this->file)",
                        "line": 55,
                        "bodyLine": 55
                    },
                    "writable": {
                        "node": "method",
                        "visibility": null,
                        "name": "writable",
                        "args": null,
                        "type": "bool",
                        "operator": "arrow",
                        "body": "is_writable($this->file)",
                        "line": 56,
                        "bodyLine": 56
                    },
                    "writeINI": {
                        "node": "method",
                        "visibility": null,
                        "name": "writeINI",
                        "args": "$data, bool $deleteEmpty = false",
                        "type": "bool",
                        "operator": "arrow",
                        "body": "$this->write(!$deleteEmpty || $data ? loop($data, fn($value, $key) => $key.' = '.dq.strtr($value, [dq => bs.dq, lf => '\\n']).dq, lf).lf : void, $deleteEmpty)",
                        "line": 57,
                        "bodyLine": 57
                    },
                    "writeJSON": {
                        "node": "method",
                        "visibility": null,
                        "name": "writeJSON",
                        "args": "$data, bool $deleteEmpty = false",
                        "type": "bool",
                        "operator": "arrow",
                        "body": "$this->write(!$deleteEmpty || $data ? json_encode($data, jsonPretty) : void, $deleteEmpty)",
                        "line": 58,
                        "bodyLine": 58
                    },
                    "writeJSONplain": {
                        "node": "method",
                        "visibility": null,
                        "name": "writeJSONplain",
                        "args": "$data, bool $deleteEmpty = false",
                        "type": "bool",
                        "operator": "arrow",
                        "body": "$this->write(!$deleteEmpty || $data ? json_encode($data) : void, $deleteEmpty)",
                        "line": 59,
                        "bodyLine": 59
                    },
                    "write": {
                        "node": "method",
                        "visibility": null,
                        "name": "write",
                        "args": "string $data, bool $deleteEmpty = false",
                        "type": "bool",
                        "operator": "method",
                        "body": "\tif (!$data && $deleteEmpty) return $this->delete\n\tif ($written = file_put_contents($this->file, $data, LOCK_EX) !== false) debug('Written '.$this->basename.' ('.$this->sizeHuman.')')\n\telse error('Could not write '.$this->file)\n\treturn $written",
                        "line": 60,
                        "bodyLine": 61
                    },
                    "objInfo": {
                        "node": "method",
                        "visibility": null,
                        "name": "objInfo",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "array_combine($keys = array_merge(['file', 'name', 'exists'], $this->exists ? ['sizeHuman', 'createdHuman', 'modifiedHuman', 'mime'] : []), loop($keys, fn($arg) => $this->$arg))",
                        "line": 67,
                        "bodyLine": 67
                    }
                },
                "functions": [],
                "assets": []
            },
            "img": {
                "file": "/srv/control/phlo/resources/files/img.phlo",
                "class": "img",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "GD image resource",
                    "advice": "Only scales down, never up: a request larger than the original returns the image untouched, so a thumbnail never looks stretched. The output format follows the extension you save to, so saving a .jpg writes JPEG at quality 85 whatever came in. Pass crop with a width and a height to fill the frame instead of fitting inside it, and top or bottom to choose which part survives.",
                    "package": "files",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "image gd file graphics"
                },
                "nodes": {
                    "detect": {
                        "node": "static",
                        "visibility": null,
                        "name": "detect",
                        "args": "$data",
                        "type": "?string",
                        "operator": "method",
                        "body": "\t$header = substr($data, 0, 12)\n\tif (substr($header, 0, 3) === \"\\xFF\\xD8\\xFF\") return 'jpg'\n\tif (substr($header, 0, 8) === \"\\x89PNG\\x0D\\x0A\\x1A\\x0A\") return 'png'\n\tif (substr($header, 0, 6) === 'GIF87a' || substr($header, 0, 6) === 'GIF89a') return 'gif'\n\tif (substr($header, 0, 4) === 'RIFF' && substr($header, 8, 4) === 'WEBP') return 'webp'\n\tif (substr($header, 0, 2) === \"BM\") return 'bmp'\n\tif (substr($header, 0, 4) === \"\\x49\\x49\\x2A\\x00\" || substr($header, 0, 4) === \"\\x4D\\x4D\\x00\\x2A\") return 'tiff'",
                        "line": 10,
                        "bodyLine": 11
                    },
                    "__handle": {
                        "node": "static",
                        "visibility": null,
                        "name": "__handle",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "\"img/$file\"",
                        "line": 20,
                        "bodyLine": 20
                    },
                    "__construct": {
                        "node": "method",
                        "visibility": null,
                        "name": "__construct",
                        "args": "public string $file",
                        "type": null,
                        "operator": null,
                        "body": null,
                        "line": 21
                    },
                    "src": {
                        "node": "prop",
                        "visibility": null,
                        "name": "src",
                        "args": null,
                        "type": "GdImage",
                        "operator": "arrow",
                        "body": "imagecreatefromstring(file_get_contents($this->file))",
                        "line": 23,
                        "bodyLine": 23
                    },
                    "width": {
                        "node": "prop",
                        "visibility": null,
                        "name": "width",
                        "args": null,
                        "type": "int",
                        "operator": "arrow",
                        "body": "imagesx($this->src)",
                        "line": 24,
                        "bodyLine": 24
                    },
                    "height": {
                        "node": "prop",
                        "visibility": null,
                        "name": "height",
                        "args": null,
                        "type": "int",
                        "operator": "arrow",
                        "body": "imagesy($this->src)",
                        "line": 25,
                        "bodyLine": 25
                    },
                    "scale": {
                        "node": "method",
                        "visibility": null,
                        "name": "scale",
                        "args": "$width = null, $height = null, $crop = false",
                        "type": "static",
                        "operator": "method",
                        "body": "\tif (!$width && !$height) return $this\n\t$srcW = $this->width\n\t$srcH = $this->height\n\t$doCrop = ($crop && $width && $height)\n\tif ($width && $height) $scale = $doCrop ? max($width / $srcW, $height / $srcH) : min($width / $srcW, $height / $srcH)\n\telseif ($width) $scale = $width / $srcW\n\telse $scale = $height / $srcH\n\tif ($scale >= 1) return $this\n\t$scaledW = (int)round($srcW * $scale)\n\t$scaledH = (int)round($srcH * $scale)\n\t$destW = ($width && $height && $doCrop) ? (int)$width : $scaledW\n\t$destH = ($width && $height && $doCrop) ? (int)$height : $scaledH\n\t$offsetX = 0\n\t$offsetY = 0\n\tif ($width && $height && $doCrop){\n\t\t$offsetX = (int)-round(($scaledW - $destW) / 2)\n\t\t$offsetY = (int)-round(($scaledH - $destH) / 2)\n\t\tif ($crop === 'top') $offsetY = 0\n\t\telseif ($crop === 'bottom') $offsetY = (int)-($scaledH - $destH)\n\t}\n\t$destImg = imagecreatetruecolor($destW, $destH)\n\timagealphablending($destImg, false)\n\timagesavealpha($destImg, true)\n\timagecopyresampled($destImg, $this->src, $offsetX, $offsetY, 0, 0, $scaledW, $scaledH, $srcW, $srcH)\n\t$this->src = $destImg\n\t$this->width = $destW\n\t$this->height = $destH\n\treturn $this",
                        "line": 27,
                        "bodyLine": 28
                    },
                    "ext": {
                        "node": "method",
                        "visibility": null,
                        "name": "ext",
                        "args": "$file = null",
                        "type": "string",
                        "operator": "arrow",
                        "body": "strtolower(pathinfo($file ?? $this->file, PATHINFO_EXTENSION))",
                        "line": 58,
                        "bodyLine": 58
                    },
                    "source": {
                        "node": "method",
                        "visibility": null,
                        "name": "source",
                        "args": "$format = null",
                        "type": "string",
                        "operator": "method",
                        "body": "\tob_start()\n\t$this->write($format)\n\treturn ob_get_clean()",
                        "line": 60,
                        "bodyLine": 61
                    },
                    "save": {
                        "node": "method",
                        "visibility": null,
                        "name": "save",
                        "args": "$file = null",
                        "type": "bool",
                        "operator": "method",
                        "body": "\t$file && $this->file = $file\n\treturn $this->write(null, $this->file)",
                        "line": 66,
                        "bodyLine": 67
                    },
                    "write": {
                        "node": "method",
                        "visibility": "private",
                        "name": "write",
                        "args": "$format = null, $file = null",
                        "type": null,
                        "operator": "method",
                        "body": "\t$format ??= $this->ext()\n\tif ($format === 'png') return imagepng($this->src, $file, 8)\n\tif ($format === 'gif') return imagegif($this->src, $file)\n\tif ($format === 'webp'){\n\t\timageistruecolor($this->src) || imagepalettetotruecolor($this->src)\n\t\treturn imagewebp($this->src, $file)\n\t}\n\treturn imagejpeg($this->src, $file, 85)",
                        "line": 71,
                        "bodyLine": 72
                    }
                },
                "functions": [],
                "assets": []
            },
            "INI": {
                "file": "/srv/control/phlo/resources/files/INI.phlo",
                "class": "INI",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Generic INI resource",
                    "advice": "The same shape as the JSON resource, saved when the object goes out of scope, but writing flattens the file: comments and section headers do not survive a round trip. Keep it to values a program owns; a file a person edits by hand deserves to be read rather than rewritten.",
                    "package": "files",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "file ini config parser"
                },
                "nodes": {
                    "objFile": {
                        "node": "prop",
                        "visibility": null,
                        "name": "objFile",
                        "args": null,
                        "type": "string",
                        "operator": null,
                        "body": null,
                        "line": 10
                    },
                    "__handle": {
                        "node": "static",
                        "visibility": null,
                        "name": "__handle",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "\"INI/$path$filename\".(!$parse ? '/0' : void)",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "__construct": {
                        "node": "method",
                        "visibility": null,
                        "name": "__construct",
                        "args": "string $filename, ?string $path = null, bool $parse = true",
                        "type": null,
                        "operator": "method",
                        "body": "\t$path ??= data\n\t$this->objFile = $path.strtr($filename, [slash => dot]).'.ini'\n\tif (is_readable($this->objFile)) $this->objRead($parse)",
                        "line": 13,
                        "bodyLine": 14
                    },
                    "objRead": {
                        "node": "method",
                        "visibility": null,
                        "name": "objRead",
                        "args": "$parse = true",
                        "type": null,
                        "operator": "arrow",
                        "body": "last($this->objData = parse_ini_file($this->objFile, true, $parse ? INI_SCANNER_TYPED : INI_SCANNER_RAW), $this->objChanged = false, $this)",
                        "line": 19,
                        "bodyLine": 19
                    },
                    "objWrite": {
                        "node": "method",
                        "visibility": null,
                        "name": "objWrite",
                        "args": null,
                        "type": "int|false",
                        "operator": "arrow",
                        "body": "file_put_contents($this->objFile, loop($this->objData, fn($value, $key) => $key.' = '.dq.strtr($value, [dq => bs.dq, lf => '\\n']).dq, lf).lf, LOCK_EX)",
                        "line": 20,
                        "bodyLine": 20
                    },
                    "__destruct": {
                        "node": "method",
                        "visibility": null,
                        "name": "__destruct",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "$this->objChanged && $this->objWrite()",
                        "line": 22,
                        "bodyLine": 22
                    }
                },
                "functions": [],
                "assets": []
            },
            "JSON": {
                "file": "/srv/control/phlo/resources/files/JSON.phlo",
                "class": "JSON",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Generic JSON resource",
                    "advice": "The file behaves as an object: read a key, write a key, and the file is saved when the object goes out of scope. That last part is the trap: nothing is written until then, so call objWrite() yourself when the request may end otherwise. Names are read against data/ and a slash in the name becomes a dot, so no name can escape the directory.",
                    "package": "files",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "json_read json_write",
                    "tags": "file json storage parser"
                },
                "nodes": {
                    "__handle": {
                        "node": "static",
                        "visibility": null,
                        "name": "__handle",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "\"JSON/$path$filename\".(is_bool($assoc) ? slash.(int)$assoc : void)",
                        "line": 11,
                        "bodyLine": 11
                    },
                    "__construct": {
                        "node": "method",
                        "visibility": null,
                        "name": "__construct",
                        "args": "string $filename, ?string $path = null, $assoc = null",
                        "type": null,
                        "operator": "method",
                        "body": "\t$path ??= data\n\t$this->objFile = $path.strtr($filename, [slash => dot]).'.json'\n\tif (is_readable($this->objFile)) $this->objRead($assoc)",
                        "line": 12,
                        "bodyLine": 13
                    },
                    "objFile": {
                        "node": "readonly",
                        "visibility": null,
                        "name": "objFile",
                        "args": null,
                        "type": "string",
                        "operator": null,
                        "body": null,
                        "line": 18
                    },
                    "objTouch": {
                        "node": "method",
                        "visibility": null,
                        "name": "objTouch",
                        "args": null,
                        "type": "bool",
                        "operator": "arrow",
                        "body": "$this->objChanged = true",
                        "line": 20,
                        "bodyLine": 20
                    },
                    "objRead": {
                        "node": "method",
                        "visibility": null,
                        "name": "objRead",
                        "args": "$assoc = null",
                        "type": null,
                        "operator": "arrow",
                        "body": "last($data = json_read($this->objFile, $assoc), $this->objData = $assoc || is_array($data) ? $data : get_object_vars($data), $this->objChanged = false, $this)",
                        "line": 21,
                        "bodyLine": 21
                    },
                    "objWrite": {
                        "node": "method",
                        "visibility": null,
                        "name": "objWrite",
                        "args": "$data, $flags = null",
                        "type": null,
                        "operator": "arrow",
                        "body": "first($written = json_write($this->objFile, $data, $flags), $written && $this->objChanged = false)",
                        "line": 22,
                        "bodyLine": 22
                    },
                    "__destruct": {
                        "node": "method",
                        "visibility": null,
                        "name": "__destruct",
                        "args": null,
                        "type": null,
                        "operator": "arrow",
                        "body": "$this->objChanged && $this->objWrite($this->objData)",
                        "line": 24,
                        "bodyLine": 24
                    }
                },
                "functions": [],
                "assets": []
            },
            "PDF": {
                "file": "/srv/control/phlo/resources/files/PDF.phlo",
                "class": "PDF",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "PDF generator and reader",
                    "advice": "Two halves that share a name. Reading uses the pdftotext binary, so it needs poppler-utils on the machine and gives nothing on a scan without a text layer. Writing renders HTML through mPDF, and mode is the mPDF one: D sends a download, I shows it in the browser, S returns the bytes as a string, so leaving the default on an API route pushes a download at your caller.",
                    "package": "files",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "file pdf reader generator"
                },
                "nodes": {
                    "toText": {
                        "node": "static",
                        "visibility": null,
                        "name": "toText",
                        "args": "string $file",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$process = proc_open('pdftotext '.escapeshellarg($file).' -', [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], $pipes)\n\tif (!is_resource($process)) return null\n\tfclose($pipes[0])\n\t$text = stream_get_contents($pipes[1])\n\tfclose($pipes[1])\n\t$error = stream_get_contents($pipes[2])\n\tfclose($pipes[2])\n\t($code = proc_close($process)) && error(\"PDFToText Error: pdftotext command failed with code $code. Error: $error\")\n\treturn rtrim($text, \"\\f\")",
                        "line": 10,
                        "bodyLine": 11
                    },
                    "title": {
                        "node": "prop",
                        "visibility": null,
                        "name": "title",
                        "args": null,
                        "type": "?string",
                        "operator": "value",
                        "body": "null",
                        "line": 22,
                        "bodyLine": 22
                    },
                    "author": {
                        "node": "prop",
                        "visibility": null,
                        "name": "author",
                        "args": null,
                        "type": "?string",
                        "operator": "value",
                        "body": "null",
                        "line": 23,
                        "bodyLine": 23
                    },
                    "subject": {
                        "node": "prop",
                        "visibility": null,
                        "name": "subject",
                        "args": null,
                        "type": "?string",
                        "operator": "value",
                        "body": "null",
                        "line": 24,
                        "bodyLine": 24
                    },
                    "keywords": {
                        "node": "prop",
                        "visibility": null,
                        "name": "keywords",
                        "args": null,
                        "type": "?string",
                        "operator": "value",
                        "body": "null",
                        "line": 25,
                        "bodyLine": 25
                    },
                    "creator": {
                        "node": "prop",
                        "visibility": null,
                        "name": "creator",
                        "args": null,
                        "type": "string",
                        "operator": "value",
                        "body": "'Phlo '.phlo.' (https://phlo.tech/)'",
                        "line": 26,
                        "bodyLine": 26
                    },
                    "filename": {
                        "node": "prop",
                        "visibility": null,
                        "name": "filename",
                        "args": null,
                        "type": "string",
                        "operator": "value",
                        "body": "'Download.pdf'",
                        "line": 28,
                        "bodyLine": 28
                    },
                    "mode": {
                        "node": "prop",
                        "visibility": null,
                        "name": "mode",
                        "args": null,
                        "type": "string",
                        "operator": "value",
                        "body": "'D'",
                        "line": 29,
                        "bodyLine": 29
                    },
                    "fromHTML": {
                        "node": "method",
                        "visibility": null,
                        "name": "fromHTML",
                        "args": "$HTML",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$mpdf = new \\Mpdf\\Mpdf\n\t$this->title && $mpdf->SetTitle($this->title)\n\t$this->author && $mpdf->SetAuthor($this->author)\n\t$this->subject && $mpdf->SetSubject($this->subject)\n\t$this->keywords && $mpdf->SetKeywords($this->keywords)\n\t$this->creator && $mpdf->SetCreator($this->creator)\n\t$mpdf->WriteHTML($HTML)\n\treturn $mpdf->Output($this->filename, $this->mode)",
                        "line": 31,
                        "bodyLine": 32
                    }
                },
                "functions": [],
                "assets": []
            },
            "UBL": {
                "file": "/srv/control/phlo/resources/files/UBL.phlo",
                "class": "UBL",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "UBL 2.1 invoice XML (PEPPOL BIS Billing 3.0) from a normalized invoice structure. Use UBL::invoice($data).",
                    "advice": "Builds the invoice XML that PEPPOL expects from a plain array with supplier, customer and lines. Tax is grouped by rate and each group gets its own subtotal, so lines at 21 and 9 percent land in the right boxes on their own. It formats and escapes the amounts you hand it but checks nothing: a total that does not match its lines is written out as given, and a receiver will reject it.",
                    "package": "files",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "ubl peppol invoice xml e-invoicing export"
                },
                "nodes": {
                    "invoice": {
                        "node": "static",
                        "visibility": null,
                        "name": "invoice",
                        "args": "array $data",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$currency = strtoupper((string)($data['currency'] ?? 'EUR'))\n\t$issue = (string)($data['issue_date'] ?? void)\n\t$due = (string)($data['due_date'] ?? void) ?: $issue\n\t$lines = []\n\t$linesSum = 0\n\t$taxSubtotals = []\n\tforeach ((array)($data['lines'] ?? []) AS $i => $line){\n\t\t$qty = (float)($line['quantity'] ?? 0)\n\t\t$net = round($qty * (float)($line['unit_price'] ?? 0), 2)\n\t\t$rate = (float)($line['tax_rate'] ?? 0)\n\t\t$lines[] = static::xmlLine($i + 1, $line, $qty, $net, $rate, $currency)\n\t\t$linesSum += $net\n\t\t$key = number_format($rate, 2, dot, void)\n\t\t$taxSubtotals[$key] = ($taxSubtotals[$key] ?? 0) + $net\n\t}\n\t$tax = (float)($data['tax_amount'] ?? 0)\n\t$total = (float)($data['total_amount'] ?? 0)\n\t$taxXml = void\n\tforeach ($taxSubtotals AS $rate => $taxable){\n\t\t$amount = round($taxable * (float)$rate / 100, 2)\n\t\t$category = (float)$rate === 0.0 ? 'Z' : 'S'\n\t\t$taxXml .= implode(void, [\n\t\t\t'<cac:TaxSubtotal>',\n\t\t\t'<cbc:TaxableAmount currencyID=\"'.$currency.'\">'.static::n($taxable).'</cbc:TaxableAmount>',\n\t\t\t'<cbc:TaxAmount currencyID=\"'.$currency.'\">'.static::n($amount).'</cbc:TaxAmount>',\n\t\t\t'<cac:TaxCategory><cbc:ID>'.$category.'</cbc:ID><cbc:Percent>'.static::n((float)$rate).'</cbc:Percent>',\n\t\t\t'<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme></cac:TaxCategory>',\n\t\t\t'</cac:TaxSubtotal>',\n\t\t])\n\t}\n\t$supplier = (array)($data['supplier'] ?? [])\n\t$customer = (array)($data['customer'] ?? [])\n\treturn implode(void, [\n\t\t'<?xml version=\"1.0\" encoding=\"UTF-8\"?>',\n\t\t'<Invoice xmlns=\"urn:oasis:names:specification:ubl:schema:xsd:Invoice-2\" xmlns:cac=\"urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2\" xmlns:cbc=\"urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2\">',\n\t\t'<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0</cbc:CustomizationID>',\n\t\t'<cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>',\n\t\t'<cbc:ID>'.static::esc((string)($data['number'] ?? void)).'</cbc:ID>',\n\t\t'<cbc:IssueDate>'.static::esc($issue).'</cbc:IssueDate>',\n\t\t'<cbc:DueDate>'.static::esc($due).'</cbc:DueDate>',\n\t\t'<cbc:InvoiceTypeCode>'.(!empty($data['credit_note']) ? '381' : '380').'</cbc:InvoiceTypeCode>',\n\t\t'<cbc:DocumentCurrencyCode>'.$currency.'</cbc:DocumentCurrencyCode>',\n\t\tstatic::partyXml('AccountingSupplierParty', (string)($supplier['name'] ?? void), $supplier, (string)($supplier['vat'] ?? void)),\n\t\tstatic::partyXml('AccountingCustomerParty', (string)($customer['name'] ?? void), $customer, (string)($customer['vat'] ?? void)),\n\t\t'<cac:TaxTotal><cbc:TaxAmount currencyID=\"'.$currency.'\">'.static::n($tax).'</cbc:TaxAmount>'.$taxXml.'</cac:TaxTotal>',\n\t\t'<cac:LegalMonetaryTotal>',\n\t\t'<cbc:LineExtensionAmount currencyID=\"'.$currency.'\">'.static::n($linesSum).'</cbc:LineExtensionAmount>',\n\t\t'<cbc:TaxExclusiveAmount currencyID=\"'.$currency.'\">'.static::n($linesSum).'</cbc:TaxExclusiveAmount>',\n\t\t'<cbc:TaxInclusiveAmount currencyID=\"'.$currency.'\">'.static::n($total).'</cbc:TaxInclusiveAmount>',\n\t\t'<cbc:PayableAmount currencyID=\"'.$currency.'\">'.static::n($total).'</cbc:PayableAmount>',\n\t\t'</cac:LegalMonetaryTotal>',\n\t\timplode(void, $lines),\n\t\t'</Invoice>',\n\t])",
                        "line": 10,
                        "bodyLine": 11
                    },
                    "xmlLine": {
                        "node": "static",
                        "visibility": null,
                        "name": "xmlLine",
                        "args": "$idx, $line, $qty, $net, $rate, $currency = 'EUR'",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$category = (float)$rate === 0.0 ? 'Z' : 'S'\n\treturn implode(void, [\n\t\t'<cac:InvoiceLine>',\n\t\t'<cbc:ID>'.$idx.'</cbc:ID>',\n\t\t'<cbc:InvoicedQuantity unitCode=\"EA\">'.static::n($qty).'</cbc:InvoicedQuantity>',\n\t\t'<cbc:LineExtensionAmount currencyID=\"'.$currency.'\">'.static::n($net).'</cbc:LineExtensionAmount>',\n\t\t'<cac:Item><cbc:Name>'.static::esc((string)($line['description'] ?? void)).'</cbc:Name>',\n\t\t'<cac:ClassifiedTaxCategory><cbc:ID>'.$category.'</cbc:ID><cbc:Percent>'.static::n((float)$rate).'</cbc:Percent>',\n\t\t'<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme></cac:ClassifiedTaxCategory></cac:Item>',\n\t\t'<cac:Price><cbc:PriceAmount currencyID=\"'.$currency.'\">'.static::n((float)($line['unit_price'] ?? 0)).'</cbc:PriceAmount></cac:Price>',\n\t\t'</cac:InvoiceLine>',\n\t])",
                        "line": 67,
                        "bodyLine": 68
                    },
                    "partyXml": {
                        "node": "static",
                        "visibility": null,
                        "name": "partyXml",
                        "args": "$wrapper, $name, $info, $vatNumber",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$address = (string)($info['address'] ?? void)\n\t$postal = (string)($info['postal_code'] ?? void)\n\t$city = (string)($info['city'] ?? void)\n\t$country = (string)($info['country'] ?? void) ?: 'NL'\n\t$tax = $vatNumber ? '<cac:PartyTaxScheme><cbc:CompanyID>'.static::esc($vatNumber).'</cbc:CompanyID><cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme></cac:PartyTaxScheme>' : void\n\treturn implode(void, [\n\t\t'<cac:'.$wrapper.'><cac:Party>',\n\t\t'<cac:PartyName><cbc:Name>'.static::esc($name).'</cbc:Name></cac:PartyName>',\n\t\t'<cac:PostalAddress>',\n\t\t'<cbc:StreetName>'.static::esc($address).'</cbc:StreetName>',\n\t\t'<cbc:CityName>'.static::esc($city).'</cbc:CityName>',\n\t\t'<cbc:PostalZone>'.static::esc($postal).'</cbc:PostalZone>',\n\t\t'<cac:Country><cbc:IdentificationCode>'.static::esc(strtoupper($country)).'</cbc:IdentificationCode></cac:Country>',\n\t\t'</cac:PostalAddress>',\n\t\t$tax,\n\t\t'<cac:PartyLegalEntity><cbc:RegistrationName>'.static::esc($name).'</cbc:RegistrationName></cac:PartyLegalEntity>',\n\t\t'</cac:Party></cac:'.$wrapper.'>',\n\t])",
                        "line": 82,
                        "bodyLine": 83
                    },
                    "n": {
                        "node": "static",
                        "visibility": null,
                        "name": "n",
                        "args": "$v",
                        "type": "string",
                        "operator": "arrow",
                        "body": "number_format((float)$v, 2, dot, void)",
                        "line": 103,
                        "bodyLine": 103
                    },
                    "esc": {
                        "node": "static",
                        "visibility": null,
                        "name": "esc",
                        "args": "$v",
                        "type": "string",
                        "operator": "arrow",
                        "body": "htmlspecialchars((string)$v, ENT_XML1 | ENT_QUOTES, 'UTF-8')",
                        "line": 104,
                        "bodyLine": 104
                    }
                },
                "functions": [],
                "assets": []
            },
            "XLSX": {
                "file": "/srv/control/phlo/resources/files/XLSX.phlo",
                "class": "XLSX",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "XLSX reader resource",
                    "advice": "Reads a workbook without a library by unpacking the zip itself, and gives every sheet by name with the first row as the header. Values arrive as the sheet stored them, so a date is the serial number Excel keeps and a percentage is a fraction; convert those yourself. Formulas give the last calculated value, so a sheet that was never opened after an edit hands you the old one.",
                    "package": "files",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "php-ext:zip",
                    "tags": "file xlsx excel reader"
                },
                "nodes": {
                    "__construct": {
                        "node": "method",
                        "visibility": null,
                        "name": "__construct",
                        "args": "string $file",
                        "type": null,
                        "operator": "method",
                        "body": "\t$sheets = []\n\t$shared = []\n\t$sheetNames = []\n\t$zip = new ZipArchive()\n\tif ($zip->open($file) !== true) dx('error opening zip', $file)\n\tfor ($i = 0; $i < $zip->numFiles; $i++){\n\t\t$name = $zip->getNameIndex($i)\n\t\tif ($name === false) continue\n\t\tif (dirname($name) === 'xl/worksheets') $sheets[filter_var($name, FILTER_SANITIZE_NUMBER_INT)] = $zip->getFromIndex($i)\n\t\telseif ($name === 'xl/sharedStrings.xml'){\n\t\t\t$xml = $zip->getFromIndex($i)\n\t\t\tif (!preg_match_all('/<t[^>]*>(.*?)<\\/t>/s', $xml, $m)) dx('error reading shared lib')\n\t\t\t$shared = array_map(fn($t) => html_entity_decode($t, ENT_QUOTES | ENT_XML1, 'UTF-8'), $m[1])\n\t\t}\n\t\telseif ($name === 'xl/workbook.xml'){\n\t\t\t$xml = $zip->getFromIndex($i)\n\t\t\tif (!preg_match_all('/<sheet[^>]*name=\"([^\"]+)\"[^>]*sheetId=\"([0-9]+)\"/', $xml, $m)) dx('error reading workbook')\n\t\t\t$sheetNames = $m[1]\n\t\t}\n\t}\n\t$zip->close()\n\t$toIndex = fn($letters) => array_reduce(str_split(strtoupper($letters)), fn($n, $c) => $n * 26 + ord($c) - 64, 0) - 1\n\t$isShared = fn($attrs) => preg_match('/\\bt=\"s\"\\b/', $attrs) === 1\n\tforeach ($sheets AS $sheetID => $sheet){\n\t\t$name = $sheetNames[$sheetID - 1] ?? 'Sheet '.$sheetID\n\t\tif (!preg_match('/<row[^>]*>(.+)<\\/row>/s', $sheet, $m)) dx('error parsing sheet')\n\t\t$rowsXml = preg_split('/<\\/row><row[^>]*>/', $m[1]) ?: []\n\t\t$headerMap = []\n\t\t$isHeader = true\n\t\tforeach ($rowsXml AS $rowXml){\n\t\t\t$rowXml = preg_replace('/<c([^>]*)\\/>/', '<c$1></c>', $rowXml)\n\t\t\tif (!preg_match_all('/<c r=\"([A-Z]+)[0-9]+\"([^>]*)>(?:<f\\b[^>]*\\/?>)?(?:(?:<v>([^<]*)<\\/v>)|(?:<is>.*?<t[^>]*>(.*?)<\\/t>.*?<\\/is>))?<\\/c>/s', $rowXml, $mm)) dx('error parsing row', $rowXml)\n\t\t\tif ($isHeader){\n\t\t\t\tforeach (array_keys($mm[0]) AS $i){\n\t\t\t\t\t$col = $toIndex($mm[1][$i])\n\t\t\t\t\t$attrs = $mm[2][$i]\n\t\t\t\t\t$valV = $mm[3][$i] ?? null\n\t\t\t\t\t$valIS = $mm[4][$i] ?? null\n\t\t\t\t\t$val = $valV !== null && $valV !== void ? $valV : ($valIS !== null && $valIS !== void ? html_entity_decode($valIS, ENT_QUOTES | ENT_XML1, 'UTF-8') : null)\n\t\t\t\t\t$txt = $isShared($attrs) ? ($shared[$val] ?? null) : $val\n\t\t\t\t\t$headerMap[$col] = $txt !== null && $txt !== void ? $txt : 'col'.$col\n\t\t\t\t}\n\t\t\t\t$isHeader = false\n\t\t\t}\n\t\t\telse {\n\t\t\t\t$rowArr = []\n\t\t\t\tforeach (array_keys($mm[0]) AS $i){\n\t\t\t\t\t$col = $toIndex($mm[1][$i])\n\t\t\t\t\t$attrs = $mm[2][$i]\n\t\t\t\t\t$valV = $mm[3][$i] ?? null\n\t\t\t\t\t$valIS = $mm[4][$i] ?? null\n\t\t\t\t\t$val = $valV !== null && $valV !== void ? $valV : ($valIS !== null && $valIS !== void ? html_entity_decode($valIS, ENT_QUOTES | ENT_XML1, 'UTF-8') : null)\n\t\t\t\t\t$key = $headerMap[$col] ?? 'col'.$col\n\t\t\t\t\t$rowArr[$key] = $isShared($attrs) ? ($shared[$val] ?? null) : $val\n\t\t\t\t}\n\t\t\t\t$this->objData[$name][] = $rowArr\n\t\t\t}\n\t\t}\n\t}",
                        "line": 16,
                        "comments": "Reads the sheets straight out of the zip with regular expressions instead of an XML parser.\nThat keeps the resource free of dependencies and fast enough for a file a person would\nopen by hand. A text cell holds an index into the shared strings table rather than the\ntext itself, which is what the t=\"s\" check resolves. The first row of each sheet is taken\nas the header.",
                        "bodyLine": 17
                    }
                },
                "functions": [],
                "assets": []
            }
        }
    },
    "payments": {
        "objs": {
            "Stripe": {
                "file": "/srv/control/phlo/resources/payments/Stripe.phlo",
                "class": "Stripe",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Thin Stripe wrappers: Checkout, Billing Portal, prices, customers, subscriptions and webhook verification. Call Stripe::boot($secret) first. Requires the Stripe PHP SDK (composer: stripe/stripe-php).",
                    "advice": "A thin layer over the Stripe SDK, which has to be installed and booted with Stripe::boot($secret) before anything else. Checkout and the billing portal are hosted by Stripe, so card details never touch your server. Verify a webhook before you read it: an unverified POST is a stranger claiming a payment, and verifyWebhook() is what turns it into a fact.",
                    "package": "payments",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "stripe payments checkout subscription billing webhook"
                },
                "nodes": {
                    "boot": {
                        "node": "static",
                        "visibility": null,
                        "name": "boot",
                        "args": "$secret, $version = '2025-09-30.clover'",
                        "type": "void",
                        "operator": "method",
                        "body": "\t$secret = trim((string)$secret)\n\tif (!$secret) error('Stripe secret key not configured', 500)\n\t\\Stripe\\Stripe::setApiKey($secret)\n\t$version && \\Stripe\\Stripe::setApiVersion($version)",
                        "line": 10,
                        "bodyLine": 11
                    },
                    "price": {
                        "node": "static",
                        "visibility": null,
                        "name": "price",
                        "args": "$lookupKey",
                        "type": null,
                        "operator": "method",
                        "body": "\t$prices = \\Stripe\\Price::all(['lookup_keys' => [$lookupKey], 'limit' => 1, 'active' => true])\n\treturn $prices->data[0] ?? null",
                        "line": 17,
                        "bodyLine": 18
                    },
                    "customer": {
                        "node": "static",
                        "visibility": null,
                        "name": "customer",
                        "args": "$id",
                        "type": null,
                        "operator": "method",
                        "body": "\ttry {\n\t\t$c = \\Stripe\\Customer::retrieve((string)$id)\n\t\treturn ($c->deleted ?? false) ? null : $c\n\t}\n\tcatch (\\Stripe\\Exception\\ApiErrorException $e){\n\t\treturn null\n\t}",
                        "line": 22,
                        "bodyLine": 23
                    },
                    "createCustomer": {
                        "node": "static",
                        "visibility": null,
                        "name": "createCustomer",
                        "args": "array $data",
                        "type": null,
                        "operator": "arrow",
                        "body": "\\Stripe\\Customer::create($data)",
                        "line": 32,
                        "bodyLine": 32
                    },
                    "checkout": {
                        "node": "static",
                        "visibility": null,
                        "name": "checkout",
                        "args": "array $params",
                        "type": null,
                        "operator": "arrow",
                        "body": "\\Stripe\\Checkout\\Session::create($params)",
                        "line": 34,
                        "bodyLine": 34
                    },
                    "portal": {
                        "node": "static",
                        "visibility": null,
                        "name": "portal",
                        "args": "$customerId, $returnUrl",
                        "type": null,
                        "operator": "arrow",
                        "body": "\\Stripe\\BillingPortal\\Session::create(['customer' => (string)$customerId, 'return_url' => (string)$returnUrl])",
                        "line": 36,
                        "bodyLine": 36
                    },
                    "verifyWebhook": {
                        "node": "static",
                        "visibility": null,
                        "name": "verifyWebhook",
                        "args": "$payload, $sigHeader, $secret",
                        "type": null,
                        "operator": "method",
                        "body": "\t$secret = trim((string)$secret)\n\tif (!$secret) error('Stripe webhook secret not configured', 500)\n\treturn \\Stripe\\Webhook::constructEvent((string)$payload, (string)$sigHeader, $secret)",
                        "line": 38,
                        "bodyLine": 39
                    },
                    "subscriptions": {
                        "node": "static",
                        "visibility": null,
                        "name": "subscriptions",
                        "args": "$customerId, $status = 'all', $limit = 10",
                        "type": null,
                        "operator": "arrow",
                        "body": "\\Stripe\\Subscription::all(['customer' => (string)$customerId, 'status' => $status, 'limit' => $limit])",
                        "line": 44,
                        "bodyLine": 44
                    },
                    "subscription": {
                        "node": "static",
                        "visibility": null,
                        "name": "subscription",
                        "args": "$id",
                        "type": null,
                        "operator": "arrow",
                        "body": "\\Stripe\\Subscription::retrieve((string)$id)",
                        "line": 46,
                        "bodyLine": 46
                    },
                    "product": {
                        "node": "static",
                        "visibility": null,
                        "name": "product",
                        "args": "$id",
                        "type": null,
                        "operator": "arrow",
                        "body": "\\Stripe\\Product::retrieve((string)$id)",
                        "line": 48,
                        "bodyLine": 48
                    }
                },
                "functions": [],
                "assets": []
            },
            "SumUp": {
                "file": "/srv/control/phlo/resources/payments/SumUp.phlo",
                "class": "SumUp",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "SumUp connector: card-present checkouts on paired Solo readers via the Cloud API, transaction lookup and history",
                    "extends": "Connector",
                    "package": "payments",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@Connector creds:SumUp",
                    "advice": "Cloud-initiated terminal payments: the POS creates a checkout for a paired reader over HTTPS, the reader wakes up with the amount, and the result arrives on the return_url webhook or by polling transaction(). A checkout must start on the device within 60 seconds and only one checkout can be active per reader at a time. Treat the webhook as a wake-up call, never as the verdict: re-fetch the transaction with transaction() before recording a payment, so a forged POST can never book money.",
                    "tags": "sumup payments terminal reader card-present checkout connector"
                },
                "nodes": {
                    "section": {
                        "node": "const",
                        "visibility": null,
                        "name": "section",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'SumUp'",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "api": {
                        "node": "const",
                        "visibility": null,
                        "name": "api",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "'https://api.sumup.com'",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "headers": {
                        "node": "method",
                        "visibility": null,
                        "name": "headers",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "[static::bearer($this->config['api_key'] ?? void)]",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "fields": {
                        "node": "static",
                        "visibility": null,
                        "name": "fields",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "arr(\n\tsection: 'SumUp',\n\tconfig: arr(\n\t\tmerchant_code: 'Merchant code (profile > merchant profile)',\n\t\treader_id: 'Default paired reader id (optional; every call accepts an explicit one)',\n\t),\n\tsecret: arr(api_key: 'API key (developer.sumup.com > API keys)'),\n)",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "merchant": {
                        "node": "method",
                        "visibility": null,
                        "name": "merchant",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "'v0.1/merchants/'.($this->config['merchant_code'] ?? void)",
                        "line": 26,
                        "bodyLine": 26
                    },
                    "readers": {
                        "node": "method",
                        "visibility": null,
                        "name": "readers",
                        "args": null,
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('merchant_code', 'api_key')) return $m\n\treturn $this->get($this->merchant.'/readers')",
                        "line": 31,
                        "comments": "The merchant's paired readers.\nA reader id changes when a device is re-paired, so resolve by name and store the id;\nnever assume it is permanent.",
                        "bodyLine": 32
                    },
                    "reader": {
                        "node": "method",
                        "visibility": null,
                        "name": "reader",
                        "args": "?string $readerId = null",
                        "type": "string",
                        "operator": "arrow",
                        "body": "(string)($readerId ?? $this->config['reader_id'] ?? void)",
                        "line": 36,
                        "bodyLine": 36
                    },
                    "createReaderCheckout": {
                        "node": "method",
                        "visibility": null,
                        "name": "createReaderCheckout",
                        "args": "int $amountMinor, string $currency = 'EUR', ?string $readerId = null, ?string $description = null, ?string $returnUrl = null",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('merchant_code', 'api_key')) return $m\n\t$reader = $this->reader($readerId)\n\tif ($reader === void) return static::fail('SumUp reader id not configured')\n\t$body = ['total_amount' => ['value' => $amountMinor, 'currency' => $currency, 'minor_unit' => 2]]\n\tif ($description !== null) $body['description'] = $description\n\tif ($returnUrl !== null) $body['return_url'] = $returnUrl\n\treturn $this->post($this->merchant.'/readers/'.$reader.'/checkout', $body)",
                        "line": 41,
                        "comments": "Start a card-present checkout on a reader. The amount is in minor units (cents).\nThe response carries data->data->client_transaction_id: keep it, that is the handle for\nthe webhook, polling and refunds.",
                        "bodyLine": 42
                    },
                    "terminateReaderCheckout": {
                        "node": "method",
                        "visibility": null,
                        "name": "terminateReaderCheckout",
                        "args": "?string $readerId = null",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('merchant_code', 'api_key')) return $m\n\t$reader = $this->reader($readerId)\n\tif ($reader === void) return static::fail('SumUp reader id not configured')\n\treturn $this->post($this->merchant.'/readers/'.$reader.'/terminate')",
                        "line": 54,
                        "comments": "Stops the active checkout on a reader.\nOnly works while the device is still waiting for the cardholder, and the response\ncarries no confirmation either way.",
                        "bodyLine": 55
                    },
                    "transaction": {
                        "node": "method",
                        "visibility": null,
                        "name": "transaction",
                        "args": "string $clientTransactionId",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('merchant_code', 'api_key')) return $m\n\treturn $this->get('v0.1/me/transactions', ['client_transaction_id' => $clientTransactionId])",
                        "line": 64,
                        "comments": "Looks up a transaction by the client transaction id a checkout returned.\nStatuses are PENDING, SUCCESSFUL, CANCELLED and FAILED. A checkout the cardholder has\nnot touched yet may yield 404 until it expires; treat that as still pending.",
                        "bodyLine": 65
                    },
                    "transactions": {
                        "node": "method",
                        "visibility": null,
                        "name": "transactions",
                        "args": "array $query = []",
                        "type": "obj",
                        "operator": "method",
                        "body": "\tif ($m = $this->missing('merchant_code', 'api_key')) return $m\n\treturn $this->get('v0.1/me/transactions/history', $query)",
                        "line": 69,
                        "bodyLine": 70
                    }
                },
                "functions": [],
                "assets": []
            }
        }
    },
    "security": {
        "objs": {
            "audit": {
                "file": "/srv/control/phlo/resources/security/audit.phlo",
                "class": "audit",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Audit log for model mutations (opt-in via static idColumn/objAudit). Schema: resources/security/audit.sql",
                    "advice": "Off unless a model sets objAudit, and then every create, change and delete is written: an update as the difference between before and after, a create as the new row, a delete as the row that went. Pass exclude for columns you would rather not keep, a password hash or a token; the log outlives the record, so what goes in is a decision, not a detail. purge() is there because a log nobody prunes eventually costs more than the table it watches.",
                    "package": "security",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "audit log compliance traceability"
                },
                "nodes": {
                    "log": {
                        "node": "static",
                        "visibility": null,
                        "name": "log",
                        "args": "$model, $action, $before = [], $after = [], $exclude = []",
                        "type": "void",
                        "operator": "method",
                        "body": "\t$class = is_object($model) ? get_class($model) : (string)$model\n\t$pk = (is_string($class) && class_exists($class) && property_exists($class, 'idColumn')) ? $class::$idColumn : 'id'\n\t$id = is_object($model) ? ($model->$pk ?? $model->id ?? null) : null\n\tif ($id === null) return\n\t$before = (array)$before\n\t$after = (array)$after\n\tforeach ($exclude AS $col) unset($before[$col], $after[$col])\n\t$changes = $action === 'update' ? static::diff($before, $after) : ($action === 'create' ? $after : $before)\n\t$class::DB()->query(\n\t\t'INSERT INTO audit_log (ts, '.$class::DB()->quoteId('user').', model, record_id, action, changes, ip) VALUES (?, ?, ?, ?, ?, ?, ?)',\n\t\ttime(),\n\t\tclass_exists('session') && isset(%session->user) ? (int)%session->user : null,\n\t\t$class,\n\t\t(string)$id,\n\t\t$action,\n\t\tjson_encode($changes, jsonFlat),\n\t\t(string)($_SERVER['REMOTE_ADDR'] ?? null),\n\t)",
                        "line": 10,
                        "bodyLine": 11
                    },
                    "diff": {
                        "node": "static",
                        "visibility": null,
                        "name": "diff",
                        "args": "$before, $after",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$changed = []\n\tforeach ($after AS $col => $newVal){\n\t\t$oldVal = $before[$col] ?? null\n\t\tif ($oldVal !== $newVal) $changed[$col] = ['from' => $oldVal, 'to' => $newVal]\n\t}\n\treturn $changed",
                        "line": 31,
                        "bodyLine": 32
                    },
                    "history": {
                        "node": "static",
                        "visibility": null,
                        "name": "history",
                        "args": "$model, $recordId, $limit = 50",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$class = is_object($model) ? get_class($model) : (string)$model\n\treturn $class::DB()->query(\n\t\t'SELECT * FROM audit_log WHERE model=? AND record_id=? ORDER BY ts DESC LIMIT ?',\n\t\t$class, (string)$recordId, (int)$limit,\n\t)->fetchAll(\\PDO::FETCH_OBJ)",
                        "line": 40,
                        "bodyLine": 41
                    },
                    "byUser": {
                        "node": "static",
                        "visibility": null,
                        "name": "byUser",
                        "args": "$model, $userId, $fromTs = 0, $limit = 100",
                        "type": "array",
                        "operator": "method",
                        "body": "\treturn $model::DB()->query(\n\t\t'SELECT * FROM audit_log WHERE '.$model::DB()->quoteId('user').'=? AND ts >= ? ORDER BY ts DESC LIMIT ?',\n\t\t(int)$userId, (int)$fromTs, (int)$limit,\n\t)->fetchAll(\\PDO::FETCH_OBJ)",
                        "line": 48,
                        "bodyLine": 49
                    },
                    "purge": {
                        "node": "static",
                        "visibility": null,
                        "name": "purge",
                        "args": "$model, $olderThanSeconds = 31536000",
                        "type": null,
                        "operator": "arrow",
                        "body": "$model::DB()->query('DELETE FROM audit_log WHERE ts < ?', time() - $olderThanSeconds)",
                        "line": 55,
                        "bodyLine": 55
                    }
                },
                "functions": [],
                "assets": []
            },
            "captcha": {
                "file": "/srv/control/phlo/resources/security/captcha.phlo",
                "class": "captcha",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Self-contained interactive slider-puzzle captcha (no external service). The server picks a secret gap position and renders the background plus a loose piece with GD; the client drags the piece into place. verify() checks the end position plus human drag behaviour (time, path, variation). Single-use and session-bound; the gap position never leaves the server.",
                    "advice": "Runs entirely on your own server, so no visitor is handed to a third party and nothing has to be disclosed in a privacy statement. The gap position never leaves the server, and it judges the drag as well as the endpoint, so a script that jumps straight to the answer is refused. verify() does not consume the puzzle: call consume() yourself, and only on success, or a failed attempt costs the visitor their challenge. It needs GD.",
                    "package": "security",
                    "frontend": "true",
                    "backend": "true",
                    "requires": "@session lang DOM/exists php-ext:gd",
                    "tags": "captcha spam bot human-verification security"
                },
                "nodes": {
                    "W": {
                        "node": "static",
                        "visibility": null,
                        "name": "W",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "300",
                        "line": 11,
                        "bodyLine": 11
                    },
                    "H": {
                        "node": "static",
                        "visibility": null,
                        "name": "H",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "180",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "P": {
                        "node": "static",
                        "visibility": null,
                        "name": "P",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "56",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "tol": {
                        "node": "static",
                        "visibility": null,
                        "name": "tol",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "8",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "ttl": {
                        "node": "static",
                        "visibility": null,
                        "name": "ttl",
                        "args": null,
                        "type": null,
                        "operator": "value",
                        "body": "600",
                        "line": 15,
                        "bodyLine": 15
                    },
                    "issue": {
                        "node": "static",
                        "visibility": null,
                        "name": "issue",
                        "args": null,
                        "type": "array",
                        "operator": "method",
                        "body": "\t$gapX = random_int(static::$P + 14, static::$W - static::$P - 6)\n\t$gapY = random_int(12, static::$H - static::$P - 12)\n\t%session->captcha = arr(gap: $gapX, exp: time() + static::$ttl)\n\treturn arr(gapX: $gapX, gapY: $gapY)",
                        "line": 17,
                        "bodyLine": 18
                    },
                    "images": {
                        "node": "static",
                        "visibility": null,
                        "name": "images",
                        "args": "$gapX, $gapY",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$w = static::$W\n\t$h = static::$H\n\t$p = static::$P\n\t$bg = imagecreatetruecolor($w, $h)\n\t$r0 = random_int(50, 170)\n\t$g0 = random_int(50, 170)\n\t$b0 = random_int(50, 170)\n\tfor ($x = 0; $x < $w; $x++){\n\t\t$col = imagecolorallocate($bg, (int)($r0 + $x * 0.5) % 256, (int)($g0 + $x * 0.2 + 30) % 256, (int)($b0 + ($w - $x) * 0.4) % 256)\n\t\timagefilledrectangle($bg, $x, 0, $x, $h, $col)\n\t}\n\timagealphablending($bg, true)\n\tfor ($i = 0; $i < 7; $i++){\n\t\t$col = imagecolorallocatealpha($bg, random_int(0, 255), random_int(0, 255), random_int(0, 255), random_int(55, 95))\n\t\timagefilledellipse($bg, random_int(0, $w), random_int(0, $h), random_int(50, 130), random_int(50, 130), $col)\n\t}\n\t$piece = imagecreatetruecolor($p, $p)\n\timagecopy($piece, $bg, 0, 0, $gapX, $gapY, $p, $p)\n\timagerectangle($piece, 0, 0, $p - 1, $p - 1, imagecolorallocate($piece, 245, 245, 245))\n\timagefilledrectangle($bg, $gapX, $gapY, $gapX + $p - 1, $gapY + $p - 1, imagecolorallocatealpha($bg, 0, 0, 0, 95))\n\timagerectangle($bg, $gapX, $gapY, $gapX + $p - 1, $gapY + $p - 1, imagecolorallocate($bg, 255, 255, 255))\n\tob_start()\n\timagepng($bg)\n\t$bgData = base64_encode(ob_get_clean())\n\tob_start()\n\timagepng($piece)\n\t$pieceData = base64_encode(ob_get_clean())\n\treturn arr(bg: 'data:image/png;base64,'.$bgData, piece: 'data:image/png;base64,'.$pieceData)",
                        "line": 24,
                        "bodyLine": 25
                    },
                    "verify": {
                        "node": "static",
                        "visibility": null,
                        "name": "verify",
                        "args": "$x, $telemetry",
                        "type": "bool",
                        "operator": "method",
                        "body": "\t$c = %session->captcha ?? null\n\tif (!$c || time() > (int)($c['exp'] ?? 0)) return false\n\tif (abs((int)round((float)$x) - (int)($c['gap'] ?? -999)) > static::$tol) return false\n\t$t = json_decode((string)$telemetry, true)\n\tif (!is_array($t)) return false\n\t$d = (int)($t['d'] ?? 0)\n\t$n = (int)($t['n'] ?? 0)\n\t$xs = is_array($t['x'] ?? null) ? array_values($t['x']) : []\n\tif ($d < 200 || $d > 60000) return false\n\tif ($n < 5 || count($xs) < 4) return false\n\t$deltas = []\n\tfor ($i = 1; $i < count($xs); $i++) $deltas[] = (float)$xs[$i] - (float)$xs[$i - 1]\n\tif (array_sum(array_map('abs', $deltas)) < 30) return false\n\t$mean = array_sum($deltas) / count($deltas)\n\t$var = 0\n\tforeach ($deltas AS $dd) $var += ($dd - $mean) ** 2\n\tif (sqrt($var / count($deltas)) < 0.5) return false\n\treturn true",
                        "line": 59,
                        "comments": "Judges the drag as much as where it ended.\nA movement under 200ms or over a minute, a path of fewer than five points, or one with\nalmost no variation between steps reads as a script rather than a hand. The endpoint\nalone is not enough, because that is the one thing a script gets right.",
                        "bodyLine": 60
                    },
                    "consume": {
                        "node": "static",
                        "visibility": null,
                        "name": "consume",
                        "args": null,
                        "type": "void",
                        "operator": "method",
                        "body": "\tunset(%session->captcha)",
                        "line": 80,
                        "bodyLine": 81
                    },
                    "widget": {
                        "node": "method",
                        "visibility": null,
                        "name": "widget",
                        "args": null,
                        "type": "string",
                        "operator": "method",
                        "body": "\t$ch = static::issue()\n\t$img = static::images($ch['gapX'], $ch['gapY'])\n\treturn (string)$this->field($img['bg'], $img['piece'], $ch['gapY'], static::$W, static::$H, static::$P)",
                        "line": 84,
                        "bodyLine": 85
                    },
                    "field": {
                        "node": "view",
                        "visibility": null,
                        "name": "field",
                        "args": "$bg, $piece, $gapY, $w, $h, $p",
                        "type": null,
                        "operator": "view",
                        "body": "<div#captcha data-w=\"$w\" data-h=\"$h\" data-p=\"$p\" data-gapy=\"$gapY\">\n\t<div.stage>\n\t\t<img.bg src=\"{{ $bg }}\" alt=\"\">\n\t\t<img.piece src=\"{{ $piece }}\" alt=\"\" draggable=\"false\">\n\t</div>\n\t<div.track>\n\t\t<div.fill></div>\n\t\t<button.thumb type=\"button\" tabindex=\"-1\" aria-label=\"{{ en('Drag the slider until the piece fits') }}\">&#8250;&#8250;</button>\n\t</div>\n\t<p.hint>{en: Drag the slider until the piece snaps into place}</p>\n\t<input type=hidden name=captcha_x>\n\t<input type=hidden name=captcha_t>\n</div>",
                        "line": 90
                    }
                },
                "functions": [],
                "assets": [
                    {
                        "node": "style",
                        "ns": null,
                        "line": 105,
                        "body": "#captcha {\n\tmargin: 14px 0 4px\n\tmax-width: 300px\n}\n#captcha .stage {\n\tborder-radius: 8px\n\tline-height: 0\n\toverflow: hidden\n\tposition: relative\n\ttouch-action: none\n\tuser-select: none\n\twidth: 100%\n}\n#captcha .bg {\n\tdisplay: block\n\theight: auto\n\tpointer-events: none\n\twidth: 100%\n}\n#captcha .piece {\n\tbox-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25), 0 2px 6px rgba(0, 0, 0, 0.4)\n\tleft: 6px\n\tpointer-events: none\n\tposition: absolute\n\ttop: 0\n}\n#captcha .track {\n\tbackground: #e7e5e4\n\tborder-radius: 8px\n\theight: 42px\n\tmargin-top: 10px\n\tposition: relative\n\ttouch-action: none\n}\n#captcha .fill {\n\tbackground: #fed7aa\n\tborder-radius: 8px\n\theight: 100%\n\tleft: 0\n\tposition: absolute\n\ttop: 0\n\twidth: 0\n}\n#captcha .thumb {\n\talign-items: center\n\tappearance: none\n\tbackground: #ea580c\n\tborder: 0\n\tborder-radius: 8px\n\tbox-shadow: 0 1px 3px rgba(0, 0, 0, 0.3)\n\tcolor: #fff\n\tcursor: grab\n\tdisplay: flex\n\tfont-family: inherit\n\tfont-size: 18px\n\tfont-weight: 700\n\theight: 42px\n\tjustify-content: center\n\tleft: 0\n\tmargin: 0\n\tpadding: 0\n\tposition: absolute\n\ttop: 0\n\ttouch-action: none\n\twidth: 42px\n}\n#captcha .thumb:active: cursor: grabbing\n#captcha .thumb:hover: background: #c2410c\n#captcha .hint {\n\tcolor: #78716c\n\tfont-size: 12px\n\tmargin: 6px 0 0\n}"
                    },
                    {
                        "node": "script",
                        "ns": null,
                        "line": 181,
                        "body": "const captcha = {\n\tscale: 1, startCss: 6, travelMax: 0, maxThumb: 0,\n\tdragging: false, t0: 0, samples: [],\n\tcap: null, bg: null, piece: null, thumb: null, track: null, fill: null, xField: null, tField: null, submit: null,\n\tgrab(){\n\t\tthis.cap = obj('#captcha')\n\t\tif (!this.cap) return false\n\t\tthis.bg = obj('.bg', this.cap)\n\t\tthis.piece = obj('.piece', this.cap)\n\t\tthis.thumb = obj('.thumb', this.cap)\n\t\tthis.track = obj('.track', this.cap)\n\t\tthis.fill = obj('.fill', this.cap)\n\t\tthis.xField = obj('[name=captcha_x]', this.cap)\n\t\tthis.tField = obj('[name=captcha_t]', this.cap)\n\t\tthis.submit = obj('#registerSubmit')\n\t\treturn true\n\t},\n\tlayout(){\n\t\tif (!this.grab()) return\n\t\tconst Wr = this.bg.clientWidth || +this.cap.dataset.w\n\t\tthis.scale = Wr / +this.cap.dataset.w\n\t\tconst pieceCss = +this.cap.dataset.p * this.scale\n\t\tthis.piece.style.width = pieceCss + 'px'\n\t\tthis.piece.style.height = pieceCss + 'px'\n\t\tthis.piece.style.top = (+this.cap.dataset.gapy * this.scale) + 'px'\n\t\tthis.startCss = 6 * this.scale\n\t\tthis.piece.style.left = this.startCss + 'px'\n\t\tthis.travelMax = Wr - pieceCss - this.startCss\n\t\tthis.maxThumb = this.track.clientWidth - this.thumb.clientWidth\n\t\tif (this.submit) this.submit.disabled = true\n\t},\n\tset(tx){\n\t\ttx = Math.max(0, Math.min(this.maxThumb, tx))\n\t\tthis.thumb.style.left = tx + 'px'\n\t\tthis.fill.style.width = (tx + this.thumb.clientWidth) + 'px'\n\t\tconst frac = this.maxThumb > 0 ? tx / this.maxThumb : 0\n\t\tconst pl = this.startCss + frac * (this.travelMax - this.startCss)\n\t\tthis.piece.style.left = pl + 'px'\n\t\treturn pl / this.scale\n\t},\n\tdown(e){\n\t\tif (!this.grab()) return\n\t\tthis.dragging = true\n\t\tthis.t0 = performance.now()\n\t\tthis.samples = []\n\t\te.preventDefault()\n\t},\n\tmove(e){\n\t\tif (!this.dragging) return\n\t\tconst px = e.touches ? e.touches[0].clientX : e.clientX\n\t\tconst rect = this.track.getBoundingClientRect()\n\t\tconst nx = this.set(px - rect.left - this.thumb.clientWidth / 2)\n\t\tthis.samples.push({x: nx, t: performance.now() - this.t0})\n\t},\n\tup(){\n\t\tif (!this.dragging) return\n\t\tthis.dragging = false\n\t\tconst xs = this.samples.map(s => Math.round(s.x))\n\t\tthis.xField.value = xs.length ? xs[xs.length - 1] : 0\n\t\tconst step = Math.max(1, Math.ceil(xs.length / 24))\n\t\tthis.tField.value = JSON.stringify({d: Math.round(this.samples.length ? this.samples[this.samples.length - 1].t : 0), n: this.samples.length, x: xs.filter((_, i) => i % step === 0)})\n\t\tif (this.submit) this.submit.disabled = false\n\t}\n}\n\nonExist('#captcha', () => captcha.layout())\non('load', '#captcha .bg', () => captcha.layout())\non('resize', window, () => captcha.layout())\non('mousedown touchstart', '#captcha .thumb', (el, e) => captcha.down(e))\non('mousemove touchmove', window, (el, e) => last(captcha.move(e), false))\non('mouseup touchend', window, (el, e) => captcha.up())"
                    }
                ]
            },
            "creds": {
                "file": "/srv/control/phlo/resources/security/creds.phlo",
                "class": "creds",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Credentials resolver from env and ini sources",
                    "advice": "One place for every secret, filled from data/creds.ini and from the environment, where PHLO__Section__key sets a value and PHLO_<HOST>__Section__key overrides it for one host, with <HOST> the request host uppercased and every other character an underscore. Values are wrapped so a var_dump or an error page shows stars instead of the secret. Keep the ini file out of the repository and let the environment win on a server.",
                    "package": "security",
                    "frontend": "false",
                    "backend": "true",
                    "tags": "credentials env ini secrets configuration"
                },
                "nodes": {
                    "__construct": {
                        "node": "method",
                        "visibility": null,
                        "name": "__construct",
                        "args": "?array $values = null",
                        "type": null,
                        "operator": "method",
                        "body": "\t$values ??= $this->resolve()\n\tforeach ($values AS $key => $value){\n\t\t$this->$key = is_array($value) ? new static($value) : new \\SensitiveParameterValue((string)$value)\n\t}",
                        "line": 10,
                        "bodyLine": 11
                    },
                    "resolve": {
                        "node": "method",
                        "visibility": null,
                        "name": "resolve",
                        "args": null,
                        "type": "array",
                        "operator": "method",
                        "body": "\t$data = []\n\t$this->merge($data, $this->loadINI(data.'creds.ini'))\n\t$this->merge($data, $this->envValues(false))\n\t$this->merge($data, $this->envValues(true))\n\treturn $data",
                        "line": 17,
                        "bodyLine": 18
                    },
                    "loadINI": {
                        "node": "method",
                        "visibility": null,
                        "name": "loadINI",
                        "args": "string $file",
                        "type": "array",
                        "operator": "method",
                        "body": "\tif (!is_file($file)) return []\n\t$ini = parse_ini_file($file, true, INI_SCANNER_RAW)\n\treturn is_array($ini) ? $ini : []",
                        "line": 25,
                        "bodyLine": 26
                    },
                    "envValues": {
                        "node": "method",
                        "visibility": null,
                        "name": "envValues",
                        "args": "bool $hostScoped = false",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$out = []\n\t$prefix = $hostScoped ? ('PHLO_'.$this->hostKey().'__') : 'PHLO__'\n\t$sources = []\n\tis_array($_ENV ?? null) && $sources[] = $_ENV\n\tis_array($_SERVER ?? null) && $sources[] = $_SERVER\n\tis_array($env = getenv()) && $sources[] = $env\n\tforeach ($sources AS $source){\n\t\tforeach ($source AS $key => $value){\n\t\t\t$key = (string)$key\n\t\t\tif (!str_starts_with($key, $prefix)) continue\n\t\t\t$path = substr($key, strlen($prefix))\n\t\t\tif (!$path) continue\n\t\t\t$this->envAssign($out, explode('__', $path), (string)$value)\n\t\t}\n\t}\n\treturn $out",
                        "line": 31,
                        "bodyLine": 32
                    },
                    "hostKey": {
                        "node": "method",
                        "visibility": null,
                        "name": "hostKey",
                        "args": null,
                        "type": "string",
                        "operator": "method",
                        "body": "\t$host = strtoupper(%req->host)\n\t$host = preg_replace('/[^A-Z0-9]+/', us, $host)\n\treturn trim($host, us)",
                        "line": 50,
                        "bodyLine": 51
                    },
                    "envAssign": {
                        "node": "method",
                        "visibility": null,
                        "name": "envAssign",
                        "args": "array &$target, array $parts, string $value",
                        "type": "void",
                        "operator": "method",
                        "body": "\t$parts = array_values(array_filter(loop($parts, fn($part) => trim($part)), 'strlen'))\n\tif (!$parts) return\n\t$node = &$target\n\t$last = count($parts) - 1\n\tforeach ($parts AS $i => $part){\n\t\tif ($i === $last){\n\t\t\t$node[$part] = $value\n\t\t\treturn\n\t\t}\n\t\tif (!isset($node[$part]) || !is_array($node[$part])) $node[$part] = []\n\t\t$node = &$node[$part]\n\t}",
                        "line": 56,
                        "bodyLine": 57
                    },
                    "merge": {
                        "node": "method",
                        "visibility": null,
                        "name": "merge",
                        "args": "array &$base, array $add",
                        "type": "void",
                        "operator": "method",
                        "body": "\tforeach ($add AS $key => $value){\n\t\tif (isset($base[$key]) && is_array($base[$key]) && is_array($value)){\n\t\t\t$this->merge($base[$key], $value)\n\t\t\tcontinue\n\t\t}\n\t\t$base[$key] = $value\n\t}",
                        "line": 71,
                        "bodyLine": 72
                    },
                    "objGet": {
                        "node": "method",
                        "visibility": null,
                        "name": "objGet",
                        "args": "$key",
                        "type": null,
                        "operator": "method",
                        "body": "\tif ($key === 'toArray') return loop($this->objData, fn($value) => is_a($value, 'SensitiveParameterValue') ? $value->getValue() : $value)\n\tif (isset($this->objData[$key]) && is_a($this->objData[$key], '\\SensitiveParameterValue')) return $this->objData[$key]->getValue()",
                        "line": 81,
                        "bodyLine": 82
                    },
                    "objInfo": {
                        "node": "method",
                        "visibility": null,
                        "name": "objInfo",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "loop($this->objData, fn($value) => is_a($value, '\\SensitiveParameterValue') ? str_repeat('*', strlen($value->getValue())) : $value)",
                        "line": 86,
                        "bodyLine": 86
                    }
                },
                "functions": [],
                "assets": []
            },
            "CSRF": {
                "file": "/srv/control/phlo/resources/security/CSRF.phlo",
                "class": "CSRF",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Rotating async CSRF protection for Phlo requests",
                    "advice": "Put %CSRF in your head view and it writes the meta tag the frontend reads from. The rest you wire yourself: verify() checks the X-CSRF-Token header against the session, and update() answers with a fresh token as a command the page applies to its meta tag. A route that does both makes a stolen token worth one request at most, which is the whole point; a route that only verifies keeps one token for the life of the session. It protects a session, so it says nothing about an API authenticated with a bearer token.",
                    "package": "security",
                    "frontend": "true",
                    "backend": "true",
                    "requires": "@session token payload",
                    "provides": "app.mod.csrf",
                    "tags": "csrf security async forms"
                },
                "nodes": {
                    "view": {
                        "node": "view",
                        "visibility": null,
                        "name": "view",
                        "args": null,
                        "type": null,
                        "operator": "view",
                        "body": "<meta name=csrf content=\"$this->token\">",
                        "line": 12
                    },
                    "token": {
                        "node": "prop",
                        "visibility": null,
                        "name": "token",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "%session->csrf ??= token(32)",
                        "line": 13,
                        "bodyLine": 13
                    },
                    "verify": {
                        "node": "method",
                        "visibility": null,
                        "name": "verify",
                        "args": null,
                        "type": "bool",
                        "operator": "arrow",
                        "body": "hash_equals($this->token, (string)($_SERVER['HTTP_X_CSRF_TOKEN'] ?? void))",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "update": {
                        "node": "method",
                        "visibility": null,
                        "name": "update",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "arr(csrf: $this->token = %session->csrf = token(32))",
                        "line": 15,
                        "bodyLine": 15
                    }
                },
                "functions": [],
                "assets": [
                    {
                        "node": "script",
                        "ns": null,
                        "line": 17,
                        "body": "app.mod.csrf = value => obj('meta[name=\"csrf\"]').content = value"
                    }
                ]
            },
            "JWT": {
                "file": "/srv/control/phlo/resources/security/JWT.phlo",
                "class": "JWT",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Sign and verify compact HS256 JSON Web Tokens (RFC 7519), secure by default",
                    "advice": "HS256 only, and a token that claims another algorithm is refused rather than tried, which is the classic way these are broken. The secret must be at least 32 bytes, an expiry is always written, and verify() throws with a 401 instead of returning false, so a route can simply call it. Name an issuer and it is checked as well. A signed token is readable by anyone holding it, so keep secrets out of the claims.",
                    "package": "security",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "php-ext:hash",
                    "tags": "jwt jws hs256 token auth security"
                },
                "nodes": {
                    "__construct": {
                        "node": "method",
                        "visibility": null,
                        "name": "__construct",
                        "args": "public string $secret, public string $issuer = void, public int $leeway = 30",
                        "type": null,
                        "operator": "arrow",
                        "body": "strlen($this->secret) >= 32 || error('JWT secret must be at least 32 bytes', 500)",
                        "line": 11,
                        "bodyLine": 11
                    },
                    "sign": {
                        "node": "method",
                        "visibility": null,
                        "name": "sign",
                        "args": "array $claims, int $ttl = 3600",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$now = time()\n\t$claims['iat'] = $now\n\t$claims['exp'] = $now + $ttl\n\tif ($this->issuer !== void) $claims['iss'] = $this->issuer\n\t$body = $this->encode(['alg' => 'HS256', 'typ' => 'JWT']).dot.$this->encode($claims)\n\treturn $body.dot.$this->sig($body)",
                        "line": 13,
                        "bodyLine": 14
                    },
                    "verify": {
                        "node": "method",
                        "visibility": null,
                        "name": "verify",
                        "args": "string $token",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$token = preg_replace('/^Bearer\\s+/i', void, trim($token))\n\t$parts = explode(dot, $token)\n\tcount($parts) === 3 || error('JWT malformed', 401)\n\t[$h, $p, $s] = $parts\n\t$header = (array)json_decode((string)$this->decode($h), true)\n\t($header['alg'] ?? void) === 'HS256' || error('JWT algorithm not allowed', 401)\n\thash_equals($this->sig($h.dot.$p), $s) || error('JWT signature invalid', 401)\n\t$claims = (array)json_decode((string)$this->decode($p), true)\n\t$now = time()\n\tisset($claims['nbf']) && $now + $this->leeway < $claims['nbf'] && error('JWT not yet valid', 401)\n\tisset($claims['exp']) && $now - $this->leeway >= $claims['exp'] && error('JWT expired', 401)\n\t$this->issuer === void || ($claims['iss'] ?? void) === $this->issuer || error('JWT issuer mismatch', 401)\n\treturn $claims",
                        "line": 22,
                        "bodyLine": 23
                    },
                    "sig": {
                        "node": "method",
                        "visibility": null,
                        "name": "sig",
                        "args": "string $body",
                        "type": "string",
                        "operator": "arrow",
                        "body": "$this->encode(hash_hmac('sha256', $body, $this->secret, true))",
                        "line": 38,
                        "bodyLine": 38
                    },
                    "encode": {
                        "node": "method",
                        "visibility": null,
                        "name": "encode",
                        "args": "$data",
                        "type": "string",
                        "operator": "arrow",
                        "body": "rtrim(strtr(base64_encode(is_string($data) ? $data : (string)json_encode($data, jsonFlat)), '+/', '-_'), eq)",
                        "line": 39,
                        "bodyLine": 39
                    },
                    "decode": {
                        "node": "method",
                        "visibility": null,
                        "name": "decode",
                        "args": "string $data",
                        "type": "string",
                        "operator": "arrow",
                        "body": "(string)base64_decode(strtr($data, '-_', '+/'))",
                        "line": 40,
                        "bodyLine": 40
                    }
                },
                "functions": [],
                "assets": []
            },
            "OAuth2": {
                "file": "/srv/control/phlo/resources/security/OAuth2.phlo",
                "class": "OAuth2",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Stateless OAuth2 client: build the authorize URL and exchange/refresh tokens. Token storage and config are the caller's responsibility. The protocol primitive under TokenStore and OAuthConnector.",
                    "advice": "The bare protocol: build an authorize URL, trade a code for tokens, refresh. It keeps nothing and knows nothing about your app, which is what makes it usable for any provider. For a connector you almost never need it directly, since TokenStore and OAuthConnector do the keeping for you; reach for it when you run the login yourself.",
                    "package": "security",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "HTTP",
                    "tags": "oauth oauth2 token authorization refresh authentication"
                },
                "nodes": {
                    "authorizeUrl": {
                        "node": "static",
                        "visibility": null,
                        "name": "authorizeUrl",
                        "args": "$endpoint, array $params",
                        "type": "string",
                        "operator": "arrow",
                        "body": "$endpoint.(str_contains($endpoint, '?') ? '&' : '?').http_build_query($params)",
                        "line": 11,
                        "bodyLine": 11
                    },
                    "token": {
                        "node": "static",
                        "visibility": null,
                        "name": "token",
                        "args": "$tokenUrl, $clientId, $clientSecret, $grantType, array $extra = []",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$body = ['grant_type' => $grantType, 'client_id' => (string)$clientId, 'client_secret' => (string)$clientSecret]\n\tforeach ($extra AS $key => $value){\n\t\tif ($value !== null && $value !== void) $body[$key] = $value\n\t}\n\ttry {\n\t\t$res = HTTP($tokenUrl, ['Content-Type: application/x-www-form-urlencoded', 'Accept: application/json'], POST: http_build_query($body))\n\t}\n\tcatch (\\Throwable $e){\n\t\treturn ['error' => $e->getMessage()]\n\t}\n\treturn json_decode((string)$res, true) ?: ['error' => 'Invalid token response']",
                        "line": 13,
                        "bodyLine": 14
                    },
                    "exchangeCode": {
                        "node": "static",
                        "visibility": null,
                        "name": "exchangeCode",
                        "args": "$tokenUrl, $clientId, $clientSecret, $code, $redirectUri = null, array $extra = []",
                        "type": "array",
                        "operator": "arrow",
                        "body": "static::token($tokenUrl, $clientId, $clientSecret, 'authorization_code', ['code' => $code, 'redirect_uri' => $redirectUri] + $extra)",
                        "line": 27,
                        "bodyLine": 27
                    },
                    "refresh": {
                        "node": "static",
                        "visibility": null,
                        "name": "refresh",
                        "args": "$tokenUrl, $clientId, $clientSecret, $refreshToken, array $extra = []",
                        "type": "array",
                        "operator": "arrow",
                        "body": "static::token($tokenUrl, $clientId, $clientSecret, 'refresh_token', ['refresh_token' => $refreshToken] + $extra)",
                        "line": 29,
                        "bodyLine": 29
                    }
                },
                "functions": [],
                "assets": []
            },
            "rate": {
                "file": "/srv/control/phlo/resources/security/rate.phlo",
                "class": "rate",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Rate-limit (fixed window) on the rate_limit table. Schema: resources/security/rate.sql",
                    "advice": "A fixed window, counted in one atomic statement, so two requests arriving together cannot both slip past the limit. Storage db survives a restart and is shared across machines; apcu is faster but lives in one server's shared memory, so it is gone after a restart and says nothing about a second machine. Because the window is fixed rather than sliding, a caller can spend a full limit at the end of one window and again at the start of the next.",
                    "package": "security",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "@MySQL",
                    "tags": "rate limit throttle abuse"
                },
                "nodes": {
                    "check": {
                        "node": "static",
                        "visibility": null,
                        "name": "check",
                        "args": "$key, $limit, $windowSeconds, $storage = 'db'",
                        "type": "bool",
                        "operator": "method",
                        "body": "\tif ($storage === 'apcu') return static::checkApcu($key, $limit, $windowSeconds)\n\t$now = time()\n\t// One atomic upsert: increment within the window or reset to a new one, and carry the\n\t// resulting per-request count out via LAST_INSERT_ID. A separate read-then-write would\n\t// let two requests arriving together both pass the limit.\n\t%MySQL->query('INSERT INTO rate_limit (rkey, count, window_start) VALUES (?, LAST_INSERT_ID(1), ?) ON DUPLICATE KEY UPDATE count = LAST_INSERT_ID(IF(? - window_start < ?, count + 1, 1)), window_start = IF(? - window_start < ?, window_start, ?)', $key, $now, $now, $windowSeconds, $now, $windowSeconds, $now)\n\treturn (int)%MySQL->query('SELECT LAST_INSERT_ID()')->fetchColumn() <= $limit",
                        "line": 11,
                        "bodyLine": 12
                    },
                    "checkApcu": {
                        "node": "static",
                        "visibility": null,
                        "name": "checkApcu",
                        "args": "$key, $limit, $windowSeconds",
                        "type": "bool",
                        "operator": "method",
                        "body": "\t$window = (int)floor(time() / $windowSeconds) * $windowSeconds\n\t$apcuKey = 'phlo.rate.'.$key.':'.$window\n\tapcu_add($apcuKey, 0, $windowSeconds)\n\treturn apcu_inc($apcuKey, 1) <= $limit",
                        "line": 21,
                        "bodyLine": 22
                    },
                    "status": {
                        "node": "static",
                        "visibility": null,
                        "name": "status",
                        "args": "$key, $limit, $windowSeconds",
                        "type": "obj",
                        "operator": "method",
                        "body": "\t$now = time()\n\t$row = %MySQL->query('SELECT count, window_start FROM rate_limit WHERE rkey=?', $key)->fetchObject('obj') ?: null\n\tif (!$row || ($now - (int)$row->window_start) >= $windowSeconds) return obj(used: 0, limit: $limit, resetIn: 0)\n\treturn obj(used: (int)$row->count, limit: $limit, resetIn: max(0, ((int)$row->window_start + $windowSeconds) - $now))",
                        "line": 28,
                        "bodyLine": 29
                    },
                    "reset": {
                        "node": "static",
                        "visibility": null,
                        "name": "reset",
                        "args": "$key",
                        "type": null,
                        "operator": "arrow",
                        "body": "%MySQL->query('DELETE FROM rate_limit WHERE rkey=?', $key)",
                        "line": 35,
                        "bodyLine": 35
                    },
                    "purge": {
                        "node": "static",
                        "visibility": null,
                        "name": "purge",
                        "args": "$olderThanSeconds = 604800",
                        "type": null,
                        "operator": "arrow",
                        "body": "%MySQL->query('DELETE FROM rate_limit WHERE window_start < ?', time() - $olderThanSeconds)",
                        "line": 36,
                        "bodyLine": 36
                    }
                },
                "functions": [],
                "assets": []
            },
            "security": {
                "file": "/srv/control/phlo/resources/security/security.phlo",
                "class": "security",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Generic security resource",
                    "advice": "Pick a profile per app rather than writing headers yourself: strict allows only nonced scripts and styles, basic allows your own files, marketing also allows images from anywhere, and api shuts everything off and marks the response as an API. Async responses get no policy, because the page they land in already has one. Add a CDN or a media host to sources and a domain that may frame you to whitelist; those are the two escape hatches, and everything else stays closed. Under debug, basic and marketing let inline scripts through so the debug console runs, so a production site with debug on is running a weaker policy than it thinks.",
                    "package": "security",
                    "frontend": "true",
                    "backend": "true",
                    "requires": "@session token",
                    "tags": "security csp nonce headers"
                },
                "nodes": {
                    "whitelist": {
                        "node": "prop",
                        "visibility": null,
                        "name": "whitelist",
                        "args": null,
                        "type": "array",
                        "operator": "value",
                        "body": "[]",
                        "line": 11,
                        "bodyLine": 11
                    },
                    "sources": {
                        "node": "prop",
                        "visibility": null,
                        "name": "sources",
                        "args": null,
                        "type": "array",
                        "operator": "value",
                        "body": "[]",
                        "line": 12,
                        "bodyLine": 12
                    },
                    "setNonce": {
                        "node": "method",
                        "visibility": null,
                        "name": "setNonce",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "%app->nonce = token(8)",
                        "line": 14,
                        "bodyLine": 14
                    },
                    "frameProtect": {
                        "node": "method",
                        "visibility": null,
                        "name": "frameProtect",
                        "args": "$mode = 'DENY'",
                        "type": null,
                        "operator": "arrow",
                        "body": "%res->header('X-Frame-Options', $mode)",
                        "line": 16,
                        "bodyLine": 16
                    },
                    "frameWhitelist": {
                        "node": "method",
                        "visibility": null,
                        "name": "frameWhitelist",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "$this->whitelist ? ' '.implode(space, array_map(fn($d) => \"https://*.$d\", (array)$this->whitelist)) : void",
                        "line": 17,
                        "bodyLine": 17
                    },
                    "sourceList": {
                        "node": "method",
                        "visibility": null,
                        "name": "sourceList",
                        "args": null,
                        "type": "string",
                        "operator": "arrow",
                        "body": "$this->sources ? ' '.implode(space, (array)$this->sources) : void",
                        "line": 19,
                        "comments": "Extra origins the app loads content from (images, media, fetch), e.g. a CDN; full origins, applied verbatim.",
                        "bodyLine": 19
                    },
                    "strict": {
                        "node": "method",
                        "visibility": null,
                        "name": "strict",
                        "args": null,
                        "type": "void",
                        "operator": "method",
                        "body": "\t$this->base\n\tif (%req->async) return\n\t%res->header('Cache-Control', 'no-store')\n\t$nonce = $this->setNonce\n\t%res->header('Content-Security-Policy', \"default-src 'self'; script-src 'nonce-$nonce'; worker-src 'self'; style-src 'self' 'nonce-$nonce'; img-src 'self' data:$this->sourceList; media-src 'self' blob:$this->sourceList; font-src 'self'; connect-src 'self'$this->sourceList; form-action 'self'; object-src 'none'; frame-src 'self'$this->frameWhitelist; frame-ancestors 'none'; base-uri 'self'\")",
                        "line": 21,
                        "bodyLine": 22
                    },
                    "basic": {
                        "node": "method",
                        "visibility": null,
                        "name": "basic",
                        "args": null,
                        "type": "void",
                        "operator": "method",
                        "body": "\t$this->base\n\tif (%req->async) return\n\t%res->header('Content-Security-Policy', \"default-src 'self'; script-src 'self'\".(debug ? \" 'unsafe-inline'\" : void).\"; style-src 'self' 'unsafe-inline'; img-src 'self' data:$this->sourceList; media-src 'self' blob:$this->sourceList; font-src 'self'; connect-src 'self'$this->sourceList; form-action 'self'; object-src 'none'; frame-src 'self'$this->frameWhitelist; frame-ancestors 'none'; base-uri 'self'\")",
                        "line": 30,
                        "comments": "Under debug, basic/marketing relax script-src to 'unsafe-inline' so the inline debug console runs.\nWhether debug is on in production is the app's own responsibility, so this is tied to debug by design.",
                        "bodyLine": 31
                    },
                    "marketing": {
                        "node": "method",
                        "visibility": null,
                        "name": "marketing",
                        "args": null,
                        "type": "void",
                        "operator": "method",
                        "body": "\t$this->base\n\tif (%req->async) return\n\t%res->header('Content-Security-Policy', \"default-src 'self'; script-src 'self'\".(debug ? \" 'unsafe-inline'\" : void).\"; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob:$this->sourceList; font-src 'self'; connect-src 'self'$this->sourceList; form-action 'self'; object-src 'none'; frame-src 'self'$this->frameWhitelist; frame-ancestors 'none'; base-uri 'self'\")",
                        "line": 35,
                        "bodyLine": 36
                    },
                    "api": {
                        "node": "method",
                        "visibility": null,
                        "name": "api",
                        "args": null,
                        "type": "void",
                        "operator": "method",
                        "body": "\t%res->api = true\n\t%res->header('Content-Security-Policy', \"default-src 'none'; frame-ancestors 'none'\")\n\t%res->header('X-Content-Type-Options', 'nosniff')\n\t%res->header('Referrer-Policy', 'no-referrer')",
                        "line": 40,
                        "bodyLine": 41
                    },
                    "base": {
                        "node": "method",
                        "visibility": null,
                        "name": "base",
                        "args": null,
                        "type": "void",
                        "operator": "method",
                        "body": "\t%res->header('Referrer-Policy', 'strict-origin-when-cross-origin')\n\t%res->header('X-Content-Type-Options', 'nosniff')\n\t%req->async || %res->header('Cross-Origin-Opener-Policy', 'same-origin')\n\t%req->async || %res->header('Cross-Origin-Resource-Policy', 'same-origin')\n\t%req->async || %res->header('Access-Control-Allow-Origin', %req->base)\n\t%req->async || %res->header('X-Frame-Options', 'DENY')",
                        "line": 47,
                        "bodyLine": 48
                    }
                },
                "functions": [],
                "assets": []
            },
            "social": {
                "file": "/srv/control/phlo/resources/security/social.phlo",
                "class": "social",
                "meta": {
                    "version": "1.0",
                    "creator": "q-ai.nl",
                    "summary": "Reusable social login (OIDC) on top of OAuth2: build the authorize URL and turn a callback code into a verified profile. Google, Microsoft and Apple. No user, session or route handling - that is the caller's responsibility.",
                    "advice": "The id_token is verified against the provider's JWKS (RS256 only, key looked up by kid) before any claim is read, on top of issuer, audience (must equal client_id), expiry and, when supplied, nonce. `verified` reports what the provider actually proved: Microsoft omits email_verified, so it counts only when the optional xms_edov claim states the tenant owns the address. Treat an unverified email as a claim, never as an identity: match users on provider + sub. Apple's client_secret is an ES256 JWT signed with the .p8 key (team_id/key_id/client_id + key from creds).",
                    "package": "security",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "OAuth2 creds payload HTTP php-ext:openssl",
                    "tags": "oauth oidc social login google microsoft apple authentication"
                },
                "nodes": {
                    "providers": {
                        "node": "static",
                        "visibility": null,
                        "name": "providers",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "arr(\n\tgoogle: arr(\n\t\tlabel: 'Google',\n\t\tauthorize: 'https://accounts.google.com/o/oauth2/v2/auth',\n\t\ttoken: 'https://oauth2.googleapis.com/token',\n\t\tjwks: 'https://www.googleapis.com/oauth2/v3/certs',\n\t\tscope: 'openid email profile',\n\t\tissuer: 'https://accounts.google.com',\n\t),\n\tmicrosoft: arr(\n\t\tlabel: 'Microsoft',\n\t\tauthorize: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',\n\t\ttoken: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',\n\t\tjwks: 'https://login.microsoftonline.com/common/discovery/v2.0/keys',\n\t\tscope: 'openid email profile',\n\t\tissuer: 'https://login.microsoftonline.com/',\n\t),\n\tapple: arr(\n\t\tlabel: 'Apple',\n\t\tauthorize: 'https://appleid.apple.com/auth/authorize',\n\t\ttoken: 'https://appleid.apple.com/auth/token',\n\t\tjwks: 'https://appleid.apple.com/auth/keys',\n\t\tscope: 'name email',\n\t\tissuer: 'https://appleid.apple.com',\n\t),\n)",
                        "line": 11,
                        "bodyLine": 11
                    },
                    "config": {
                        "node": "static",
                        "visibility": null,
                        "name": "config",
                        "args": "$provider",
                        "type": "?array",
                        "operator": "method",
                        "body": "\t$p = static::providers()[$provider] ?? null\n\tif (!$p) return null\n\t$section = %creds->{$provider} ?? null\n\t$creds = is_object($section) ? (array)$section->toArray : []\n\treturn $p + arr(\n\t\tclient_id: (string)($creds['client_id'] ?? void),\n\t\tclient_secret: (string)($creds['client_secret'] ?? void),\n\t\tredirect_uri: (string)($creds['redirect_uri'] ?? ('https://'.%req->host.'/auth/'.$provider.'/callback')),\n\t)",
                        "line": 38,
                        "bodyLine": 39
                    },
                    "configured": {
                        "node": "static",
                        "visibility": null,
                        "name": "configured",
                        "args": "$provider",
                        "type": "bool",
                        "operator": "method",
                        "body": "\t$c = static::config($provider)\n\treturn $c && $c['client_id'] !== void",
                        "line": 50,
                        "bodyLine": 51
                    },
                    "authUrl": {
                        "node": "static",
                        "visibility": null,
                        "name": "authUrl",
                        "args": "$provider, $state, $nonce = void",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$c = static::config($provider)\n\t$params = arr(\n\t\tclient_id: $c['client_id'],\n\t\tredirect_uri: $c['redirect_uri'],\n\t\tresponse_type: 'code',\n\t\tscope: $c['scope'],\n\t\tstate: $state,\n\t)\n\tif ($nonce !== void) $params['nonce'] = $nonce\n\tif ($provider === 'apple') $params['response_mode'] = 'form_post'\n\treturn OAuth2::authorizeUrl($c['authorize'], $params)",
                        "line": 55,
                        "bodyLine": 56
                    },
                    "profile": {
                        "node": "static",
                        "visibility": null,
                        "name": "profile",
                        "args": "$provider, $code, $nonce = void",
                        "type": "?array",
                        "operator": "method",
                        "body": "\t$cfg = static::config($provider)\n\tif (!$cfg || $cfg['client_id'] === void) return null\n\t$secret = $provider === 'apple' ? static::appleSecret() : $cfg['client_secret']\n\t$token = OAuth2::exchangeCode($cfg['token'], $cfg['client_id'], $secret, $code, $cfg['redirect_uri'])\n\t$jwt = (string)($token['id_token'] ?? void)\n\tif (!static::verifySignature($provider, $jwt)) return null\n\t$claims = static::decodeIdToken($jwt)\n\tif (!$claims || !static::verifyClaims($provider, $cfg, $claims, $nonce)) return null\n\t$profile = static::normalize($provider, $claims)\n\tif ($provider === 'apple' && !$profile['name']){\n\t\t$u = json_decode((string)(%payload->user ?? void), true)\n\t\tif (is_array($u) && !empty($u['name'])) $profile['name'] = trim(((string)($u['name']['firstName'] ?? void)).space.((string)($u['name']['lastName'] ?? void)))\n\t}\n\treturn $profile",
                        "line": 69,
                        "bodyLine": 70
                    },
                    "decodeIdToken": {
                        "node": "static",
                        "visibility": null,
                        "name": "decodeIdToken",
                        "args": "$jwt",
                        "type": "?array",
                        "operator": "method",
                        "body": "\t$parts = explode(dot, (string)$jwt)\n\tif (count($parts) < 2) return null\n\t$claims = json_decode(static::b64urlDecode($parts[1]), true)\n\treturn is_array($claims) ? $claims : null",
                        "line": 86,
                        "bodyLine": 87
                    },
                    "algs": {
                        "node": "static",
                        "visibility": null,
                        "name": "algs",
                        "args": null,
                        "type": "array",
                        "operator": "arrow",
                        "body": "arr(RS256: OPENSSL_ALGO_SHA256)",
                        "line": 93,
                        "bodyLine": 93
                    },
                    "verifySignature": {
                        "node": "static",
                        "visibility": null,
                        "name": "verifySignature",
                        "args": "$provider, $jwt",
                        "type": "bool",
                        "operator": "method",
                        "body": "\t$parts = explode(dot, (string)$jwt)\n\tif (count($parts) !== 3) return false\n\t$header = json_decode(static::b64urlDecode($parts[0]), true)\n\tif (!is_array($header)) return false\n\t$alg = static::algs()[(string)($header['alg'] ?? void)] ?? null\n\t$kid = (string)($header['kid'] ?? void)\n\tif (!$alg || !$kid) return false\n\t$pem = static::key($provider, $kid)\n\tif (!$pem) return false\n\treturn openssl_verify($parts[0].dot.$parts[1], static::b64urlDecode($parts[2]), $pem, $alg) === 1",
                        "line": 95,
                        "bodyLine": 96
                    },
                    "jwks": {
                        "node": "static",
                        "visibility": null,
                        "name": "jwks",
                        "args": "$provider",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$cached = %req->socialJwks ?? []\n\tif (isset($cached[$provider])) return $cached[$provider]\n\t$url = (string)(static::providers()[$provider]['jwks'] ?? void)\n\t$keys = []\n\tif ($url){\n\t\t$data = json_decode((string)HTTP($url, ['Accept: application/json']), true)\n\t\tif (is_array($data) && is_array($data['keys'] ?? null)) $keys = $data['keys']\n\t}\n\t$cached[$provider] = $keys\n\t%req->socialJwks = $cached\n\treturn $keys",
                        "line": 108,
                        "bodyLine": 109
                    },
                    "key": {
                        "node": "static",
                        "visibility": null,
                        "name": "key",
                        "args": "$provider, $kid",
                        "type": "string",
                        "operator": "method",
                        "body": "\tforeach (static::jwks($provider) AS $jwk){\n\t\tif ((string)($jwk['kid'] ?? void) === $kid) return static::jwkToPem((array)$jwk)\n\t}\n\treturn void",
                        "line": 122,
                        "bodyLine": 123
                    },
                    "jwkToPem": {
                        "node": "static",
                        "visibility": null,
                        "name": "jwkToPem",
                        "args": "array $jwk",
                        "type": "string",
                        "operator": "method",
                        "body": "\tif ((string)($jwk['kty'] ?? void) !== 'RSA') return void\n\t$n = static::b64urlDecode((string)($jwk['n'] ?? void))\n\t$e = static::b64urlDecode((string)($jwk['e'] ?? void))\n\tif (!$n || !$e) return void\n\t$key = static::der(0x30, static::derInt($n).static::derInt($e))\n\t$bits = static::der(0x03, \"\\x00\".$key)\n\t$algo = static::der(0x30, \"\\x06\\x09\\x2a\\x86\\x48\\x86\\xf7\\x0d\\x01\\x01\\x01\\x05\\x00\")\n\t$spki = static::der(0x30, $algo.$bits)\n\treturn '-----BEGIN PUBLIC KEY-----'.lf.chunk_split(base64_encode($spki), 64, lf).'-----END PUBLIC KEY-----'.lf",
                        "line": 129,
                        "bodyLine": 130
                    },
                    "der": {
                        "node": "static",
                        "visibility": null,
                        "name": "der",
                        "args": "$tag, $content",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$len = strlen($content)\n\tif ($len < 0x80) return chr($tag).chr($len).$content\n\t$bytes = ltrim(pack('N', $len), \"\\x00\")\n\treturn chr($tag).chr(0x80 | strlen($bytes)).$bytes.$content",
                        "line": 141,
                        "bodyLine": 142
                    },
                    "derInt": {
                        "node": "static",
                        "visibility": null,
                        "name": "derInt",
                        "args": "$bytes",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$bytes = ltrim((string)$bytes, \"\\x00\")\n\tif ($bytes === void) $bytes = \"\\x00\"\n\tif (ord($bytes[0]) & 0x80) $bytes = \"\\x00\".$bytes\n\treturn static::der(0x02, $bytes)",
                        "line": 148,
                        "bodyLine": 149
                    },
                    "b64urlDecode": {
                        "node": "static",
                        "visibility": null,
                        "name": "b64urlDecode",
                        "args": "$data",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$data = strtr((string)$data, '-_', '+/')\n\t$pad = strlen($data) % 4\n\tif ($pad) $data .= str_repeat(eq, 4 - $pad)\n\treturn (string)base64_decode($data)",
                        "line": 155,
                        "bodyLine": 156
                    },
                    "verifyClaims": {
                        "node": "static",
                        "visibility": null,
                        "name": "verifyClaims",
                        "args": "$provider, array $cfg, array $claims, $nonce = void",
                        "type": "bool",
                        "operator": "method",
                        "body": "\tif ((int)($claims['exp'] ?? 0) <= time()) return false\n\t$aud = $claims['aud'] ?? void\n\tif (!in_array((string)($cfg['client_id'] ?? void), array_map('strval', is_array($aud) ? $aud : [$aud]), true)) return false\n\tif ($nonce !== void && !hash_equals((string)$nonce, (string)($claims['nonce'] ?? void))) return false\n\treturn static::verifyIssuer($provider, $claims)",
                        "line": 162,
                        "bodyLine": 163
                    },
                    "verifyIssuer": {
                        "node": "static",
                        "visibility": null,
                        "name": "verifyIssuer",
                        "args": "$provider, array $claims",
                        "type": "bool",
                        "operator": "method",
                        "body": "\t$iss = (string)($claims['iss'] ?? void)\n\tif ($provider === 'microsoft'){\n\t\t$tid = (string)($claims['tid'] ?? void)\n\t\treturn $tid !== void && $iss === 'https://login.microsoftonline.com/'.$tid.'/v2.0'\n\t}\n\t$expected = (string)(static::providers()[$provider]['issuer'] ?? void)\n\tif ($expected === void) return false\n\treturn $iss === $expected || ($provider === 'google' && $iss === 'accounts.google.com')",
                        "line": 170,
                        "bodyLine": 171
                    },
                    "normalize": {
                        "node": "static",
                        "visibility": null,
                        "name": "normalize",
                        "args": "$provider, array $claims",
                        "type": "array",
                        "operator": "method",
                        "body": "\t$ev = $claims['email_verified'] ?? null\n\t$verified = $ev === true || $ev === 'true'\n\tif ($provider === 'microsoft'){\n\t\t$edov = $claims['xms_edov'] ?? null\n\t\t$verified = $edov === true || $edov === 'true' || $edov === 1 || $edov === '1'\n\t}\n\t$name = (string)($claims['name'] ?? void)\n\tif (!$name) $name = trim(((string)($claims['given_name'] ?? void)).space.((string)($claims['family_name'] ?? void)))\n\treturn arr(\n\t\tprovider: $provider,\n\t\tuid: (string)($claims['sub'] ?? void),\n\t\temail: strtolower(trim((string)($claims['email'] ?? void))),\n\t\tverified: $verified,\n\t\tname: trim($name),\n\t)",
                        "line": 181,
                        "bodyLine": 182
                    },
                    "b64url": {
                        "node": "static",
                        "visibility": null,
                        "name": "b64url",
                        "args": "$data",
                        "type": "string",
                        "operator": "arrow",
                        "body": "rtrim(strtr(base64_encode((string)$data), '+/', '-_'), eq)",
                        "line": 199,
                        "bodyLine": 199
                    },
                    "appleSecret": {
                        "node": "static",
                        "visibility": null,
                        "name": "appleSecret",
                        "args": null,
                        "type": null,
                        "operator": "method",
                        "body": "\t$section = %creds->apple ?? null\n\t$creds = is_object($section) ? (array)$section->toArray : []\n\t$teamId = (string)($creds['team_id'] ?? void)\n\t$keyId = (string)($creds['key_id'] ?? void)\n\t$clientId = (string)($creds['client_id'] ?? void)\n\t$keyData = (string)($creds['private_key'] ?? void)\n\tif (!$keyData && !empty($creds['key_file']) && is_file((string)$creds['key_file'])) $keyData = (string)file_get_contents((string)$creds['key_file'])\n\tif (!$teamId || !$keyId || !$clientId || !$keyData) return void\n\t$key = openssl_pkey_get_private($keyData)\n\tif (!$key) return void\n\t$now = time()\n\t$input = static::b64url(json_encode(arr(alg: 'ES256', kid: $keyId))).dot.static::b64url(json_encode(arr(iss: $teamId, iat: $now, exp: $now + 15552000, aud: 'https://appleid.apple.com', sub: $clientId)))\n\t$der = void\n\tif (!openssl_sign($input, $der, $key, OPENSSL_ALGO_SHA256)) return void\n\treturn $input.dot.static::b64url(static::derToJose($der))",
                        "line": 201,
                        "bodyLine": 202
                    },
                    "derToJose": {
                        "node": "static",
                        "visibility": null,
                        "name": "derToJose",
                        "args": "$der",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$rlen = ord($der[3])\n\t$r = substr((string)$der, 4, $rlen)\n\t$slen = ord($der[4 + $rlen + 1])\n\t$s = substr((string)$der, 4 + $rlen + 2, $slen)\n\treturn static::pad32($r).static::pad32($s)",
                        "line": 219,
                        "bodyLine": 220
                    },
                    "pad32": {
                        "node": "static",
                        "visibility": null,
                        "name": "pad32",
                        "args": "$x",
                        "type": "string",
                        "operator": "method",
                        "body": "\t$x = ltrim((string)$x, \"\\x00\")\n\treturn str_pad($x, 32, \"\\x00\", STR_PAD_LEFT)",
                        "line": 227,
                        "bodyLine": 228
                    }
                },
                "functions": [],
                "assets": []
            }
        },
        "functions": {
            "decrypt": {
                "args": "$encrypted, $key",
                "return": "string|false",
                "body": "($d = base64_decode($encrypted, true)) !== false && strlen($d) >= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ? sodium_crypto_secretbox_open(substr($d, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES), substr($d, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES), hash('sha256', $key, true)) : false",
                "meta": {
                    "version": "1.0",
                    "summary": "Encrypt and decrypt secretbox payloads using a key",
                    "advice": "Authenticated encryption through libsodium: a fresh nonce per call travels with the value, so encrypting the same text twice gives different output and a changed ciphertext refuses to decrypt rather than returning rubbish. decrypt() gives false on a failure, so test with === false and not on falsiness. The key is hashed to the right length, which means any string works, but a short one is still a short secret.",
                    "package": "security",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "php-ext:sodium",
                    "tags": "encrypt decrypt encryption sodium secretbox crypto"
                },
                "file": "/srv/control/phlo/resources/security/encryption.phlo",
                "line": 12,
                "source": "function"
            },
            "encrypt": {
                "args": "$data, $key",
                "return": "string",
                "body": "base64_encode(($nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES)).sodium_crypto_secretbox($data, $nonce, hash('sha256', $key, true)))",
                "meta": {
                    "version": "1.0",
                    "summary": "Encrypt and decrypt secretbox payloads using a key",
                    "advice": "Authenticated encryption through libsodium: a fresh nonce per call travels with the value, so encrypting the same text twice gives different output and a changed ciphertext refuses to decrypt rather than returning rubbish. decrypt() gives false on a failure, so test with === false and not on falsiness. The key is hashed to the right length, which means any string works, but a short one is still a short secret.",
                    "package": "security",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "php-ext:sodium",
                    "tags": "encrypt decrypt encryption sodium secretbox crypto"
                },
                "file": "/srv/control/phlo/resources/security/encryption.phlo",
                "line": 10,
                "source": "function"
            },
            "token": {
                "args": "int $length = 8, ?string $input = null",
                "return": "string",
                "body": "\t$length || error('Token must have a minimum length above 0', 500)\n\t$alphabet = 'abcdefghijklmnopqrstuvwxyz'\n\t$alphabetLength = strlen($alphabet)\n\t$limit = intdiv(256, $alphabetLength) * $alphabetLength\n\t$token = void\n\t$buffer = void\n\t$state = is_null($input) ? null : hash('sha256', (string)$input, true)\n\twhile (strlen($token) < $length){\n\t\tif ($buffer === void){\n\t\t\tif (is_null($state)) $buffer = random_bytes(32)\n\t\t\telse {\n\t\t\t\t$state = hash('sha256', $state, true)\n\t\t\t\t$buffer = $state\n\t\t\t}\n\t\t}\n\t\t$byte = ord($buffer[0])\n\t\t$buffer = substr($buffer, 1)\n\t\tif ($byte >= $limit) continue\n\t\t$token .= $alphabet[$byte % $alphabetLength]\n\t}\n\treturn $token",
                "comments": "Bytes from the last incomplete block of 256 are thrown away rather than wrapped around.\nA plain modulo over an alphabet that does not divide 256 would make the first letters\nmore likely than the last, which is exactly the bias a token must not have. With an\ninput the bytes come from a sha256 chain instead of the random source, so the same\ninput always yields the same token.",
                "meta": {
                    "version": "1.0",
                    "summary": "Generate deterministic or random lowercase token",
                    "advice": "Lowercase letters only, so a token survives being read aloud, typed by hand or used in a URL without escaping. Pass an input and the token is derived from it, so the same input always gives the same token: exactly what you want for a file or a record, and exactly what you do not want for a secret. Leave the input out for anything that must be unguessable.",
                    "package": "security",
                    "frontend": "false",
                    "backend": "true",
                    "requires": "php-ext:openssl",
                    "tags": "token random deterministic security"
                },
                "file": "/srv/control/phlo/resources/security/token.phlo",
                "line": 15,
                "source": "function"
            }
        }
    }
}