10 private const BUNDLE_VERSION =
'1.0';
11 private const SESSION_KEY =
'bundle_jobs';
12 private const AREA =
'cms';
15 return dbx()->get_include_obj(
'dbxKiCmsService',
'dbxKi');
19 return dbx()->get_include_obj(
'dbxKiHelp',
'dbxKi');
22 private function withModuleBar(array $data,
string $screen,
string $actionsHtml =
'',
string $barTitle =
''): array {
23 return array_merge($data, $this->help()->moduleBarTemplateData($screen, $actionsHtml, $barTitle));
26 private function moduleUrl(
string $run1, array $params = array()):
string {
27 $url =
'?dbx_modul=dbxKi&dbx_run1=' . rawurlencode($run1);
28 foreach ($params as $key =>
$value) {
32 $url .=
'&' . rawurlencode((
string)$key) .
'=' . rawurlencode((
string)
$value);
37 private function esc(
$value):
string {
38 return htmlspecialchars((
string)
$value, ENT_QUOTES,
'UTF-8');
41 private function configInt(
string $key,
int $default):
int {
42 return max(1, (
int)
dbx()->get_config(
'dbxKi', $key, $default));
45 private function sessionBucket(): array {
49 if (!isset(
$_SESSION[
'dbx'][
'dbxKi'][self::SESSION_KEY]) || !is_array(
$_SESSION[
'dbx'][
'dbxKi'][self::SESSION_KEY])) {
50 $_SESSION[
'dbx'][
'dbxKi'][self::SESSION_KEY] = array();
52 return $_SESSION[
'dbx'][
'dbxKi'][self::SESSION_KEY];
55 private function getJob(
string $token): array {
60 $bucket = $this->sessionBucket();
61 return is_array($bucket[
$token] ??
null) ? $bucket[
$token] : array();
64 private function setJob(
string $token, array
$state): array {
70 $state[
'updated_at'] = date(
'Y-m-d H:i:s');
75 private function sanitizeToken(
string $token):
string {
76 return preg_replace(
'/[^A-Za-z0-9_-]+/',
'', (
string)
$token);
79 private function newToken():
string {
80 return substr(md5(session_id() . microtime(
true) . mt_rand()), 0, 16);
83 private function tempRoot():
string {
87 @mkdir(
$dir, 0777,
true);
92 private function jobDir(
string $token):
string {
93 return rtrim($this->tempRoot(),
'/\\') . DIRECTORY_SEPARATOR . $this->sanitizeToken(
$token);
96 private function removeJobDir(
string $token):
void {
101 $it = new \RecursiveIteratorIterator(
102 new \RecursiveDirectoryIterator(
$dir, \FilesystemIterator::SKIP_DOTS),
103 \RecursiveIteratorIterator::CHILD_FIRST
106 if (
$file->isDir()) {
107 @rmdir(
$file->getPathname());
109 @unlink(
$file->getPathname());
119 'bundle_version' => self::BUNDLE_VERSION,
120 'api_version' =>
'0.1',
121 'area' => self::AREA,
123 'purpose' =>
'Offline KI-Bundles fuer CMS-Aenderungen ohne direkten API-Zugriff.',
124 'upload_url' => $this->moduleUrl(
'bundle_import'),
125 'export_url' => $this->moduleUrl(
'bundle_export'),
126 'allowed_actions' => $this->allowedActions(),
128 'manifest.json' =>
'Metadaten (title, recipe, lng, intent, auto_execute)',
129 'job.json' =>
'Geordnete steps mit action und params',
130 'context.json' =>
'Optionaler Arbeitskontext',
131 'assets/' =>
'Optionale Bilder und Dateien',
132 'README.md' =>
'Kurzbeschreibung fuer Menschen',
135 'syntax' =>
'$ref:{step_id}.{field}',
136 'examples' => array(
'$ref:hero.id',
'$ref:page.id',
'$ref:hero_assign.usage_id'),
138 'asset_ref' =>
'In media.create_base64: asset_ref = Dateiname relativ zu assets/ (z.B. hero.jpg). Kein data_base64.',
140 'page.create.v1' =>
'Medien hochladen, Seite anlegen, Hero/Gallery zuweisen',
141 'page.update.v1' =>
'Seite per id patchen',
142 'translation.v1' =>
'translation.apply',
144 'automation' => array(
145 'default' =>
'dbxKi importiert ZIP/JSON, validiert alle Steps und zeigt die Vorschau.',
146 'auto_execute' =>
'Wenn manifest.auto_execute=true gesetzt ist, startet dbxKi nach erfolgreicher Validierung automatisch den Bundle-Prozess.',
147 'ki_contract' =>
'Die KI liefert nur manifest.json, job.json und optionale assets/. Keine externen Runner oder eigenen Tools.',
149 'cms' =>
$cms->bundleSystemDescribe(),
153 private function allowedActions(): array {
155 foreach ($this->cms()->bundleActionCatalog() as $action => $meta) {
156 if ($this->cms()->bundleIsAllowedInPackage($action)) {
157 $out[$action] = $meta;
171 private function importForm(
string $template,
string $returnRun1 =
'') {
172 $form =
dbx()->get_system_obj(
'dbxForm');
174 $form->_action = $this->moduleUrl(
'bundle_import');
177 $form->_data[
'return_run1'] = $this->sanitizeReturnRun1($returnRun1);
178 $form->_data[
'bundle_zip'] =
'';
179 $form->_msg_info =
'';
180 $form->add_fld(
'return_run1',
'dbx|hidden', rules:
'parameter', dd:
'');
183 'dbxKi|ki-bundle-zip-upload',
184 label:
$template ===
'ki-briefing-import-panel' ?
'3 KI-Antwort-ZIP' :
'Antwort-ZIP',
186 tooltip:
'ZIP-Datei mit manifest.json und job.json auswaehlen.',
187 errormsg:
'Bitte eine gueltige ZIP-Datei auswaehlen.',
197 return $this->importForm(
'ki-briefing-import-panel', $returnRun1)->run();
204 $form = $this->importForm(
'ki-bundle-start');
205 foreach ($this->withModuleBar(array(
206 'api_url' => $this->
esc($this->moduleUrl(
'api')),
207 'describe_url' => $this->
esc($this->moduleUrl(
'bundle_describe')),
208 'export_url' => $this->
esc($this->moduleUrl(
'bundle_export')),
209 'bundle_version' => $this->
esc(self::BUNDLE_VERSION),
210 'execute_token' => $this->
esc($this->cms()->bundleExecuteToken()),
211 ),
'bundle') as $key =>
$value) {
226 $isBrowserUpload = $this->firstUploadFile() !== array() || isset(
$_POST[
'return_run1']);
227 if ($isBrowserUpload) {
228 $importForm = $this->importForm(
229 'ki-briefing-import-panel',
230 (
string)(
$_POST[
'return_run1'] ??
'')
232 if (!$importForm->submit()) {
233 throw new \RuntimeException(
'Ungueltiger oder abgelaufener Formular-Token.');
236 $token = $this->newToken();
237 $payload = $this->readImportPayload(
$token);
238 $manifest = is_array($payload[
'manifest'] ??
null) ? $payload[
'manifest'] : array();
239 $context = is_array($payload[
'context'] ??
null) ? $payload[
'context'] : array();
240 $job = is_array($payload[
'job'] ??
null) ? $payload[
'job'] : array();
241 $assetsDir = (string)($payload[
'assets_dir'] ??
'');
242 $readme = (string)($payload[
'readme'] ??
'');
244 $validation = $this->validateJob($job, $assetsDir);
245 $preview = $this->buildPreview($job, $assetsDir,
$manifest);
248 'area' => self::AREA,
249 'status' =>
'preview_ready',
252 'message' =>
'Bundle geprueft. Ausfuehrung starten.',
254 'context' => $context,
256 'assets_dir' => $assetsDir,
259 'preview' => $preview,
261 'step_results' => array(),
262 'total' => count($job[
'steps'] ?? array()),
263 'title' => (
string)(
$manifest[
'title'] ??
'KI-Bundle'),
264 'recipe' => (
string)(
$manifest[
'recipe'] ??
''),
265 'lng' => (
string)(
$manifest[
'lng'] ?? dbxContentLng::current()),
266 'return_run1' => $this->sanitizeReturnRun1((
string)(
$_POST[
'return_run1'] ??
'')),
270 if ($this->manifestAutoExecute(
$manifest)) {
272 $this->authorizeAutoExecuteManifest();
273 $state[
'status'] =
'running';
274 $state[
'message'] =
'Bundle geprueft. Automatische Ausfuehrung gestartet.';
276 $next = $this->moduleUrl(
'bundle_process', array(
'proc_key' =>
$token));
277 return $this->renderProcess(
$state, $next);
278 }
catch (\Throwable $e) {
279 $state[
'status'] =
'error';
280 $state[
'message'] = $e->getMessage();
282 $next = $this->moduleUrl(
'bundle_process', array(
'proc_key' =>
$token));
283 return $this->renderProcess(
$state, $next);
288 }
catch (\Throwable $e) {
289 dbx()->sys_msg(
'error',
'dbxKi',
'bundle_import',
'Import fehlgeschlagen', $e->getMessage());
290 $backRun1 = $this->sanitizeReturnRun1((
string)(
$_POST[
'return_run1'] ??
''));
291 $backUrl = $backRun1 !==
'' ? $this->moduleUrl($backRun1) : $this->moduleUrl(
'briefing');
292 $tpl =
dbx()->get_system_obj(
'dbxTPL');
293 return $tpl->get_tpl(
'dbxKi|ki-bundle-import-error', $this->withModuleBar(array(
294 'message' => $this->
esc($e->getMessage()),
295 'back_url' => $this->esc($backUrl),
296 'briefing_url' => $this->esc($this->moduleUrl(
'briefing')),
301 private function readImportPayload(
string $token): array {
302 $file = $this->firstUploadFile();
307 $rawJob = trim((
string)(
$_POST[
'job_json'] ??
''));
308 if ($rawJob !==
'') {
309 $job = json_decode($rawJob,
true);
310 if (!is_array($job)) {
311 throw new \InvalidArgumentException(
'job_json ist kein gueltiges JSON.');
313 $manifest = array(
'title' =>
'JSON-Job',
'lng' => dbxContentLng::current(),
'bundle_version' => self::BUNDLE_VERSION);
314 $rawManifest = trim((
string)(
$_POST[
'manifest_json'] ??
''));
315 if ($rawManifest !==
'') {
316 $decoded = json_decode($rawManifest,
true);
317 if (is_array($decoded)) {
323 'context' => array(),
330 throw new \InvalidArgumentException(
'Bitte ZIP-Datei oder job.json senden.');
333 private function firstUploadFile(): array {
334 if (empty($_FILES) || !is_array($_FILES)) {
337 foreach ($_FILES as
$file) {
338 if (!is_array(
$file)) {
341 if (isset(
$file[
'tmp_name']) && !is_array(
$file[
'tmp_name'])) {
348 private function extractZipUpload(array
$file,
string $token): array {
349 if ((
$file[
'error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
350 throw new \InvalidArgumentException(
'ZIP-Upload fehlgeschlagen.');
352 $tmp = (string)(
$file[
'tmp_name'] ??
'');
353 if (
$tmp ===
'' || !is_uploaded_file(
$tmp)) {
354 throw new \InvalidArgumentException(
'ZIP-Upload ungueltig.');
356 if (!class_exists(
'ZipArchive')) {
357 throw new \RuntimeException(
'ZipArchive ist auf dem Server nicht verfuegbar.');
360 $maxBytes = $this->configInt(
'max_bundle_bytes', 52428800);
361 if ((
int)(
$file[
'size'] ?? 0) > $maxBytes) {
362 throw new \InvalidArgumentException(
'ZIP ueberschreitet max_bundle_bytes.');
367 throw new \InvalidArgumentException(
'Interner Bundle-Token fehlt.');
369 $dest = $this->jobDir(
$token);
371 $this->removeJobDir(
$token);
373 @mkdir($dest, 0777,
true);
375 $zip = new \ZipArchive();
376 if ($zip->open(
$tmp) !==
true) {
377 throw new \InvalidArgumentException(
'ZIP konnte nicht geoeffnet werden.');
380 $maxFiles = $this->configInt(
'max_bundle_files', 50);
381 $totalUncompressed = 0;
382 for ($i = 0; $i < $zip->numFiles; $i++) {
383 $name = (string)$zip->getNameIndex($i);
384 if ($name ===
'' || strpos($name,
'..') !==
false || $name[0] ===
'/') {
386 throw new \InvalidArgumentException(
'Unzulaessiger ZIP-Pfad: ' . $name);
388 $stat = $zip->statIndex($i);
389 $totalUncompressed += (int)($stat[
'size'] ?? 0);
390 if ($totalUncompressed > $maxBytes) {
392 throw new \InvalidArgumentException(
'Entpacktes Bundle zu gross.');
395 if ($zip->numFiles > $maxFiles) {
397 throw new \InvalidArgumentException(
'Zu viele Dateien im Bundle.');
400 if (!$zip->extractTo($dest)) {
402 throw new \RuntimeException(
'ZIP konnte nicht entpackt werden.');
406 $root = $this->findBundleRoot($dest);
408 $context = $this->readJsonFile(
$root .
'/context.json',
false);
409 $job = $this->readJsonFile(
$root .
'/job.json',
true);
410 $readme = is_file(
$root .
'/README.md') ? (string)file_get_contents(
$root .
'/README.md') :
'';
411 $assetsDir = is_dir(
$root .
'/assets') ?
$root .
'/assets' :
'';
415 'context' => is_array($context) ? $context : array(),
417 'assets_dir' => $assetsDir,
422 private function findBundleRoot(
string $dest): string {
423 if (is_file($dest .
'/job.json')) {
426 foreach (glob($dest .
'/*', GLOB_ONLYDIR) ?: array() as
$dir) {
427 if (is_file(
$dir .
'/job.json')) {
431 throw new \InvalidArgumentException(
'job.json im Bundle nicht gefunden.');
434 private function readJsonFile(
string $path,
bool $required): array {
435 if (!is_file($path)) {
437 throw new \InvalidArgumentException(
'Pflichtdatei fehlt: ' . basename($path));
441 $raw = file_get_contents($path);
442 $data = json_decode((
string)$raw,
true);
443 if (!is_array($data)) {
444 throw new \InvalidArgumentException(
'Ungueltiges JSON: ' . basename($path));
449 private function validateJob(array $job,
string $assetsDir): array {
450 $steps = $job[
'steps'] ?? null;
452 throw new \InvalidArgumentException(
'job.json muss ein nicht-leeres steps-Array enthalten.');
458 $stepResults = array();
462 if (!is_array($step)) {
466 $id = trim((
string)($step[
'id'] ??
''));
471 if (isset($seenIds[$id])) {
472 $errors[] =
'Doppelte step id: ' . $id;
474 $seenIds[$id] =
true;
476 $action = trim((
string)($step[
'action'] ??
''));
477 if ($action ===
'') {
478 $errors[] =
'Step ' . $id .
' ohne action.';
481 if (!
$cms->bundleIsAllowedInPackage($action)) {
482 $errors[] =
'Step ' . $id .
': Aktion nicht erlaubt (' . $action .
').';
486 $params = is_array($step[
'params'] ??
null) ? $step[
'params'] : array();
488 $this->validateRefTargets($params, array_keys($stepResults));
489 $resolved = $this->resolveParams($params, $stepResults, $assetsDir,
false);
491 $stepResults[$id] = $this->syntheticStepResult($action);
492 }
catch (\Throwable $e) {
493 if ($this->paramsContainRef($params) && $this->isPreviewRefDependencyError($e)) {
494 $stepResults[$id] = $this->syntheticStepResult($action);
497 $warnings[] =
'Step ' . $id .
' (' . $action .
'): ' . $e->getMessage();
502 throw new \InvalidArgumentException(implode(
' ',
$errors));
507 'step_count' => count(
$steps),
508 'warnings' => $warnings,
512 private function buildPreview(array $job,
string $assetsDir, array
$manifest): array {
514 $title = trim((
string)(
$manifest[
'title'] ??
''));
516 $lines[] =
'Titel: ' . $title;
518 $recipe = trim((
string)(
$manifest[
'recipe'] ??
''));
519 if ($recipe !==
'') {
520 $lines[] =
'Rezept: ' . $recipe;
524 $lines[] =
'Sprache: ' .
$lng;
527 foreach ($job[
'steps'] ?? array() as $step) {
528 if (!is_array($step)) {
531 $lines[] =
'- ' . ($step[
'id'] ??
'?') .
': ' . ($step[
'action'] ??
'?');
534 if ($assetsDir !==
'' && is_dir($assetsDir)) {
535 $count = count(glob(rtrim($assetsDir,
'/\\') .
'/*') ?: array());
536 $lines[] =
'Assets: ' .
$count .
' Datei(en)';
541 'step_count' => count($job[
'steps'] ?? array()),
545 private function renderPreviewPage(
string $token, array
$state): string {
546 $tpl =
dbx()->get_system_obj(
'dbxTPL');
547 $warnings = is_array(
$state[
'validation'][
'warnings'] ??
null) ?
$state[
'validation'][
'warnings'] : array();
548 $lines = is_array(
$state[
'preview'][
'lines'] ??
null) ?
$state[
'preview'][
'lines'] : array();
550 $warningHtml =
'<li class="text-muted">Keine</li>';
555 if ($warningHtml ===
'') {
557 $warningHtml .=
'<li>' . $this->
esc($warning) .
'</li>';
562 foreach ($lines as $line) {
563 $lineHtml .=
'<li class="list-group-item py-1 px-2">' . $this->
esc($line) .
'</li>';
567 if ($warningHtml !==
'<li class="text-muted">Keine</li>') {
568 $warningBlock =
'<div class="small text-warning mb-2"><strong>Hinweise</strong><ul class="mb-0 ps-3">'
569 . $warningHtml .
'</ul></div>';
572 $readme = trim((
string)(
$state[
'readme'] ??
''));
574 if ($readme !==
'') {
575 $readmeHtml =
'<details class="small mt-2"><summary class="text-muted">README der KI-ZIP</summary>'
576 .
'<pre class="small bg-light p-2 rounded mt-1 mb-0" style="max-height:8rem;overflow:auto">'
577 . $this->
esc($readme) .
'</pre></details>';
580 $backUrl = $this->returnUrlFromState(
$state);
581 $startUrl = $this->moduleUrl(
'bundle_process', array(
584 'proc_cmd' =>
'start',
585 'token' => $this->cms()->bundleExecuteToken(),
587 $footerActions = $this->buildImportFooterActions(
$state,
true, $startUrl, $backUrl);
588 $barTitle = trim((
string)(
$state[
'title'] ??
''));
590 return $tpl->get_tpl(
'dbxKi|ki-bundle-preview', $this->withModuleBar(array(
591 'subtitle' => $this->
esc((
string)($state[
'recipe'] ??
'')),
592 'step_count' => (
int)(
$state[
'total'] ?? 0),
593 'preview_list' => $lineHtml,
594 'warning_block' => $warningBlock,
595 'readme_block' => $readmeHtml,
596 'footer_actions' => $footerActions,
597 ),
'bundle_preview',
'', $barTitle));
600 private function cmsAdminPageUrl(
int $cid,
string $lng =
''): string {
601 $url =
'?dbx_modul=dbxContent_admin&dbx_run1=cms&cid=' . (int)$cid;
604 $url .=
'&dbx_lng=' . rawurlencode(
$lng);
609 private function resolveContentPageRef(array
$state): array {
610 $lng = strtolower(trim((string)(
$state[
'lng'] ??
'')));
611 $stepResults = is_array(
$state[
'step_results'] ??
null) ?
$state[
'step_results'] : array();
612 foreach ($stepResults as
$result) {
616 $cid = (int)(
$result[
'page_id'] ?? 0);
618 $resultLng = strtolower(trim((
string)(
$result[
'lng'] ??
'')));
621 'lng' => $resultLng !==
'' ? $resultLng :
$lng,
626 $job = is_array(
$state[
'job'] ??
null) ?
$state[
'job'] : array();
627 foreach ($job[
'steps'] ?? array() as $step) {
628 if (!is_array($step)) {
631 $action = (string)($step[
'action'] ??
'');
632 $params = is_array($step[
'params'] ??
null) ? $step[
'params'] : array();
633 if ($action ===
'page.update') {
634 $cid = (int)($params[
'id'] ?? 0);
636 $stepLng = strtolower(trim((
string)($params[
'lng'] ??
'')));
637 return array(
'cid' => $cid,
'lng' => $stepLng !==
'' ? $stepLng :
$lng);
641 return array(
'cid' => 0,
'lng' =>
$lng);
644 private function buildImportFooterActions(array
$state,
bool $showExecute,
string $executeUrl,
string $backUrl): string {
646 if ($showExecute && $executeUrl !==
'') {
647 $html .=
'<a class="btn btn-primary btn-sm" href="' . $this->
esc($executeUrl)
648 .
'"><i class="bi bi-play-fill"></i> Ausfuehren</a>';
651 $pageRef = $this->resolveContentPageRef(
$state);
652 if ((
int)($pageRef[
'cid'] ?? 0) > 0) {
653 $html .=
'<a class="btn btn-success btn-sm" href="' . $this->
esc($this->cmsAdminPageUrl((
int)$pageRef[
'cid'], (
string)($pageRef[
'lng'] ??
'')))
654 .
'"><i class="bi bi-pencil-square"></i> Seite im CMS</a>';
657 $html .=
'<a class="btn btn-outline-primary btn-sm" href="' . $this->
esc($this->moduleUrl(
'briefing'))
658 .
'"><i class="bi bi-plus-lg"></i> Neuer KI-Auftrag</a>';
659 $html .=
'<a class="btn btn-outline-secondary btn-sm" href="' . $this->
esc($backUrl)
660 .
'"><i class="bi bi-arrow-left"></i> Zurueck</a>';
664 private function sanitizeReturnRun1(
string $run1): string {
667 'briefing_page_create',
668 'briefing_page_update',
669 'briefing_page_translate',
672 $run1 = strtolower(trim($run1));
673 return in_array($run1,
$allowed,
true) ? $run1 :
'bundle';
676 private function returnUrlFromState(array
$state): string {
677 $run1 = trim((string)(
$state[
'return_run1'] ??
''));
679 return $this->moduleUrl(
'bundle');
681 return $this->moduleUrl($this->sanitizeReturnRun1($run1));
685 $token = $this->sanitizeToken((string)(
$_GET[
'proc_key'] ??
''));
687 $token = $this->newToken();
690 $cmd = strtolower(preg_replace(
'/[^a-z_]+/',
'', (
string)(
$_GET[
'proc_cmd'] ??
'')));
691 $reset = (int)(
$_GET[
'reset'] ?? 0);
695 return '<div class="container py-4"><div class="alert alert-warning">Bundle-Prozess nicht gefunden.</div>'
696 .
'<p><a class="btn btn-secondary" href="' . $this->
esc($this->moduleUrl(
'bundle')) .
'">Zurueck</a></p></div>';
699 if ($reset || $cmd ===
'restart') {
701 if ($cmd ===
'start' || $reset || $cmd ===
'restart') {
702 $this->authorizeBundleExecute();
704 }
catch (\Throwable $e) {
705 $state[
'status'] =
'error';
706 $state[
'message'] = $e->getMessage();
708 $next = $this->moduleUrl(
'bundle_process', array(
'proc_key' =>
$token));
709 return $this->renderProcess(
$state, $next);
711 $state[
'status'] =
'running';
713 $state[
'step_results'] = array();
715 $state[
'step_percent'] = 0;
716 $state[
'message'] =
'Bundle-Ausfuehrung gestartet.';
717 } elseif ($cmd ===
'start' && (
$state[
'status'] ??
'') ===
'preview_ready') {
719 $this->authorizeBundleExecute();
720 }
catch (\Throwable $e) {
721 $state[
'status'] =
'error';
722 $state[
'message'] = $e->getMessage();
724 $next = $this->moduleUrl(
'bundle_process', array(
'proc_key' =>
$token));
725 return $this->renderProcess(
$state, $next);
727 $state[
'status'] =
'running';
729 $state[
'step_results'] = array();
731 $state[
'step_percent'] = 0;
732 $state[
'message'] =
'Bundle-Ausfuehrung gestartet.';
735 if ($cmd ===
'pause' && (
$state[
'status'] ??
'') ===
'running') {
736 $state[
'status'] =
'paused';
737 $state[
'message'] =
'Bundle angehalten.';
738 } elseif ($cmd ===
'resume' && (
$state[
'status'] ??
'') ===
'paused') {
739 $state[
'status'] =
'running';
740 $state[
'message'] =
'Bundle fortgesetzt.';
741 } elseif ($cmd ===
'cancel') {
742 $state[
'status'] =
'canceled';
743 $state[
'message'] =
'Bundle abgebrochen.';
746 if ((
$state[
'status'] ??
'') ===
'running') {
751 $next = $this->moduleUrl(
'bundle_process', array(
'proc_key' =>
$token));
752 return $this->renderProcess(
$state, $next);
755 private function authorizeBundleExecute(): void {
756 if ((int)
dbx()->get_config(
'dbxKi',
'allow_execute', 1) !== 1) {
757 throw new \RuntimeException(
'Automatische Ausfuehrung ist deaktiviert (allow_execute).');
760 if (!$this->cms()->bundleCheckExecuteToken(
$token)) {
761 throw new \RuntimeException(
'Ungueltiges oder abgelaufenes Ausfuehrungs-Token.');
765 private function manifestAutoExecute(array
$manifest): bool {
771 return in_array(
$value, array(
'1',
'true',
'yes',
'ja',
'on'),
true);
774 private function authorizeAutoExecuteManifest(): void {
775 if ((int)
dbx()->get_config(
'dbxKi',
'allow_execute', 1) !== 1) {
776 throw new \RuntimeException(
'Automatische Ausfuehrung ist deaktiviert (allow_execute).');
780 private function tickJob(array &
$state): void {
783 $pos = (int)(
$state[
'step_pos'] ?? 0);
785 $state[
'status'] =
'error';
786 $state[
'message'] =
'Keine Steps im Bundle.';
789 if ($pos >= $total) {
790 $state[
'status'] =
'finished';
792 $state[
'step_percent'] = 100;
798 $stepId = (string)($step[
'id'] ?? (
'step_' . $pos));
799 $action = (string)($step[
'action'] ??
'');
800 $assetsDir = (string)(
$state[
'assets_dir'] ??
'');
801 $stepResults = is_array(
$state[
'step_results'] ??
null) ?
$state[
'step_results'] : array();
804 $params = is_array($step[
'params'] ??
null) ? $step[
'params'] : array();
805 $resolved = $this->resolveParams($params, $stepResults, $assetsDir,
false);
808 $stepResults[$stepId] = $this->normalizeStepResult($action,
$result);
809 $state[
'step_results'] = $stepResults;
810 $state[
'step_pos'] = $pos + 1;
811 $state[
'message'] =
'Step ' . $stepId .
' (' . $action .
') ausgefuehrt.';
812 }
catch (\Throwable $e) {
813 $state[
'status'] =
'error';
814 $state[
'message'] =
'Fehler in Step ' . $stepId .
': ' . $e->getMessage();
815 dbx()->sys_msg(
'error',
'dbxKi',
'bundle_process', $stepId, $e->getMessage());
819 $done = (int)
$state[
'step_pos'];
820 $state[
'percent'] = (int)floor(($done / $total) * 100);
821 $state[
'step_percent'] = 100;
822 if ($done >= $total) {
823 $state[
'status'] =
'finished';
829 private function finishedMessage(array
$state): string {
830 $parts = array(
'Bundle abgeschlossen.');
831 foreach (is_array(
$state[
'step_results'] ??
null) ?
$state[
'step_results'] : array() as $id =>
$result) {
832 if (!empty(
$result[
'page_id'])) {
833 $parts[] =
'Seite #' . (int)
$result[
'page_id'];
836 $parts[] = $id .
' id=' . (int)
$result[
'id'];
839 return implode(
' ', $parts);
842 private function normalizeStepResult(
string $action, array
$result): array {
847 if (strpos($action,
'page.') === 0) {
850 if (strpos($action,
'media.create') === 0) {
853 if ($action ===
'media.assign' && !empty(
$result[
'usage_id'])) {
856 if ($action ===
'translation.apply') {
862 if (strpos($action,
'folder.') === 0 && !empty(
$result[
'id'])) {
868 private function resolveParams(
$value, array $stepResults,
string $assetsDir,
bool $dryRun) {
871 foreach (
$value as $key => $item) {
872 $out[$key] = $this->resolveParams($item, $stepResults, $assetsDir, $dryRun);
874 if (isset(
$out[
'asset_ref']) && $assetsDir !==
'') {
875 $out = $this->applyAssetRef(
$out, $assetsDir, $dryRun);
884 if (strpos(
$value,
'$ref:') === 0) {
888 return $this->resolveRef(
$value, $stepResults);
891 if (strpos(
$value,
'$ref:') !==
false) {
895 return preg_replace_callback(
'/\$ref:([A-Za-z0-9_.-]+)/',
function($match) use ($stepResults) {
896 return (
string)$this->resolveRef(
'$ref:' . $match[1], $stepResults);
903 private function paramsContainRef(
$value): bool {
905 foreach (
$value as $item) {
906 if ($this->paramsContainRef($item)) {
912 return is_string(
$value) && strpos(
$value,
'$ref:') !==
false;
915 private function validateRefTargets(
$value, array $completedStepIds): void {
917 foreach (
$value as $item) {
918 $this->validateRefTargets($item, $completedStepIds);
922 if (!is_string(
$value) || strpos(
$value,
'$ref:') !== 0) {
926 $parts = explode(
'.', $raw, 2);
927 if (count($parts) !== 2 || $parts[0] ===
'' || $parts[1] ===
'') {
928 throw new \InvalidArgumentException(
'Ungueltige Referenz: ' .
$value);
930 if (!in_array($parts[0], $completedStepIds,
true)) {
931 throw new \InvalidArgumentException(
'Referenz auf noch nicht ausgefuehrten Step: ' .
$value);
935 private function isPreviewRefDependencyError(\Throwable $e): bool {
936 $msg = $e->getMessage();
937 return strpos($msg,
'größer als 0') !==
false
938 || strpos($msg,
'groesser als 0') !==
false
939 || strpos($msg,
'nicht gefunden') !==
false;
942 private function syntheticStepResult(
string $action): array {
943 if (strpos($action,
'media.create') === 0) {
944 return array(
'id' => 900001,
'media_id' => 900001);
946 if ($action ===
'page.create' || $action ===
'page.update') {
947 return array(
'id' => 900002,
'page_id' => 900002);
949 if ($action ===
'media.assign') {
950 return array(
'usage_id' => 900003);
952 if (strpos($action,
'folder.create') === 0) {
953 return array(
'id' => 900004,
'folder_id' => 900004);
955 return array(
'id' => 900000);
958 private function resolveRef(
string $value, array $stepResults) {
960 $parts = explode(
'.', $raw, 2);
961 if (count($parts) !== 2) {
962 throw new \InvalidArgumentException(
'Ungueltige Referenz: ' .
$value);
966 if (!isset($stepResults[$stepId]) || !is_array($stepResults[$stepId])) {
967 throw new \RuntimeException(
'Referenz nicht aufloesbar: ' .
$value);
969 if (!array_key_exists(
$field, $stepResults[$stepId])) {
970 throw new \RuntimeException(
'Feld in Referenz fehlt: ' .
$value);
972 return $stepResults[$stepId][
$field];
975 private function applyAssetRef(array $params,
string $assetsDir,
bool $dryRun): array {
976 $ref = ltrim(str_replace(
'\\',
'/', (string)($params[
'asset_ref'] ??
'')),
'/');
977 if (strpos($ref,
'assets/') === 0) {
978 $ref = substr($ref, 7);
980 if ($ref ===
'' || strpos($ref,
'..') !==
false) {
981 throw new \InvalidArgumentException(
'Ungueltiger asset_ref.');
983 $path = rtrim(str_replace(
'\\',
'/', $assetsDir),
'/') .
'/' . $ref;
984 $realAssets = realpath($assetsDir);
985 $realFile = realpath($path);
986 if (!$realAssets || !$realFile || strpos($realFile, $realAssets) !== 0 || !is_file($realFile)) {
987 throw new \InvalidArgumentException(
'Asset nicht gefunden: ' . $ref);
990 $params[
'data_base64'] = base64_encode(
'dry-run');
992 $bytes = file_get_contents($realFile);
993 if ($bytes ===
false) {
994 throw new \RuntimeException(
'Asset konnte nicht gelesen werden: ' . $ref);
996 $params[
'data_base64'] = base64_encode($bytes);
998 unset($params[
'asset_ref']);
999 if (empty($params[
'file_name'])) {
1000 $params[
'file_name'] = basename($realFile);
1005 private function renderProcess(array
$state,
string $nextUrl): string {
1006 $tpl =
dbx()->get_system_obj(
'dbxTPL');
1007 $status = strtolower((
string)(
$state[
'status'] ??
'running'));
1008 $percent = max(0, min(100, (
int)(
$state[
'percent'] ?? 0)));
1009 $stepPercent = max(0, min(100, (
int)(
$state[
'step_percent'] ?? 0)));
1012 $statusLabels = array(
1013 'preview_ready' =>
'Bereit',
1014 'running' =>
'Laeuft',
1015 'paused' =>
'Pausiert',
1016 'finished' =>
'Fertig',
1017 'error' =>
'Fehler',
1018 'canceled' =>
'Abgebrochen',
1020 $statusClasses = array(
1021 'running' =>
'text-bg-primary',
1022 'paused' =>
'text-bg-warning',
1023 'finished' =>
'text-bg-success',
1024 'error' =>
'text-bg-danger',
1025 'canceled' =>
'text-bg-secondary',
1026 'preview_ready' =>
'text-bg-info',
1028 $statusIcons = array(
1029 'running' =>
'bi bi-arrow-repeat',
1030 'paused' =>
'bi bi-pause-fill',
1031 'finished' =>
'bi bi-check-lg',
1032 'error' =>
'bi bi-exclamation-triangle',
1033 'canceled' =>
'bi bi-x-lg',
1034 'preview_ready' =>
'bi bi-hourglass',
1037 $append =
function(
string $url, array $params): string {
1042 $url .= (strpos(
$url,
'?') ===
false ?
'?' :
'&')
1043 . rawurlencode((
string)$key) .
'=' . rawurlencode((
string)
$value);
1048 $targetId =
'dbx_ki_bundle_' . substr(md5(
$token), 0, 12);
1049 $autostart = ($status ===
'running') ? 1 : 0;
1050 $tokenParam = $this->cms()->bundleExecuteToken();
1051 $backUrl = $this->returnUrlFromState(
$state);
1052 $finishedActions =
'';
1053 if ($status ===
'finished') {
1054 $finishedActions = $this->buildImportFooterActions(
$state,
false,
'', $backUrl);
1057 $barTitle = trim((
string)(
$state[
'title'] ??
''));
1059 return $tpl->get_tpl(
'dbxKi|ki-bundle-process', $this->withModuleBar(array(
1060 'target_id' => $this->
esc($targetId),
1061 'status_key' => $this->
esc($status),
1062 'status_label' => $this->
esc($statusLabels[$status] ?? $status),
1063 'status_class' => $this->
esc($statusClasses[$status] ??
'text-bg-secondary'),
1064 'status_icon' => $this->
esc($statusIcons[$status] ??
'bi bi-circle'),
1065 'message' => $this->
esc((
string)($state[
'message'] ??
'')),
1066 'percent' => $percent,
1067 'step_percent' => $stepPercent,
1068 'step_label' => $this->
esc((
int)($state[
'step_pos'] ?? 0) .
' / ' . (
int)(
$state[
'total'] ?? 0)),
1069 'updated_at' => $this->
esc((
string)($state[
'updated_at'] ??
'')),
1070 'next_url' => $this->
esc($nextUrl),
1071 'pause_url' => $this->
esc($append($nextUrl, array(
'proc_cmd' =>
'pause',
'token' => $tokenParam))),
1072 'resume_url' => $this->
esc($append($nextUrl, array(
'proc_cmd' =>
'resume',
'token' => $tokenParam))),
1073 'cancel_url' => $this->
esc($append($nextUrl, array(
'proc_cmd' =>
'cancel',
'token' => $tokenParam))),
1074 'restart_url' => $this->
esc($append($nextUrl, array(
'reset' => 1,
'proc_cmd' =>
'restart',
'token' => $tokenParam))),
1075 'back_url' => $this->
esc($backUrl),
1076 'autostart' => $autostart,
1078 'pause_visible' =>
'running',
1079 'resume_visible' =>
'paused',
1080 'restart_visible' =>
'paused,canceled,error,finished',
1081 'cancel_visible' =>
'running,paused',
1082 'result_html' => $this->renderResultHtml(
$state),
1083 'finished_actions' => $finishedActions,
1084 ),
'bundle_process',
'', $barTitle));
1087 private function renderResultHtml(array
$state): string {
1088 if ((
$state[
'status'] ??
'') !==
'finished') {
1092 foreach (is_array(
$state[
'step_results'] ??
null) ?
$state[
'step_results'] : array() as $id =>
$result) {
1096 $bits = array($this->
esc($id));
1097 foreach (array(
'page_id',
'media_id',
'folder_id',
'usage_id',
'id') as $key) {
1099 $bits[] = $key .
'=' . (int)
$result[$key];
1102 $items .=
'<li class="list-group-item py-1 px-2 small">' . implode(
' ', $bits) .
'</li>';
1107 return '<ul class="list-group list-group-flush small mb-0">' .
$items .
'</ul>';
1111 if (!class_exists(
'ZipArchive')) {
1112 dbx()->json_response(array(
'ok' => 0,
'error' =>
'ZipArchive nicht verfuegbar.'),
true);
1115 $cms = $this->cms();
1116 $lng = dbxContentLng::current();
1117 $describe = $this->describeBundle();
1118 $snapshot =
$cms->bundleSnapshot(array(
'lng' =>
$lng,
'limit' => 50));
1119 $instructionsPath = dirname(__DIR__) .
'/KI-INSTRUCTIONS.md';
1120 $instructions = is_file($instructionsPath) ? file_get_contents($instructionsPath) :
'';
1123 'bundle_version' => self::BUNDLE_VERSION,
1124 'api_version' =>
'0.1',
1125 'title' =>
'dbxKi CMS Briefing',
1126 'recipe' =>
'page.create.v1',
1128 'intent' =>
'create',
1129 'area' => self::AREA,
1130 'auto_execute' =>
true,
1133 $exampleJob = array(
1137 'action' =>
'media.create_base64',
1139 'file_name' =>
'hero.jpg',
1140 'asset_ref' =>
'hero.jpg',
1142 'alt' =>
'Hero-Bild',
1147 'action' =>
'page.create',
1151 'title' =>
'Neue KI-Seite',
1152 'template' =>
'c-title-hero_header-body1-footer',
1153 'content' =>
'<p>Inhalt der Seite</p>',
1157 'id' =>
'hero_assign',
1158 'action' =>
'media.assign',
1160 'media_id' =>
'$ref:hero.media_id',
1161 'content_id' =>
'$ref:page.page_id',
1171 'snapshot' => $snapshot,
1172 'note' =>
'Ordner- und Seiten-IDs aus snapshot fuer folder_id / page.update verwenden.',
1175 $readme =
"# dbxKi CMS Bundle\n\n"
1176 .
"1. job.json und optionale assets/ bearbeiten\n"
1177 .
"2. ZIP mit manifest.json, job.json, README.md erstellen\n"
1178 .
"3. In dbxKi unter Bundle importieren\n";
1180 $tmp = tempnam(sys_get_temp_dir(),
'dbxki');
1181 $zip = new \ZipArchive();
1182 if ($zip->open(
$tmp, \ZipArchive::OVERWRITE) !==
true) {
1184 throw new \RuntimeException(
'Export-ZIP konnte nicht erstellt werden.');
1187 $zip->addFromString(
'manifest.json', json_encode(
$manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
1188 $zip->addFromString(
'job.json', json_encode($exampleJob, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
1189 $zip->addFromString(
'context.json', json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
1190 $zip->addFromString(
'bundle.describe.json', json_encode($describe, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
1191 $zip->addFromString(
'README.md', $readme);
1197 $name =
'dbxki-cms-briefing-' .
$lng .
'.zip';
1198 header(
'Content-Type: application/zip');
1199 header(
'Content-Disposition: attachment; filename="' . $name .
'"');
1200 header(
'Content-Length: ' . filesize(
$tmp));