$limit) { return mb_substr($text, 0, $limit - 3, 'UTF-8') . '...'; } if (!function_exists('mb_strlen') && strlen($text) > $limit) { return substr($text, 0, $limit - 3) . '...'; } return $text; } function raijuCopilotAssertBurstLimit(string $bucket, int $limit, int $windowSeconds): void { if ($limit <= 0 || $windowSeconds <= 0) { return; } $bucket = preg_replace('/[^a-z0-9_-]/i', '-', $bucket) ?: 'default'; $now = time(); if (!isset($_SESSION['raiju_copilot_rate']) || !is_array($_SESSION['raiju_copilot_rate'])) { $_SESSION['raiju_copilot_rate'] = []; } $entries = $_SESSION['raiju_copilot_rate'][$bucket] ?? []; if (!is_array($entries)) { $entries = []; } $entries = array_values(array_filter(array_map('intval', $entries), function (int $timestamp) use ($now, $windowSeconds): bool { return $timestamp > ($now - $windowSeconds); })); if (count($entries) >= $limit) { throw new RuntimeException('Too many AI Assistant requests. Please wait a moment and try again.'); } $entries[] = $now; $_SESSION['raiju_copilot_rate'][$bucket] = $entries; } function raijuCopilotSelectOptions(string $path, $currentValue = null): array { $path = strtolower($path); if ($path === 'seo.robots') { return ['index, follow', 'noindex, follow', 'noindex, nofollow']; } return []; } function raijuCopilotFieldType(string $path, $value): string { $last = strtolower((string)preg_replace('/^.*\./', '', $path)); if (is_bool($value)) return 'boolean'; if (is_int($value) || is_float($value)) return 'number'; if (in_array($last, ['image', 'src', 'cover', 'thumbnail', 'favicon', 'logo'], true)) return 'image'; if (in_array($last, ['url', 'href', 'link', 'registrationurl'], true)) return 'link'; if (raijuCopilotSelectOptions($path, $value)) return 'select'; if (is_string($value) && preg_match('/<\/?[a-z][\s\S]*>/i', $value)) return 'html'; if (is_array($value)) return array_is_list($value) ? 'list' : 'group'; return 'text'; } function raijuCopilotFieldLabel(string $path): string { if (preg_match('/^sections\.(\d+)\.(src|image)$/', $path, $match)) { return 'Section ' . ((int)$match[1] + 1) . ' image'; } if (preg_match('/^sections\.(\d+)\.alt$/', $path, $match)) { return 'Section ' . ((int)$match[1] + 1) . ' alt text'; } if (preg_match('/^sections\.(\d+)\.caption$/', $path, $match)) { return 'Section ' . ((int)$match[1] + 1) . ' caption'; } if (preg_match('/^sections\.(\d+)\.(title|heading)$/', $path, $match)) { return 'Section ' . ((int)$match[1] + 1) . ' title'; } if (preg_match('/^sections\.(\d+)\.(text|content|description)$/', $path, $match)) { return 'Section ' . ((int)$match[1] + 1) . ' text'; } return ucwords(str_replace(['.', '-', '_'], ' ', $path)); } function raijuCopilotSectionDescriptor(array $section, int $index): string { $type = trim((string)($section['type'] ?? 'section')); $titleSources = [ $section['title'] ?? null, $section['heading'] ?? null, $section['text'] ?? null, $section['alt'] ?? null, $section['caption'] ?? null, $section['description'] ?? null, $section['content'] ?? null, ]; $title = ''; foreach ($titleSources as $source) { $title = raijuCopilotShortText($source ?? '', 56); if ($title !== '') { break; } } $label = 'Sec. ' . ($index + 1); if ($type !== '') { $label .= ' ' . $type; } if ($title !== '') { $label .= ': ' . $title; } return $label; } function raijuCopilotSectionFieldLabel(string $path): string { $fieldPath = preg_replace('/^sections\.\d+\./', '', $path) ?? $path; $fieldPath = preg_replace_callback('/\bitems\.(\d+)\./', static function(array $match): string { return 'item ' . ((int)$match[1] + 1) . ' '; }, $fieldPath) ?? $fieldPath; $fieldPath = preg_replace('/\.\d+\./', ' ', $fieldPath) ?? $fieldPath; $fieldPath = str_replace(['.', '-', '_'], ' ', $fieldPath); return trim((string)$fieldPath); } function raijuCopilotEnrichSectionFieldLabels(array $fields, array $data): array { if (!isset($data['sections']) || !is_array($data['sections'])) { return $fields; } foreach ($fields as &$field) { $path = (string)($field['path'] ?? ''); if (!preg_match('/^sections\.(\d+)\./', $path, $match)) { continue; } $index = (int)$match[1]; $section = $data['sections'][$index] ?? null; if (!is_array($section)) { continue; } $fieldName = raijuCopilotSectionFieldLabel($path); $field['sectionId'] = (string)($section['id'] ?? ''); $field['sectionType'] = (string)($section['type'] ?? ''); $field['sectionLabel'] = raijuCopilotSectionDescriptor($section, $index); $field['label'] = $fieldName !== '' ? $field['sectionLabel'] . ' - ' . $fieldName : $field['sectionLabel']; } unset($field); return $fields; } function raijuCopilotIsSensitivePath(string $path): bool { $parts = array_filter(explode('.', strtolower($path)), 'strlen'); foreach ($parts as $part) { if (in_array($part, ['password', 'passwordhash', 'hash', 'token', 'secret', 'apikey', 'api_key', 'key', 'csrf'], true)) { return true; } if (str_contains($part, 'password') || str_contains($part, 'secret') || str_contains($part, 'token')) { return true; } } return false; } function raijuCopilotIsStructuralPath(string $path): bool { $parts = array_values(array_filter(explode('.', strtolower($path)), 'strlen')); if (!$parts) { return true; } $last = $parts[count($parts) - 1]; if (str_ends_with($path, '__hidden')) { return true; } if (in_array($path, ['page', 'lang', 'lastmodified', 'createdat', 'updatedat', 'published'], true)) { return true; } if (in_array($last, ['id', 'type', 'template', 'layout', 'component', 'blocktype', 'hidden', 'status', 'lastmodified', 'createdat', 'updatedat'], true)) { return true; } if (preg_match('/^sections\.\d+\.(size|level|columns|variant|style|theme|align|alignment)$/i', $path)) { return true; } return false; } function raijuCopilotCollectFields(array $data, string $prefix = '', int $depth = 0, int &$count = 0): array { if ($depth > 5 || $count >= 80) { return []; } $fields = []; foreach ($data as $key => $value) { if ($count >= 80) { break; } $key = (string)$key; if ($key === '' || str_starts_with($key, '_')) { continue; } $path = $prefix === '' ? $key : $prefix . '.' . $key; if (raijuCopilotIsSensitivePath($path)) { continue; } if (!is_array($value) && raijuCopilotIsStructuralPath($path)) { continue; } if (is_array($value) && $depth < 5 && !array_is_list($value)) { $objectType = raijuCopilotFieldType($path, $value); if ($objectType === 'image' && isset($value['src']) && is_string($value['src'])) { $fields[] = [ 'id' => substr(hash('sha256', $path), 0, 12), 'path' => $path, 'label' => raijuCopilotFieldLabel($path), 'type' => 'image', 'preview' => raijuCopilotShortText($value['src']), 'operations' => raijuCopilotOperationsForType('image') ]; $count++; continue; } $fields = array_merge($fields, raijuCopilotCollectFields($value, $path, $depth + 1, $count)); continue; } if (is_array($value) && $depth < 5 && array_is_list($value)) { $nested = []; foreach ($value as $index => $item) { if ($count >= 80) { break; } if (is_array($item)) { $nested = array_merge($nested, raijuCopilotCollectFields($item, $path . '.' . (int)$index, $depth + 1, $count)); } } if ($nested) { $fields = array_merge($fields, $nested); continue; } } $type = raijuCopilotFieldType($path, $value); if ($type === 'list' && is_array($value)) { $preview = count($value) . ' item(s)'; } else { $preview = raijuCopilotShortText($value); } $field = [ 'id' => substr(hash('sha256', $path), 0, 12), 'path' => $path, 'label' => raijuCopilotFieldLabel($path), 'type' => $type, 'preview' => $preview, 'operations' => raijuCopilotOperationsForType($type) ]; $options = raijuCopilotSelectOptions($path, $value); if ($options) { $field['options'] = $options; } $fields[] = $field; $count++; } return $fields; } function raijuCopilotOperationsForType(string $type): array { if (in_array($type, ['text', 'html', 'link', 'boolean', 'select'], true)) { return ['explain', 'suggest']; } if ($type === 'image') { return ['explain', 'generate-image']; } return ['explain']; } function raijuCopilotPageContext(?string $contentPage): array { $contentPage = is_string($contentPage) ? trim($contentPage) : ''; $context = [ 'contentPage' => $contentPage, 'exists' => false, 'type' => 'unknown', 'lang' => '', 'slug' => '', 'title' => '', 'description' => '', 'fields' => [], 'sections' => [] ]; if (preg_match('/^news:([a-z0-9]+(?:-[a-z0-9]+)*)$/', $contentPage, $newsMatch)) { $path = raijuCopilotNewsPath($newsMatch[1]); $data = $path !== '' ? raijuCopilotReadJson($path) : []; if (!$data) { return $context; } $context['exists'] = true; $context['type'] = 'news-post'; $context['lang'] = (string)($data['lang'] ?? ''); $context['slug'] = (string)($data['slug'] ?? $newsMatch[1]); $context['title'] = raijuCopilotShortText($data['title'] ?? $context['slug'], 160); $context['description'] = raijuCopilotShortText($data['excerpt'] ?? '', 240); $fieldCount = 0; $context['fields'] = raijuCopilotEnrichSectionFieldLabels(raijuCopilotCollectFields($data, '', 0, $fieldCount), $data); return $context; } if ($contentPage === '' || !raijuPageIsValidContentKey($contentPage)) { return $context; } $path = raijuCopilotContentRoot() . '/pages/' . $contentPage . '.json'; $data = raijuCopilotReadJson($path); if (!$data) { return $context; } $page = raijuPageParseContentKey($contentPage); $context['exists'] = true; $context['type'] = isset($data['sections']) && is_array($data['sections']) ? 'standard-page' : 'custom-page'; $context['lang'] = (string)($data['lang'] ?? $page['lang'] ?? ''); $context['slug'] = (string)($data['path'] ?? $page['path'] ?? ''); $context['title'] = raijuCopilotShortText($data['title'] ?? $context['slug'], 160); $context['description'] = raijuCopilotShortText($data['description'] ?? '', 240); if (isset($data['sections']) && is_array($data['sections'])) { foreach (array_slice($data['sections'], 0, 40) as $index => $section) { if (!is_array($section)) { continue; } $context['sections'][] = [ 'index' => $index, 'id' => (string)($section['id'] ?? ''), 'type' => (string)($section['type'] ?? 'unknown'), 'title' => raijuCopilotShortText($section['title'] ?? $section['text'] ?? '', 160), 'summary' => raijuCopilotShortText($section['content'] ?? $section['caption'] ?? '', 240) ]; } } $fieldCount = 0; $context['fields'] = raijuCopilotEnrichSectionFieldLabels(raijuCopilotCollectFields($data, '', 0, $fieldCount), $data); return $context; } function raijuCopilotContentTypes(): array { $root = raijuCopilotContentRoot(); $permissions = function_exists('raijuCopilotUserPermissions') ? raijuCopilotUserPermissions() : []; $contentRootAvailable = is_dir($root); $pagesAvailable = is_dir($root . '/pages'); $newsAvailable = $contentRootAvailable; $eventsAvailable = $contentRootAvailable; return [ [ 'id' => 'page', 'label' => 'Standard page', 'labelKey' => 'copilot.type_page', 'storage' => 'content/pages/{lang}_{slug}.json', 'available' => $pagesAvailable, 'canCreate' => !empty($permissions['createPage']), 'canPublish' => !empty($permissions['publishPage']), 'status' => !empty($permissions['createPage']) ? 'write-action' : 'forbidden', 'requiredFields' => ['title', 'slug', 'lang', 'content'], 'optionalFields' => ['description'], 'draftSupport' => 'private-page', 'publishSupport' => true ], [ 'id' => 'news', 'label' => 'News post', 'labelKey' => 'copilot.type_news', 'storage' => 'content/news/{slug}.json', 'available' => $newsAvailable, 'canCreate' => !empty($permissions['createNews']), 'canPublish' => !empty($permissions['publishNews']), 'status' => !empty($permissions['createNews']) ? 'write-action' : 'forbidden', 'requiredFields' => ['title', 'slug', 'lang', 'date'], 'optionalFields' => ['excerpt', 'content', 'author'], 'draftSupport' => 'hidden-post', 'publishSupport' => true ], [ 'id' => 'event', 'label' => 'Event / appointment', 'labelKey' => 'copilot.type_event', 'storage' => 'content/events.json', 'available' => $eventsAvailable, 'canCreate' => !empty($permissions['createEvent']), 'canPublish' => !empty($permissions['publishEvent']), 'status' => !empty($permissions['createEvent']) ? 'write-action' : 'forbidden', 'requiredFields' => ['title', 'lang', 'date', 'time', 'location'], 'optionalFields' => ['description', 'admission', 'url'], 'draftSupport' => 'hidden-event', 'publishSupport' => true ], [ 'id' => 'form', 'label' => 'Form', 'labelKey' => 'copilot.type_form', 'available' => is_dir($root . '/forms'), 'canCreate' => false, 'canPublish' => false, 'status' => 'future', 'requiredFields' => [], 'optionalFields' => [], 'draftSupport' => '', 'publishSupport' => false ] ]; } function raijuCopilotKnowledgeBase(): array { return [ [ 'id' => 'overview', 'title' => 'What Raiju is', 'source' => 'README.md', 'summary' => 'Raiju is a flat-file PHP CMS without a database. Content is stored in JSON files, PHP helpers render editable visitor HTML, and logged-in admins/editors can edit content inline or through the dashboard.' ], [ 'id' => 'inline-editing', 'title' => 'Inline editing', 'source' => 'SKILLS.md:make-page-editable', 'summary' => 'Editable text, HTML, links, images, and structured list items are exposed through helper functions such as editableText(), editableHtml(), editableLink(), editableImage(), editableListAttrs(), and editableListGroupItemAttrs(). These helpers add data-page/data-field attributes that the inline editor and Copilot can map to safe JSON fields.' ], [ 'id' => 'standard-pages', 'title' => 'Standard pages', 'source' => 'README.md; SKILLS.md:create-page', 'summary' => 'Standard pages live in content/pages/{lang}_{slug}.json and can render sections without a custom PHP template. Common section types include heading, text, quote, list, image, card, youtube, soundcloud, audio, divider, and spacer.' ], [ 'id' => 'custom-layouts', 'title' => 'Custom layouts', 'source' => 'README.md; SKILLS.md:make-page-editable', 'summary' => 'Custom PHP templates set $contentPage before including the header/content loader, then use editable helper calls for content that editors may change. Site-owned styling belongs in website/page CSS, while core raiju files remain update-owned.' ], [ 'id' => 'content-types', 'title' => 'Pages, news, and events', 'source' => 'README.md; SKILLS.md:create-news-post', 'summary' => 'Pages are stored under content/pages, news posts under content/news, and events in content/events.json. AI-created content should start as private or hidden draft content and require a separate confirmed publish action.' ], [ 'id' => 'forms', 'title' => 'JSON-backed forms', 'source' => 'AI-AGENT-GUIDE.md; SKILLS.md:create-form', 'summary' => 'Simple public forms can be defined in content/forms/*.json and rendered by includes/forms.php. This keeps form fields editable without turning the Copilot into a PHP form builder.' ], [ 'id' => 'ai', 'title' => 'AI features', 'source' => 'README.md; architecture.md', 'summary' => 'AI requests go through the server-side gateway in includes/ai/ai-helper.php. AI settings, feature flags, limits, usage counters, audit logs, image history, and generated image files stay server-side; browser code must not call providers directly.' ], [ 'id' => 'dashboard-structure', 'title' => 'Where things are in the dashboard', 'source' => 'includes/ai/raiju-map.php', 'summary' => raijuMapStructureSummary(raijuMapIsAdmin()) ], [ 'id' => 'security', 'title' => 'Security boundaries', 'source' => 'AI-AGENT-GUIDE.md', 'summary' => 'Editors should not be told to edit PHP, secrets, arbitrary JSON paths, provider credentials, password hashes, or server files. Copilot writes must use authenticated CSRF-protected API actions, manifest-validated targets, signed previews, backups, audit logging, and explicit confirmation.' ] ]; } function raijuCopilotUserPermissions(): array { $role = (string)($_SESSION['admin_role'] ?? 'editor'); $isAdmin = $role === 'admin'; $isEditor = in_array($role, ['admin', 'editor'], true); return [ 'chat' => $isEditor, 'suggestField' => $isEditor, 'applyField' => $isEditor, 'toggleVisibility' => $isEditor, 'undoField' => $isEditor, 'createPage' => $isEditor, 'createNews' => $isEditor, 'createEvent' => $isEditor, 'publishPage' => $isAdmin, 'publishNews' => $isAdmin, 'publishEvent' => $isAdmin, 'generateImage' => $isAdmin ]; } function raijuCopilotCan(string $permission): bool { $permissions = raijuCopilotUserPermissions(); return !empty($permissions[$permission]); } function raijuCopilotLoadPageData(string $contentPage): array { $path = raijuCopilotContentPath($contentPage); if ($path === '') { throw new RuntimeException('Invalid content page.'); } if (!is_file($path)) { throw new RuntimeException('Content page not found.'); } $data = raijuCopilotReadJson($path); if (!$data) { throw new RuntimeException('Content page is empty or invalid.'); } return $data; } function raijuCopilotContentPath(string $contentPage): string { if (raijuPageIsValidContentKey($contentPage)) { return raijuCopilotContentRoot() . '/pages/' . $contentPage . '.json'; } if (preg_match('/^news:([a-z0-9]+(?:-[a-z0-9]+)*)$/', $contentPage, $match)) { return raijuCopilotNewsPath($match[1]); } return ''; } function raijuCopilotNewsPath(string $ref): string { $ref = trim($ref); if ($ref === '' || !preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $ref)) { return ''; } foreach (glob(raijuCopilotContentRoot() . '/news/*.json') ?: [] as $file) { $post = raijuCopilotReadJson($file); if ((string)($post['id'] ?? '') === $ref || (string)($post['slug'] ?? '') === $ref) { return $file; } } return ''; } function raijuCopilotFindField(array $context, string $fieldRef): ?array { $fieldRef = trim($fieldRef); if ($fieldRef === '') { return null; } foreach (($context['page']['fields'] ?? []) as $field) { if (!is_array($field)) { continue; } if (($field['id'] ?? '') === $fieldRef || ($field['path'] ?? '') === $fieldRef) { return $field; } } return null; } function raijuCopilotAllowedSuggestionFields(array $context, ?string $fieldRef = null): array { $allowed = []; foreach (($context['page']['fields'] ?? []) as $field) { if (!is_array($field)) { continue; } $ops = $field['operations'] ?? []; if (!is_array($ops) || !in_array('suggest', $ops, true)) { continue; } if ($fieldRef !== null && $fieldRef !== '' && ($field['id'] ?? '') !== $fieldRef && ($field['path'] ?? '') !== $fieldRef) { continue; } $allowed[] = [ 'id' => (string)($field['id'] ?? ''), 'path' => (string)($field['path'] ?? ''), 'label' => (string)($field['label'] ?? ''), 'type' => (string)($field['type'] ?? 'text'), 'preview' => (string)($field['preview'] ?? '') ]; } return $allowed; } function raijuCopilotAllowedImageFields(array $context, ?string $fieldRef = null): array { $allowed = []; foreach (($context['page']['fields'] ?? []) as $field) { if (!is_array($field)) { continue; } $ops = $field['operations'] ?? []; if (!is_array($ops) || !in_array('generate-image', $ops, true)) { continue; } if ($fieldRef !== null && $fieldRef !== '' && ($field['id'] ?? '') !== $fieldRef && ($field['path'] ?? '') !== $fieldRef) { continue; } $allowed[] = [ 'id' => (string)($field['id'] ?? ''), 'path' => (string)($field['path'] ?? ''), 'label' => (string)($field['label'] ?? ''), 'type' => (string)($field['type'] ?? 'image'), 'preview' => (string)($field['preview'] ?? '') ]; } return $allowed; } function raijuCopilotNormalizeFormatOperation(string $value): string { $value = strtolower(trim($value)); $map = [ 'bold' => 'strong', 'strong' => 'strong', 'fett' => 'strong', 'b' => 'strong', 'italic' => 'em', 'italics' => 'em', 'em' => 'em', 'kursiv' => 'em', 'i' => 'em', 'underline' => 'u', 'unterstrichen' => 'u', 'u' => 'u', ]; if (!isset($map[$value])) { throw new RuntimeException('Unsupported HTML formatting action.'); } return $map[$value]; } function raijuCopilotFormatHtmlValue(string $html, string $format): string { $format = raijuCopilotNormalizeFormatOperation($format); $html = raijuCopilotSanitizeHtml($html); if ($html === '') { throw new RuntimeException('HTML field is empty.'); } $wrap = function (string $inner) use ($format): string { $inner = trim($inner); if ($inner === '') { return ''; } if (preg_match('#^<' . preg_quote($format, '#') . '\b[^>]*>[\s\S]*$#i', $inner)) { return $inner; } return '<' . $format . '>' . $inner . ''; }; $changed = false; $formatted = preg_replace_callback('#<(p|li|h2|h3|blockquote)([^>]*)>([\s\S]*?)#i', function (array $match) use ($wrap, &$changed): string { $changed = true; return '<' . strtolower($match[1]) . $match[2] . '>' . $wrap($match[3]) . ''; }, $html) ?? ''; if (!$changed) { $formatted = $wrap($html); } return raijuCopilotSanitizeHtml($formatted); } function raijuCopilotBuildHtmlFormatProposal(string $contentPage, string $fieldRef, string $format, string $reason = ''): array { $context = raijuCopilotBuildContext($contentPage); if (empty($context['page']['exists'])) { throw new RuntimeException('Content page not found.'); } $field = raijuCopilotFindField($context, $fieldRef); if (!$field || (string)($field['type'] ?? '') !== 'html' || !in_array('suggest', $field['operations'] ?? [], true)) { throw new RuntimeException('Target field does not accept HTML formatting.'); } $pageData = raijuCopilotLoadPageData($contentPage); $current = function_exists('getNestedValue') ? getNestedValue($pageData, (string)$field['path']) : null; $currentText = is_array($current) ? json_encode($current, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : (string)$current; $safeValue = raijuCopilotFormatHtmlValue($currentText, $format); if ($safeValue === '' || $safeValue === $currentText) { throw new RuntimeException('HTML formatting produced no change.'); } $proposal = [ 'action' => 'formatHtmlField', 'fieldId' => (string)$field['id'], 'path' => (string)$field['path'], 'label' => (string)$field['label'], 'type' => 'html', 'current' => raijuCopilotShortText($currentText, 800), 'currentHash' => hash('sha256', $currentText), 'value' => $safeValue, 'allowedValueHashes' => [hash('sha256', $safeValue)], 'preview' => raijuCopilotShortText($safeValue, 800), 'reason' => raijuCopilotShortText($reason !== '' ? $reason : 'Apply safe HTML formatting.', 240) ]; $proposal['proposalSignature'] = raijuCopilotProposalSignature($contentPage, $proposal); return $proposal; } function raijuCopilotNormalizeVisibilityAction(string $value): string { $value = strtolower(trim($value)); $hide = ['hide', 'hidden', 'ausblenden', 'verstecken', 'unsichtbar', 'nicht anzeigen']; $show = ['show', 'visible', 'anzeigen', 'einblenden', 'sichtbar', 'wieder anzeigen']; if (in_array($value, $hide, true)) { return 'hide'; } if (in_array($value, $show, true)) { return 'show'; } throw new RuntimeException('Unsupported visibility action.'); } function raijuCopilotVisibilitySignature(string $contentPage, string $path, string $action, string $currentHash): string { $payload = [ 'contentPage' => $contentPage, 'path' => $path, 'action' => raijuCopilotNormalizeVisibilityAction($action), 'currentHash' => $currentHash ]; return hash_hmac('sha256', json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), raijuCopilotDraftSecret()); } function raijuCopilotBuildVisibilityProposal(string $contentPage, string $fieldRef, string $action, string $reason = ''): array { $action = raijuCopilotNormalizeVisibilityAction($action); $context = raijuCopilotBuildContext($contentPage); if (empty($context['page']['exists'])) { throw new RuntimeException('Content page not found.'); } $field = raijuCopilotFindField($context, $fieldRef); if (!$field || raijuCopilotIsSensitivePath((string)($field['path'] ?? '')) || raijuCopilotIsStructuralPath((string)($field['path'] ?? ''))) { throw new RuntimeException('Target field cannot be hidden or shown by AI Assistant.'); } $pageData = raijuCopilotLoadPageData($contentPage); $hiddenPath = (string)$field['path'] . '__hidden'; $currentHidden = function_exists('getNestedValue') && getNestedValue($pageData, $hiddenPath) === true; if ($action === 'hide' && $currentHidden) { throw new RuntimeException('This field is already hidden.'); } if ($action === 'show' && !$currentHidden) { throw new RuntimeException('This field is already visible.'); } $current = $currentHidden ? 'hidden' : 'visible'; $preview = $action === 'hide' ? 'hidden' : 'visible'; $currentHash = hash('sha256', $current); return [ 'action' => 'toggleFieldVisibility', 'fieldId' => (string)$field['id'], 'path' => (string)$field['path'], 'hiddenPath' => $hiddenPath, 'label' => (string)$field['label'], 'type' => 'visibility', 'current' => $current, 'currentHash' => $currentHash, 'value' => $action, 'preview' => $preview, 'reason' => raijuCopilotShortText($reason !== '' ? $reason : 'Toggle field visibility.', 240), 'visibilitySignature' => raijuCopilotVisibilitySignature($contentPage, (string)$field['path'], $action, $currentHash) ]; } function raijuCopilotExtractJsonObject(string $text): array { $text = trim($text); if ($text === '') { throw new RuntimeException('AI returned an empty suggestion.'); } if (preg_match('/```(?:json)?\s*(.*?)```/is', $text, $match)) { $text = trim($match[1]); } elseif (preg_match('/\{.*\}/s', $text, $match)) { $text = $match[0]; } $data = json_decode($text, true); if (!is_array($data)) { throw new RuntimeException('AI did not return valid suggestion JSON.'); } return $data; } function raijuCopilotNormalizeSuggestionValue($value, string $type): string { if ($type === 'boolean') { if (is_bool($value)) { return $value ? 'true' : 'false'; } $normalized = strtolower(trim((string)$value)); if (in_array($normalized, ['1', 'true', 'yes', 'on', 'enabled', 'visible', 'show'], true)) { return 'true'; } if (in_array($normalized, ['0', 'false', 'no', 'off', 'disabled', 'hidden', 'hide'], true)) { return 'false'; } throw new RuntimeException('AI suggested an invalid boolean value.'); } if ($type === 'select') { return substr(trim(strip_tags((string)$value)), 0, 160); } $value = trim((string)$value); if ($type === 'html') { return raijuCopilotSanitizeHtml($value); } if ($type === 'link') { return raijuCopilotNormalizeLinkValue($value); } return substr(trim(strip_tags($value)), 0, 2000); } function raijuCopilotNormalizeLinkValue(string $value): string { $value = trim($value); if (preg_match('/[<>"\']/', $value)) { throw new RuntimeException('AI suggested an unsafe link.'); } $value = strip_tags($value); $value = preg_replace('/[\x00-\x1F\x7F]/', '', $value) ?? ''; $value = substr($value, 0, 500); if ($value === '') { return ''; } if (preg_match('#^[\\\\/]#', $value) && !str_starts_with($value, '/')) { throw new RuntimeException('AI suggested an unsafe link.'); } if (str_starts_with($value, '//') || str_contains($value, '\\')) { throw new RuntimeException('AI suggested an unsafe link.'); } $scheme = parse_url($value, PHP_URL_SCHEME); if ($scheme !== null && $scheme !== false && $scheme !== '') { $scheme = strtolower((string)$scheme); if (!in_array($scheme, ['http', 'https', 'mailto', 'tel'], true)) { throw new RuntimeException('AI suggested an unsupported link protocol.'); } } if (preg_match('/[<>"\']/', $value)) { throw new RuntimeException('AI suggested an unsafe link.'); } return $value; } function raijuCopilotSanitizeHtml(string $html): string { $html = trim($html); $html = preg_replace('#<\s*(script|style|iframe|object|embed|form|input|button|textarea|select|option|link|meta)\b[^>]*>[\s\S]*?<\s*/\s*\1\s*>#i', '', $html) ?? ''; $html = preg_replace('#<\s*(script|style|iframe|object|embed|form|input|button|textarea|select|option|link|meta)\b[^>]*\/?\s*>#i', '', $html) ?? ''; $html = preg_replace('/\s+(href|src)\s*=\s*(["\'])\s*(javascript|data|vbscript)\s*:[^"\']*\2/i', ' $1="#"', $html) ?? ''; $html = preg_replace('/\s+(href|src)\s*=\s*(javascript|data|vbscript)\s*:[^\s>]*/i', ' $1="#"', $html) ?? ''; $clean = function_exists('sanitizeHtml') ? sanitizeHtml($html) : $html; $clean = strip_tags($clean, '