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('/]*?\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('~]*>~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('~]*>(.*?)~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,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; }