Raiju/includes/ai/agent/changeset-ops.php
2026-10-25 06:05:42 +03:00

581 lines
28 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Change set operations (agent phase P3): value normalisation and the pure replay of
* operations onto in-memory copies of the stored content.
*
* An operation is created by the server from a validated tool request (see
* raijuAgentChangesetStage()) and stores everything needed to replay it later without
* scanning a page again: the field spec captured from the capability map, the normalised
* value and the hash of the value it replaced.
*
* Operations: set_field, set_image, list_insert, list_hide, list_remove, list_move,
* section_insert, set_page_menus, create_news, create_event, create_page.
*
* Lists are normalised to JSON arrays when an operation touches them. The editor writes
* numbered objects ({"0": …}) and PHP reads both forms; arrays are what json_encode()
* produces for them anyway.
*/
require_once __DIR__ . '/capabilities.php';
if (!class_exists('RaijuAgentOpException', false)) {
/** A request or operation the agent has to correct; the hint tells it how. */
class RaijuAgentOpException extends RuntimeException {
public string $hint;
public function __construct(string $message, string $hint = '') {
parent::__construct($message);
$this->hint = $hint;
}
}
}
// ---------------------------------------------------------------------------
// Targets and storage
// ---------------------------------------------------------------------------
/** @return array{kind:string,key:string,file:string,lang:string}|null */
function raijuAgentTargetStorage(string $target): ?array {
$info = raijuAgentParseTarget($target);
return $info === null ? null : ['kind' => $info['kind'], 'key' => $info['key'], 'file' => $info['file'], 'lang' => $info['lang']];
}
/** What a content file holds: a page, a news post or the events file. */
function raijuAgentTargetKindOfFile(string $file): string {
$root = realpath(raijuAgentSiteRoot()) ?: raijuAgentSiteRoot();
$dir = realpath(dirname($file)) ?: dirname($file);
if ($dir === $root . '/content/news') return 'news';
if ($dir === $root . '/content' && basename($file) === 'events.json') return 'events';
return 'page';
}
/** Stable, order-independent JSON for hashes. */
function raijuAgentCanonicalJson($value): string {
$normalize = static function ($node) use (&$normalize) {
if (!is_array($node)) return $node;
if (!array_is_list($node)) ksort($node);
return array_map($normalize, $node);
};
return (string)json_encode($normalize($value), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
function raijuAgentValueHash($value): string {
return hash('sha256', raijuAgentCanonicalJson($value));
}
function raijuAgentPathValid(string $path): bool {
if ($path === '' || strlen($path) > 160) return false;
foreach (explode('.', $path) as $segment) {
if (!preg_match('/^[A-Za-z0-9_-]{1,64}$/', $segment) || in_array($segment, ['__proto__', 'constructor', 'prototype'], true)) return false;
}
return true;
}
// ---------------------------------------------------------------------------
// Value normalisation
// ---------------------------------------------------------------------------
function raijuAgentUrlKey(string $url): string {
$url = trim($url);
if (preg_match('/^(mailto|tel):/i', $url)) return strtolower($url);
$parts = parse_url($url);
if (!is_array($parts) || empty($parts['host'])) return strtolower($url);
$path = rtrim((string)($parts['path'] ?? ''), '/');
return strtolower(($parts['scheme'] ?? 'https') . '://' . $parts['host'] . (isset($parts['port']) ? ':' . $parts['port'] : '')) . $path . (isset($parts['query']) ? '?' . $parts['query'] : '');
}
/** Whether an external URL was named by the user or fetched as a source of this run. */
function raijuAgentUrlAllowed(string $url, array $cs): bool {
$key = raijuAgentUrlKey($url);
foreach ((array)($cs['allowedUrls'] ?? []) as $allowed) {
if (raijuAgentUrlKey((string)$allowed) === $key) return true;
}
foreach ((array)($cs['sources'] ?? []) as $source) {
if (!is_array($source)) continue;
foreach (['url', 'canonical', 'resultUrl'] as $field) {
if (!empty($source[$field]) && raijuAgentUrlKey((string)$source[$field]) === $key) return true;
}
}
return false;
}
function raijuAgentNormalizeHref(string $href, array $cs): string {
try {
$href = raijuCopilotNormalizeLinkValue($href);
} catch (RuntimeException $e) {
throw new RaijuAgentOpException('The link is not allowed.', 'Use a relative link such as /contact, an anchor, or an http(s)/mailto/tel address.');
}
if ($href === '' || str_starts_with($href, '#') || (str_starts_with($href, '/') && !str_starts_with($href, '//'))) return $href;
$scheme = parse_url($href, PHP_URL_SCHEME);
if (!$scheme) return $href; // relative path
if (!raijuAgentUrlAllowed($href, $cs)) {
throw new RaijuAgentOpException('External link not allowed: ' . mb_substr($href, 0, 80, 'UTF-8'), 'External links are only allowed when the user named them or a source of this run contains them. Link an internal page instead.');
}
return $href;
}
function raijuAgentNormalizeText(string $value, int $max, bool $multiline, array &$warnings, string $label): string {
$value = strip_tags(str_replace(["\r\n", "\r"], "\n", $value));
$value = preg_replace($multiline ? '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/' : '/[\x00-\x1F\x7F]+/', $multiline ? '' : ' ', $value) ?? '';
$value = trim($multiline ? $value : preg_replace('/\s{2,}/', ' ', $value) ?? '');
if (mb_strlen($value, 'UTF-8') > $max) {
$value = rtrim(mb_substr($value, 0, $max, 'UTF-8'));
$warnings[] = "{$label} was shortened to {$max} characters.";
}
return $value;
}
/**
* Normalise a value for a field spec {type, max, req, options, fmt}.
* Images are resolved by raijuAgentResolveImage(), not here.
*
* @return string|array
* @throws RaijuAgentOpException
*/
function raijuAgentNormalizeValue(array $spec, $value, array $cs, array &$warnings, string $label) {
$type = (string)$spec['type'];
$max = (int)($spec['max'] ?? raijuAgentDefaultMax($type, $label));
switch ($type) {
case 'text':
case 'textarea':
if (!is_string($value) && !is_int($value) && !is_float($value)) throw new RaijuAgentOpException("{$label} needs a text value.");
return raijuAgentNormalizeText((string)$value, $max, $type === 'textarea', $warnings, $label);
case 'html':
if (!is_string($value)) throw new RaijuAgentOpException("{$label} needs an HTML string.");
$clean = raijuSanitizeRichHtml(raijuCopilotSanitizeHtml($value));
if (raijuSanitizeRichHtml($clean) !== $clean) {
throw new RaijuAgentOpException("{$label} contains markup that cannot be stored safely.", 'Use only paragraphs, headings (h2, h3), lists, bold, italic and links.');
}
if (mb_strlen($clean, 'UTF-8') > $max) {
throw new RaijuAgentOpException("{$label} is longer than {$max} characters.", 'Shorten the text.');
}
// The link rule holds inside rich text too: an external address needs the user's word or a source of this run.
if (preg_match_all('/<a\b[^>]*?\bhref\s*=\s*(?:"([^"]*)"|\'([^\']*)\')/i', $clean, $links, PREG_SET_ORDER)) {
foreach ($links as $link) {
$href = trim(html_entity_decode(($link[1] ?? '') !== '' ? $link[1] : ($link[2] ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($href !== '') raijuAgentNormalizeHref($href, $cs);
}
}
return $clean;
case 'icon':
if (!is_string($value) || !preg_match('/^[A-Za-z0-9_-]{1,80}$/', trim($value))) throw new RaijuAgentOpException("{$label} needs an icon name (letters, digits, - and _).");
return trim($value);
case 'select':
$options = array_map('strval', (array)($spec['options'] ?? []));
if (!is_string($value) || ($options && !in_array($value, $options, true))) {
throw new RaijuAgentOpException("{$label} must be one of: " . implode(', ', $options) . '.');
}
return $value;
case 'date':
if (!is_string($value) || !preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $m) || !checkdate((int)$m[2], (int)$m[3], (int)$m[1])) {
throw new RaijuAgentOpException("{$label} needs a date as YYYY-MM-DD.");
}
return $value;
case 'time':
if (!is_string($value) || !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $value)) throw new RaijuAgentOpException("{$label} needs a time as HH:MM.");
return $value;
case 'boolean':
if (!is_bool($value)) throw new RaijuAgentOpException("{$label} needs true or false.");
return $value;
case 'number':
if (!is_int($value) && !is_float($value)) throw new RaijuAgentOpException("{$label} needs a number.");
return $value;
case 'link':
if (($spec['fmt'] ?? '') === 'string') {
if (!is_string($value)) throw new RaijuAgentOpException("{$label} needs a link address as text.");
return raijuAgentNormalizeHref($value, $cs);
}
if (!is_array($value) || !isset($value['href']) || !is_string($value['href'])) {
throw new RaijuAgentOpException("{$label} needs {text, href}.");
}
$text = raijuAgentNormalizeText((string)($value['text'] ?? ''), 120, false, $warnings, $label . '.text');
return ['text' => $text, 'href' => raijuAgentNormalizeHref($value['href'], $cs)];
}
throw new RaijuAgentOpException("{$label} has an unsupported type ({$type}).");
}
/**
* Resolve an image source: a staged media reference (m_…) or a path in the media library.
*
* @return string public path
*/
function raijuAgentResolveImage($source, array $cs, string $label): string {
if (!is_string($source) || trim($source) === '') throw new RaijuAgentOpException("{$label} needs an image (mediaRef or libraryPath).");
$source = trim($source);
if (preg_match('/^m_[A-Za-z0-9]{4,40}$/', $source)) {
$media = $cs['media'][$source] ?? null;
if (!is_array($media) || empty($media['finalPath'])) {
throw new RaijuAgentOpException("{$label}: unknown image reference {$source}.", 'Use list_run_media to see the images of this run.');
}
return (string)$media['finalPath'];
}
try {
return raijuCopilotNormalizeImagePath($source);
} catch (RuntimeException $e) {
throw new RaijuAgentOpException("{$label}: the image {$source} is not in the media library.", 'Use list_media to find a library image, or an image reference of this run.');
}
}
function raijuAgentNormalizeAlt($alt, string $label): string {
$alt = raijuCopilotNormalizeAltText((string)$alt);
if ($alt === '') throw new RaijuAgentOpException("{$label} needs an alt text.", 'Describe the image in a short sentence.');
return $alt;
}
/** A short readable piece of (possibly HTML) text. */
function raijuAgentSnippet(string $text, int $max = 40): string {
$text = preg_replace('~</?(p|h[1-6]|li|ul|ol|div|br|blockquote|tr|td)\b[^>]*>~i', ' ', $text);
$text = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags((string)$text), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
return mb_strlen($text, 'UTF-8') > $max ? rtrim(mb_substr($text, 0, $max - 1, 'UTF-8')) . '…' : $text;
}
/** What a person would call a section: its title, its first heading or the start of its text. */
function raijuAgentSectionTitle(array $section): string {
foreach (['title', 'heading', 'headline', 'name'] as $key) {
if (isset($section[$key]) && is_string($section[$key]) && raijuAgentSnippet($section[$key]) !== '') return raijuAgentSnippet($section[$key]);
}
foreach ($section as $value) {
if (is_string($value) && preg_match('~<h[1-6][^>]*>(.*?)</h[1-6]>~is', $value, $m) && raijuAgentSnippet($m[1]) !== '') return raijuAgentSnippet($m[1]);
}
foreach (['content', 'text', 'description', 'quote'] as $key) {
if (isset($section[$key]) && is_string($section[$key]) && raijuAgentSnippet($section[$key]) !== '') return raijuAgentSnippet($section[$key]);
}
return '';
}
/**
* Where a path points, in terms a person understands: the field, and the section (position, type,
* title) and list item it sits in. Used for the review texts only.
*
* @return array{field:string,section:?array{n:int,type:string,title:string},item:?int}
*/
function raijuAgentFieldContext(array $doc, string $path): array {
$parts = explode('.', $path);
$context = ['field' => $path, 'section' => null, 'item' => null];
if (count($parts) >= 3 && $parts[0] === 'sections' && ctype_digit($parts[1])) {
$section = getNestedValue($doc, 'sections.' . $parts[1]);
if (is_array($section)) {
$context['section'] = ['n' => (int)$parts[1] + 1, 'type' => (string)($section['type'] ?? ''), 'title' => raijuAgentSectionTitle($section)];
$rest = array_slice($parts, 2);
if (count($rest) >= 3 && ctype_digit($rest[1])) {
$context['item'] = (int)$rest[1] + 1;
$context['field'] = implode('.', array_slice($rest, 2));
} else {
$context['field'] = implode('.', $rest);
}
}
}
return $context;
}
/** Text that identifies a list item to a human (used to confirm removals). */
function raijuAgentItemLabel($item): string {
if (!is_array($item)) return trim(strip_tags((string)$item));
foreach (['title', 'name', 'heading', 'question', 'label', 'text', 'content'] as $key) {
if (isset($item[$key]) && is_string($item[$key]) && trim(strip_tags($item[$key])) !== '') return trim(strip_tags($item[$key]));
}
foreach ($item as $value) {
if (is_string($value) && trim(strip_tags($value)) !== '') return trim(strip_tags($value));
}
return '';
}
/** Normalise the item of a list insert against the item schema ({key: type}). */
function raijuAgentNormalizeItem(array $schema, array $item, array $defaults, array $cs, array &$warnings, string $label): array {
$result = $defaults;
foreach ($item as $key => $value) {
$key = (string)$key;
if (!isset($schema[$key])) {
throw new RaijuAgentOpException("{$label}: '{$key}' is not a field of this list.", 'Fields: ' . implode(', ', array_keys($schema)) . '.');
}
$type = $schema[$key];
$at = "{$label}.{$key}";
if (str_starts_with($type, 'image:')) {
$format = substr($type, 6);
$source = is_array($value) ? ($value['mediaRef'] ?? $value['libraryPath'] ?? null) : $value;
$src = raijuAgentResolveImage($source, $cs, $at);
if ($format === 'object') {
$existing = is_array($defaults[$key] ?? null) ? $defaults[$key] : [];
$result[$key] = array_replace($existing, ['src' => $src, 'alt' => raijuAgentNormalizeAlt(is_array($value) ? ($value['alt'] ?? '') : '', $at)]);
} elseif ($format === 'string') {
$result[$key] = $src;
} else {
throw new RaijuAgentOpException("{$at}: this image field cannot be set inside a list.");
}
continue;
}
$result[$key] = raijuAgentNormalizeValue(['type' => $type, 'max' => raijuAgentDefaultMax($type, $key)], $value, $cs, $warnings, $at);
}
return $result;
}
// ---------------------------------------------------------------------------
// Replay
// ---------------------------------------------------------------------------
/**
* Fresh in-memory state: file data is loaded lazily, so a replay always starts from
* what is stored right now.
*/
function raijuAgentReplayState(): array {
return ['files' => [], 'tokens' => [], 'results' => [], 'created' => [], 'touched' => []];
}
function raijuAgentStateFile(array &$state, string $file): void {
if (!isset($state['files'][$file])) {
$data = raijuAgentReadJsonFile($file);
$state['files'][$file] = ['exists' => $data !== null, 'before' => $data, 'data' => $data];
}
}
/** Reference to the document of a target inside the state. */
function &raijuAgentDocRef(array &$state, string $target): array {
$storage = raijuAgentTargetStorage($target);
if ($storage === null) throw new RaijuAgentOpException('Invalid target.');
raijuAgentStateFile($state, $storage['file']);
$file = &$state['files'][$storage['file']];
if ($storage['kind'] === 'event') {
if (!is_array($file['data']['events'] ?? null)) throw new RaijuAgentOpException("Event {$storage['key']} not found.");
foreach ($file['data']['events'] as $index => &$event) {
if (is_array($event) && ($event['id'] ?? null) === $storage['key']) {
$state['touched'][$target] = true;
return $event;
}
}
throw new RaijuAgentOpException("Event {$storage['key']} not found.");
}
if ($file['data'] === null) throw new RaijuAgentOpException("{$target} does not exist.");
$state['touched'][$target] = true;
return $file['data'];
}
function raijuAgentListItemsOf(array $doc, string $listKey): array {
$value = getNestedValue($doc, $listKey);
if (!is_array($value)) return [];
ksort($value, SORT_NUMERIC);
return array_values($value);
}
function raijuAgentTokensFor(array &$state, string $target, string $listKey, array $items): array {
$key = $target . '|' . $listKey;
if (!isset($state['tokens'][$key]) || count($state['tokens'][$key]) !== count($items)) {
$state['tokens'][$key] = array_fill(0, count($items), null);
}
return $state['tokens'][$key];
}
function raijuAgentCheckIndex($index, int $count, string $what): int {
if (!is_int($index) || $index < 0 || $index >= $count) {
throw new RaijuAgentOpException("{$what}: index " . json_encode($index) . " is out of range (the list has {$count} items).", 'Indexes start at 0 and refer to the list after the previous changes.');
}
return $index;
}
/** Write a normalised image value according to the field format. */
function raijuAgentWriteImage(array &$doc, array $spec, string $path, string $src, string $alt): void {
$format = (string)($spec['fmt'] ?? 'object');
if ($format === 'string') {
setNestedValue($doc, $path, $src);
} elseif ($format === 'split') {
if (empty($spec['altPath'])) throw new RaijuAgentOpException("{$path}: the alt field of this image is unknown.");
setNestedValue($doc, $path, $src);
setNestedValue($doc, (string)$spec['altPath'], $alt);
} else {
$existing = getNestedValue($doc, $path);
setNestedValue($doc, $path, array_replace(is_array($existing) ? $existing : [], ['src' => $src, 'alt' => $alt]));
}
}
/**
* Apply one operation to the state.
*
* @throws RaijuAgentOpException
*/
function raijuAgentApplyOp(array &$state, array $op): void {
$type = (string)$op['type'];
$target = (string)($op['target'] ?? '');
$before = null;
$after = null;
$effectivePosition = null;
$context = null;
switch ($type) {
case 'set_field':
case 'set_image':
$doc = &raijuAgentDocRef($state, $target);
$path = (string)$op['path'];
if (!raijuAgentPathValid($path)) throw new RaijuAgentOpException('Invalid path.');
if (!empty($op['spec']['mustExist']) && !is_array(getNestedValue($doc, (string)$op['spec']['mustExist']))) {
throw new RaijuAgentOpException("'{$path}': {$op['spec']['mustExist']} no longer exists at this point of the change set.", 'Indexes refer to the page after the previous changes.');
}
$before = getNestedValue($doc, $path);
$context = raijuAgentFieldContext($doc, $path);
if ($type === 'set_field') {
$value = $op['value'];
if (is_array($value) && is_array($before) && !array_is_list($before)) {
$value = array_replace($before, $value); // a link keeps its other settings (target, download)
}
setNestedValue($doc, $path, $value);
$after = $value;
} else {
raijuAgentWriteImage($doc, (array)$op['spec'], $path, (string)$op['src'], (string)($op['alt'] ?? ''));
$after = getNestedValue($doc, $path);
if (!empty($op['creditPath']) && !empty($op['creditText'])) {
// The credit goes into the caption; an existing caption keeps its text and gets the credit appended.
$caption = trim((string)getNestedValue($doc, (string)$op['creditPath']));
if (!str_contains($caption, (string)$op['creditText'])) {
setNestedValue($doc, (string)$op['creditPath'], $caption === '' ? $op['creditText'] : $caption . ' – ' . $op['creditText']);
}
}
}
unset($doc);
break;
case 'list_insert':
case 'list_hide':
case 'list_remove':
case 'list_move':
$doc = &raijuAgentDocRef($state, $target);
$listKey = (string)$op['listKey'];
if (!raijuAgentPathValid($listKey)) throw new RaijuAgentOpException('Invalid list key.');
$items = raijuAgentListItemsOf($doc, $listKey);
$context = raijuAgentFieldContext($doc, $listKey);
$tokens = raijuAgentTokensFor($state, $target, $listKey, $items);
$count = count($items);
if ($type === 'list_insert') {
$position = min((int)$op['position'], $count);
if ($position < 0) throw new RaijuAgentOpException('Invalid position.');
$effectivePosition = $position;
array_splice($items, $position, 0, [$op['item']]);
array_splice($tokens, $position, 0, [$op['id']]);
$after = $op['item'];
} elseif ($type === 'list_hide') {
$index = raijuAgentCheckIndex($op['index'], $count, 'list_hide');
$before = !empty($items[$index]['hidden']);
if (!is_array($items[$index])) throw new RaijuAgentOpException('This list item cannot be hidden.');
if ($op['hidden']) $items[$index]['hidden'] = true; else unset($items[$index]['hidden']);
$after = (bool)$op['hidden'];
} elseif ($type === 'list_remove') {
$index = raijuAgentCheckIndex($op['index'], $count, 'list_remove');
$label = raijuAgentItemLabel($items[$index]);
$prefix = (string)$op['titlePrefix'];
if ($prefix === '' || mb_stripos($label, $prefix, 0, 'UTF-8') !== 0) {
throw new RaijuAgentOpException("list_remove: item {$index} starts with \"" . mb_substr($label, 0, 40, 'UTF-8') . "\", not \"" . mb_substr($prefix, 0, 40, 'UTF-8') . '".', 'Pass the first characters of the title of the item you want to remove, to confirm the index.');
}
$before = $items[$index];
array_splice($items, $index, 1);
array_splice($tokens, $index, 1);
} else {
$from = raijuAgentCheckIndex($op['from'], $count, 'list_move from');
$to = raijuAgentCheckIndex($op['to'], $count, 'list_move to');
$moved = array_splice($items, $from, 1);
array_splice($items, $to, 0, $moved);
$movedToken = array_splice($tokens, $from, 1);
array_splice($tokens, $to, 0, $movedToken);
$before = $from;
$after = $to;
}
setNestedValue($doc, $listKey, array_values($items));
$state['tokens'][$target . '|' . $listKey] = array_values($tokens);
unset($doc);
break;
case 'section_insert':
$doc = &raijuAgentDocRef($state, $target);
if (!is_array($doc['sections'] ?? null)) throw new RaijuAgentOpException('This page has no sections.');
$sections = array_values($doc['sections']);
$position = min((int)$op['position'], count($sections));
if ($position < 0) throw new RaijuAgentOpException('Invalid position.');
$effectivePosition = $position;
foreach ($sections as $section) {
if (is_array($section) && ($section['id'] ?? null) === $op['section']['id']) throw new RaijuAgentOpException('Section id already exists.');
}
array_splice($sections, $position, 0, [$op['section']]);
$doc['sections'] = $sections;
$after = $op['section'];
unset($doc);
break;
case 'set_page_menus':
$doc = &raijuAgentDocRef($state, $target);
$current = array_values(array_map('strval', (array)($doc['nav'] ?? ['header'])));
$menus = array_values(array_unique(array_merge($current, $op['add'])));
$menus = array_values(array_diff($menus, $op['remove']));
$label = static fn(array $ids): string => $ids ? implode(', ', array_map(static fn($id) => function_exists('getMenuLabel') ? getMenuLabel($id, (string)($doc['lang'] ?? 'en')) : $id, $ids)) : '–';
$before = $label($current);
$doc['nav'] = $menus;
$after = $label($menus);
unset($doc);
break;
case 'create_news':
$storage = raijuAgentTargetStorage($target);
raijuAgentStateFile($state, $storage['file']);
if ($state['files'][$storage['file']]['data'] !== null) throw new RaijuAgentOpException("News post {$storage['key']} already exists.", 'Choose another slug or date.');
$state['files'][$storage['file']]['data'] = $op['post'];
$state['touched'][$target] = true;
$after = $op['post'];
break;
case 'create_page':
$storage = raijuAgentTargetStorage($target);
raijuAgentStateFile($state, $storage['file']);
if ($state['files'][$storage['file']]['data'] !== null) throw new RaijuAgentOpException("Page {$storage['key']} already exists.", 'Choose another slug.');
$state['files'][$storage['file']]['data'] = $op['page'];
$state['touched'][$target] = true;
$after = $op['page'];
break;
case 'create_event':
$storage = raijuAgentTargetStorage($target);
raijuAgentStateFile($state, $storage['file']);
$file = &$state['files'][$storage['file']];
$file['data'] ??= ['events' => []];
foreach ((array)($file['data']['events'] ?? []) as $existing) {
if (is_array($existing) && ($existing['id'] ?? null) === $storage['key']) throw new RaijuAgentOpException("Event {$storage['key']} already exists.", 'Change the title or date.');
}
$file['data']['events'][] = $op['event'];
$state['touched'][$target] = true;
$after = $op['event'];
unset($file);
break;
default:
throw new RaijuAgentOpException("Unknown operation {$type}.");
}
if (in_array($type, ['create_news', 'create_page', 'create_event'], true)) {
$state['created'][$target] = $op['id'];
}
$state['results'][$op['id']] = ['before' => $before, 'after' => $after, 'position' => $effectivePosition, 'context' => $context];
}
/**
* Replay operations onto fresh copies of the stored content.
*
* @return array{ok:bool,errors:array<string,array{message:string,hint:string}>,state:array}
*/
function raijuAgentReplayOps(array $ops): array {
$state = raijuAgentReplayState();
$errors = [];
foreach ($ops as $op) {
try {
raijuAgentApplyOp($state, $op);
} catch (RaijuAgentOpException $e) {
$errors[(string)$op['id']] = ['message' => $e->getMessage(), 'hint' => $e->hint];
break; // later operations depend on this one
}
}
return ['ok' => !$errors, 'errors' => $errors, 'state' => $state];
}
/** Files that differ from their stored state: file => {exists, before, after}. */
function raijuAgentChangedFiles(array $state): array {
$changed = [];
foreach ($state['files'] as $file => $entry) {
if ($entry['data'] !== $entry['before']) {
$changed[$file] = ['exists' => $entry['exists'], 'before' => $entry['before'], 'after' => $entry['data']];
}
}
return $changed;
}