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) {
29 if ($value ===
null || $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 {
46 if (!isset($_SESSION[
'dbx'][
'dbxKi']) || !is_array($_SESSION[
'dbx'][
'dbxKi'])) {
47 $_SESSION[
'dbx'][
'dbxKi'] = 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 {
56 $token = $this->sanitizeToken($token);
60 $bucket = $this->sessionBucket();
61 return is_array($bucket[$token] ??
null) ? $bucket[$token] : array();
64 private function setJob(
string $token, array $state): array {
65 $token = $this->sanitizeToken($token);
69 $state[
'proc_key'] = $token;
70 $state[
'updated_at'] = date(
'Y-m-d H:i:s');
71 $_SESSION[
'dbx'][
'dbxKi'][self::SESSION_KEY][$token] = $state;
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 {
84 $dir = rtrim(
dbx()->get_file_dir(),
'/\\') .
'/tmp/ki-bundle';
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 {
97 $dir = $this->jobDir($token);
101 $it = new \RecursiveIteratorIterator(
102 new \RecursiveDirectoryIterator(
$dir, \FilesystemIterator::SKIP_DOTS),
103 \RecursiveIteratorIterator::CHILD_FIRST
105 foreach ($it as $file) {
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;
164 $tpl =
dbx()->get_system_obj(
'dbxTPL');
165 return $tpl->get_tpl(
'dbxKi|ki-bundle-start', $this->withModuleBar(array(
166 'api_url' => $this->esc($this->moduleUrl(
'api')),
167 'describe_url' => $this->esc($this->moduleUrl(
'bundle_describe')),
168 'export_url' => $this->esc($this->moduleUrl(
'bundle_export')),
169 'import_url' => $this->esc($this->moduleUrl(
'bundle_import')),
170 'bundle_version' => $this->esc(self::BUNDLE_VERSION),
171 'execute_token' => $this->esc($this->cms()->bundleExecuteToken()),
177 $token = $this->newToken();
178 $payload = $this->readImportPayload($token);
179 $manifest = is_array($payload[
'manifest'] ??
null) ? $payload[
'manifest'] : array();
180 $context = is_array($payload[
'context'] ??
null) ? $payload[
'context'] : array();
181 $job = is_array($payload[
'job'] ??
null) ? $payload[
'job'] : array();
182 $assetsDir = (string)($payload[
'assets_dir'] ??
'');
183 $readme = (string)($payload[
'readme'] ??
'');
185 $validation = $this->validateJob($job, $assetsDir);
186 $preview = $this->buildPreview($job, $assetsDir, $manifest);
189 'area' => self::AREA,
190 'status' =>
'preview_ready',
193 'message' =>
'Bundle geprueft. Ausfuehrung starten.',
194 'manifest' => $manifest,
195 'context' => $context,
197 'assets_dir' => $assetsDir,
199 'validation' => $validation,
200 'preview' => $preview,
202 'step_results' => array(),
203 'total' => count($job[
'steps'] ?? array()),
204 'title' => (
string)($manifest[
'title'] ??
'KI-Bundle'),
205 'recipe' => (
string)($manifest[
'recipe'] ??
''),
206 'lng' => (
string)($manifest[
'lng'] ?? dbxContentLng::current()),
207 'return_run1' => $this->sanitizeReturnRun1((
string)($_POST[
'return_run1'] ??
'')),
209 $this->setJob($token, $state);
211 if ($this->manifestAutoExecute($manifest)) {
213 $this->authorizeAutoExecuteManifest();
214 $state[
'status'] =
'running';
215 $state[
'message'] =
'Bundle geprueft. Automatische Ausfuehrung gestartet.';
216 $this->setJob($token, $state);
217 $next = $this->moduleUrl(
'bundle_process', array(
'proc_key' => $token));
218 return $this->renderProcess($state, $next);
219 }
catch (\Throwable $e) {
220 $state[
'status'] =
'error';
221 $state[
'message'] = $e->getMessage();
222 $this->setJob($token, $state);
223 $next = $this->moduleUrl(
'bundle_process', array(
'proc_key' => $token));
224 return $this->renderProcess($state, $next);
228 return $this->renderPreviewPage($token, $state);
229 }
catch (\Throwable $e) {
230 dbx()->sys_msg(
'error',
'dbxKi',
'bundle_import',
'Import fehlgeschlagen', $e->getMessage());
231 $backRun1 = $this->sanitizeReturnRun1((
string)($_POST[
'return_run1'] ??
''));
232 $backUrl = $backRun1 !==
'' ? $this->moduleUrl($backRun1) : $this->moduleUrl(
'briefing');
233 $tpl =
dbx()->get_system_obj(
'dbxTPL');
234 return $tpl->get_tpl(
'dbxKi|ki-bundle-import-error', $this->withModuleBar(array(
235 'message' => $this->esc($e->getMessage()),
236 'back_url' => $this->esc($backUrl),
237 'briefing_url' => $this->esc($this->moduleUrl(
'briefing')),
242 private function readImportPayload(
string $token): array {
243 $file = $this->firstUploadFile();
245 return $this->extractZipUpload($file, $token);
248 $rawJob = trim((
string)($_POST[
'job_json'] ??
''));
249 if ($rawJob !==
'') {
250 $job = json_decode($rawJob,
true);
251 if (!is_array($job)) {
252 throw new \InvalidArgumentException(
'job_json ist kein gueltiges JSON.');
254 $manifest = array(
'title' =>
'JSON-Job',
'lng' => dbxContentLng::current(),
'bundle_version' => self::BUNDLE_VERSION);
255 $rawManifest = trim((
string)($_POST[
'manifest_json'] ??
''));
256 if ($rawManifest !==
'') {
257 $decoded = json_decode($rawManifest,
true);
258 if (is_array($decoded)) {
259 $manifest = array_merge($manifest, $decoded);
263 'manifest' => $manifest,
264 'context' => array(),
271 throw new \InvalidArgumentException(
'Bitte ZIP-Datei oder job.json senden.');
274 private function firstUploadFile(): array {
275 if (empty($_FILES) || !is_array($_FILES)) {
278 foreach ($_FILES as $file) {
279 if (!is_array($file)) {
282 if (isset($file[
'tmp_name']) && !is_array($file[
'tmp_name'])) {
289 private function extractZipUpload(array $file,
string $token): array {
290 if (($file[
'error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
291 throw new \InvalidArgumentException(
'ZIP-Upload fehlgeschlagen.');
293 $tmp = (string)($file[
'tmp_name'] ??
'');
294 if ($tmp ===
'' || !is_uploaded_file($tmp)) {
295 throw new \InvalidArgumentException(
'ZIP-Upload ungueltig.');
297 if (!class_exists(
'ZipArchive')) {
298 throw new \RuntimeException(
'ZipArchive ist auf dem Server nicht verfuegbar.');
301 $maxBytes = $this->configInt(
'max_bundle_bytes', 52428800);
302 if ((
int)($file[
'size'] ?? 0) > $maxBytes) {
303 throw new \InvalidArgumentException(
'ZIP ueberschreitet max_bundle_bytes.');
306 $token = $this->sanitizeToken($token);
308 throw new \InvalidArgumentException(
'Interner Bundle-Token fehlt.');
310 $dest = $this->jobDir($token);
312 $this->removeJobDir($token);
314 @mkdir($dest, 0777,
true);
316 $zip = new \ZipArchive();
317 if ($zip->open($tmp) !==
true) {
318 throw new \InvalidArgumentException(
'ZIP konnte nicht geoeffnet werden.');
321 $maxFiles = $this->configInt(
'max_bundle_files', 50);
322 $totalUncompressed = 0;
323 for ($i = 0; $i < $zip->numFiles; $i++) {
324 $name = (string)$zip->getNameIndex($i);
325 if ($name ===
'' || strpos($name,
'..') !==
false || $name[0] ===
'/') {
327 throw new \InvalidArgumentException(
'Unzulaessiger ZIP-Pfad: ' . $name);
329 $stat = $zip->statIndex($i);
330 $totalUncompressed += (int)($stat[
'size'] ?? 0);
331 if ($totalUncompressed > $maxBytes) {
333 throw new \InvalidArgumentException(
'Entpacktes Bundle zu gross.');
336 if ($zip->numFiles > $maxFiles) {
338 throw new \InvalidArgumentException(
'Zu viele Dateien im Bundle.');
341 if (!$zip->extractTo($dest)) {
343 throw new \RuntimeException(
'ZIP konnte nicht entpackt werden.');
347 $root = $this->findBundleRoot($dest);
348 $manifest = $this->readJsonFile(
$root .
'/manifest.json',
false);
349 $context = $this->readJsonFile(
$root .
'/context.json',
false);
350 $job = $this->readJsonFile(
$root .
'/job.json',
true);
351 $readme = is_file(
$root .
'/README.md') ? (string)file_get_contents(
$root .
'/README.md') :
'';
352 $assetsDir = is_dir(
$root .
'/assets') ?
$root .
'/assets' :
'';
355 'manifest' => is_array($manifest) ? $manifest : array(),
356 'context' => is_array($context) ? $context : array(),
358 'assets_dir' => $assetsDir,
363 private function findBundleRoot(
string $dest): string {
364 if (is_file($dest .
'/job.json')) {
367 foreach (glob($dest .
'/*', GLOB_ONLYDIR) ?: array() as
$dir) {
368 if (is_file(
$dir .
'/job.json')) {
372 throw new \InvalidArgumentException(
'job.json im Bundle nicht gefunden.');
375 private function readJsonFile(
string $path,
bool $required): array {
376 if (!is_file($path)) {
378 throw new \InvalidArgumentException(
'Pflichtdatei fehlt: ' . basename($path));
382 $raw = file_get_contents($path);
383 $data = json_decode((
string)$raw,
true);
384 if (!is_array($data)) {
385 throw new \InvalidArgumentException(
'Ungueltiges JSON: ' . basename($path));
390 private function validateJob(array $job,
string $assetsDir): array {
391 $steps = $job[
'steps'] ?? null;
392 if (!is_array($steps) || !$steps) {
393 throw new \InvalidArgumentException(
'job.json muss ein nicht-leeres steps-Array enthalten.');
399 $stepResults = array();
402 foreach ($steps as
$index => $step) {
403 if (!is_array($step)) {
404 $errors[] =
'Step ' .
$index .
' ist kein Objekt.';
407 $id = trim((
string)($step[
'id'] ??
''));
409 $errors[] =
'Step ' .
$index .
' ohne id.';
412 if (isset($seenIds[$id])) {
413 $errors[] =
'Doppelte step id: ' . $id;
415 $seenIds[$id] =
true;
417 $action = trim((
string)($step[
'action'] ??
''));
418 if ($action ===
'') {
419 $errors[] =
'Step ' . $id .
' ohne action.';
422 if (!
$cms->bundleIsAllowedInPackage($action)) {
423 $errors[] =
'Step ' . $id .
': Aktion nicht erlaubt (' . $action .
').';
427 $params = is_array($step[
'params'] ??
null) ? $step[
'params'] : array();
429 $this->validateRefTargets($params, array_keys($stepResults));
430 $resolved = $this->resolveParams($params, $stepResults, $assetsDir,
false);
431 $cms->bundleBuildPlan($action, $resolved);
432 $stepResults[$id] = $this->syntheticStepResult($action);
433 }
catch (\Throwable $e) {
434 if ($this->paramsContainRef($params) && $this->isPreviewRefDependencyError($e)) {
435 $stepResults[$id] = $this->syntheticStepResult($action);
438 $warnings[] =
'Step ' . $id .
' (' . $action .
'): ' . $e->getMessage();
443 throw new \InvalidArgumentException(implode(
' ', $errors));
448 'step_count' => count($steps),
449 'warnings' => $warnings,
453 private function buildPreview(array $job,
string $assetsDir, array $manifest): array {
455 $title = trim((
string)($manifest[
'title'] ??
''));
457 $lines[] =
'Titel: ' . $title;
459 $recipe = trim((
string)($manifest[
'recipe'] ??
''));
460 if ($recipe !==
'') {
461 $lines[] =
'Rezept: ' . $recipe;
463 $lng = trim((
string)($manifest[
'lng'] ??
''));
465 $lines[] =
'Sprache: ' . $lng;
468 foreach ($job[
'steps'] ?? array() as $step) {
469 if (!is_array($step)) {
472 $lines[] =
'- ' . ($step[
'id'] ??
'?') .
': ' . ($step[
'action'] ??
'?');
475 if ($assetsDir !==
'' && is_dir($assetsDir)) {
476 $count = count(glob(rtrim($assetsDir,
'/\\') .
'/*') ?: array());
477 $lines[] =
'Assets: ' .
$count .
' Datei(en)';
482 'step_count' => count($job[
'steps'] ?? array()),
486 private function renderPreviewPage(
string $token, array $state): string {
487 $tpl =
dbx()->get_system_obj(
'dbxTPL');
488 $warnings = is_array($state[
'validation'][
'warnings'] ??
null) ? $state[
'validation'][
'warnings'] : array();
489 $lines = is_array($state[
'preview'][
'lines'] ??
null) ? $state[
'preview'][
'lines'] : array();
491 $warningHtml =
'<li class="text-muted">Keine</li>';
492 foreach ($warnings as $warning) {
496 if ($warningHtml ===
'') {
497 foreach ($warnings as $warning) {
498 $warningHtml .=
'<li>' . $this->esc($warning) .
'</li>';
503 foreach ($lines as $line) {
504 $lineHtml .=
'<li class="list-group-item py-1 px-2">' . $this->esc($line) .
'</li>';
508 if ($warningHtml !==
'<li class="text-muted">Keine</li>') {
509 $warningBlock =
'<div class="small text-warning mb-2"><strong>Hinweise</strong><ul class="mb-0 ps-3">'
510 . $warningHtml .
'</ul></div>';
513 $readme = trim((
string)($state[
'readme'] ??
''));
515 if ($readme !==
'') {
516 $readmeHtml =
'<details class="small mt-2"><summary class="text-muted">README der KI-ZIP</summary>'
517 .
'<pre class="small bg-light p-2 rounded mt-1 mb-0" style="max-height:8rem;overflow:auto">'
518 . $this->esc($readme) .
'</pre></details>';
521 $backUrl = $this->returnUrlFromState($state);
522 $startUrl = $this->moduleUrl(
'bundle_process', array(
523 'proc_key' => $token,
525 'proc_cmd' =>
'start',
526 'token' => $this->cms()->bundleExecuteToken(),
528 $footerActions = $this->buildImportFooterActions($state,
true, $startUrl, $backUrl);
529 $barTitle = trim((
string)($state[
'title'] ??
''));
531 return $tpl->get_tpl(
'dbxKi|ki-bundle-preview', $this->withModuleBar(array(
532 'subtitle' => $this->esc((
string)($state[
'recipe'] ??
'')),
533 'step_count' => (
int)($state[
'total'] ?? 0),
534 'preview_list' => $lineHtml,
535 'warning_block' => $warningBlock,
536 'readme_block' => $readmeHtml,
537 'footer_actions' => $footerActions,
538 ),
'bundle_preview',
'', $barTitle));
541 private function cmsAdminPageUrl(
int $cid,
string $lng =
''): string {
542 $url =
'?dbx_modul=dbxContent_admin&dbx_run1=cms&cid=' . (int)$cid .
'&dbx_ajax=1';
543 $lng = strtolower(trim($lng));
545 $url .=
'&dbx_lng=' . rawurlencode($lng);
550 private function resolveContentPageRef(array $state): array {
551 $lng = strtolower(trim((string)($state[
'lng'] ??
'')));
552 $stepResults = is_array($state[
'step_results'] ??
null) ? $state[
'step_results'] : array();
553 foreach ($stepResults as
$result) {
557 $cid = (int)(
$result[
'page_id'] ?? 0);
559 $resultLng = strtolower(trim((
string)(
$result[
'lng'] ??
'')));
562 'lng' => $resultLng !==
'' ? $resultLng : $lng,
567 $job = is_array($state[
'job'] ??
null) ? $state[
'job'] : array();
568 foreach ($job[
'steps'] ?? array() as $step) {
569 if (!is_array($step)) {
572 $action = (string)($step[
'action'] ??
'');
573 $params = is_array($step[
'params'] ??
null) ? $step[
'params'] : array();
574 if ($action ===
'page.update') {
575 $cid = (int)($params[
'id'] ?? 0);
577 $stepLng = strtolower(trim((
string)($params[
'lng'] ??
'')));
578 return array(
'cid' => $cid,
'lng' => $stepLng !==
'' ? $stepLng : $lng);
582 return array(
'cid' => 0,
'lng' => $lng);
585 private function buildImportFooterActions(array $state,
bool $showExecute,
string $executeUrl,
string $backUrl): string {
587 if ($showExecute && $executeUrl !==
'') {
588 $html .=
'<a class="btn btn-primary btn-sm" href="' . $this->esc($executeUrl)
589 .
'"><i class="bi bi-play-fill"></i> Ausfuehren</a>';
592 $pageRef = $this->resolveContentPageRef($state);
593 if ((
int)($pageRef[
'cid'] ?? 0) > 0) {
594 $html .=
'<a class="btn btn-success btn-sm" href="' . $this->esc($this->cmsAdminPageUrl((
int)$pageRef[
'cid'], (
string)($pageRef[
'lng'] ??
'')))
595 .
'"><i class="bi bi-pencil-square"></i> Seite im CMS</a>';
598 $html .=
'<a class="btn btn-outline-primary btn-sm" href="' . $this->esc($this->moduleUrl(
'briefing'))
599 .
'"><i class="bi bi-plus-lg"></i> Neuer KI-Auftrag</a>';
600 $html .=
'<a class="btn btn-outline-secondary btn-sm" href="' . $this->esc($backUrl)
601 .
'"><i class="bi bi-arrow-left"></i> Zurueck</a>';
605 private function sanitizeReturnRun1(
string $run1): string {
608 'briefing_page_create',
609 'briefing_page_update',
610 'briefing_page_translate',
613 $run1 = strtolower(trim($run1));
614 return in_array($run1, $allowed,
true) ? $run1 :
'bundle';
617 private function returnUrlFromState(array $state): string {
618 $run1 = trim((string)($state[
'return_run1'] ??
''));
620 return $this->moduleUrl(
'bundle');
622 return $this->moduleUrl($this->sanitizeReturnRun1($run1));
626 $token = $this->sanitizeToken((string)($_GET[
'proc_key'] ??
''));
628 $token = $this->newToken();
631 $cmd = strtolower(preg_replace(
'/[^a-z_]+/',
'', (
string)($_GET[
'proc_cmd'] ??
'')));
632 $reset = (int)($_GET[
'reset'] ?? 0);
633 $state = $this->getJob($token);
636 return '<div class="container py-4"><div class="alert alert-warning">Bundle-Prozess nicht gefunden.</div>'
637 .
'<p><a class="btn btn-secondary" href="' . $this->esc($this->moduleUrl(
'bundle')) .
'">Zurueck</a></p></div>';
640 if ($reset || $cmd ===
'restart') {
642 if ($cmd ===
'start' || $reset || $cmd ===
'restart') {
643 $this->authorizeBundleExecute();
645 }
catch (\Throwable $e) {
646 $state[
'status'] =
'error';
647 $state[
'message'] = $e->getMessage();
648 $this->setJob($token, $state);
649 $next = $this->moduleUrl(
'bundle_process', array(
'proc_key' => $token));
650 return $this->renderProcess($state, $next);
652 $state[
'status'] =
'running';
653 $state[
'step_pos'] = 0;
654 $state[
'step_results'] = array();
655 $state[
'percent'] = 0;
656 $state[
'step_percent'] = 0;
657 $state[
'message'] =
'Bundle-Ausfuehrung gestartet.';
658 } elseif ($cmd ===
'start' && ($state[
'status'] ??
'') ===
'preview_ready') {
660 $this->authorizeBundleExecute();
661 }
catch (\Throwable $e) {
662 $state[
'status'] =
'error';
663 $state[
'message'] = $e->getMessage();
664 $this->setJob($token, $state);
665 $next = $this->moduleUrl(
'bundle_process', array(
'proc_key' => $token));
666 return $this->renderProcess($state, $next);
668 $state[
'status'] =
'running';
669 $state[
'step_pos'] = 0;
670 $state[
'step_results'] = array();
671 $state[
'percent'] = 0;
672 $state[
'step_percent'] = 0;
673 $state[
'message'] =
'Bundle-Ausfuehrung gestartet.';
676 if ($cmd ===
'pause' && ($state[
'status'] ??
'') ===
'running') {
677 $state[
'status'] =
'paused';
678 $state[
'message'] =
'Bundle angehalten.';
679 } elseif ($cmd ===
'resume' && ($state[
'status'] ??
'') ===
'paused') {
680 $state[
'status'] =
'running';
681 $state[
'message'] =
'Bundle fortgesetzt.';
682 } elseif ($cmd ===
'cancel') {
683 $state[
'status'] =
'canceled';
684 $state[
'message'] =
'Bundle abgebrochen.';
687 if (($state[
'status'] ??
'') ===
'running') {
688 $this->tickJob($state);
691 $this->setJob($token, $state);
692 $next = $this->moduleUrl(
'bundle_process', array(
'proc_key' => $token));
693 return $this->renderProcess($state, $next);
696 private function authorizeBundleExecute(): void {
697 if ((int)
dbx()->get_config(
'dbxKi',
'allow_execute', 1) !== 1) {
698 throw new \RuntimeException(
'Automatische Ausfuehrung ist deaktiviert (allow_execute).');
700 $token = trim((
string)($_GET[
'token'] ?? $_POST[
'token'] ??
''));
701 if (!$this->cms()->bundleCheckExecuteToken($token)) {
702 throw new \RuntimeException(
'Ungueltiges oder abgelaufenes Ausfuehrungs-Token.');
706 private function manifestAutoExecute(array $manifest): bool {
707 $value = $manifest[
'auto_execute'] ?? false;
708 if (is_bool($value)) {
711 $value = strtolower(trim((
string)$value));
712 return in_array($value, array(
'1',
'true',
'yes',
'ja',
'on'),
true);
715 private function authorizeAutoExecuteManifest(): void {
716 if ((int)
dbx()->get_config(
'dbxKi',
'allow_execute', 1) !== 1) {
717 throw new \RuntimeException(
'Automatische Ausfuehrung ist deaktiviert (allow_execute).');
721 private function tickJob(array &$state): void {
722 $steps = is_array($state[
'job'][
'steps'] ?? null) ? $state[
'job'][
'steps'] : array();
723 $total = count($steps);
724 $pos = (int)($state[
'step_pos'] ?? 0);
726 $state[
'status'] =
'error';
727 $state[
'message'] =
'Keine Steps im Bundle.';
730 if ($pos >= $total) {
731 $state[
'status'] =
'finished';
732 $state[
'percent'] = 100;
733 $state[
'step_percent'] = 100;
734 $state[
'message'] = $this->finishedMessage($state);
738 $step = $steps[$pos];
739 $stepId = (string)($step[
'id'] ?? (
'step_' . $pos));
740 $action = (string)($step[
'action'] ??
'');
741 $assetsDir = (string)($state[
'assets_dir'] ??
'');
742 $stepResults = is_array($state[
'step_results'] ??
null) ? $state[
'step_results'] : array();
745 $params = is_array($step[
'params'] ??
null) ? $step[
'params'] : array();
746 $resolved = $this->resolveParams($params, $stepResults, $assetsDir,
false);
747 $plan = $this->cms()->bundleBuildPlan($action, $resolved);
748 $result = $this->cms()->bundleExecutePlan($action, $resolved, $plan);
749 $stepResults[$stepId] = $this->normalizeStepResult($action,
$result);
750 $state[
'step_results'] = $stepResults;
751 $state[
'step_pos'] = $pos + 1;
752 $state[
'message'] =
'Step ' . $stepId .
' (' . $action .
') ausgefuehrt.';
753 }
catch (\Throwable $e) {
754 $state[
'status'] =
'error';
755 $state[
'message'] =
'Fehler in Step ' . $stepId .
': ' . $e->getMessage();
756 dbx()->sys_msg(
'error',
'dbxKi',
'bundle_process', $stepId, $e->getMessage());
760 $done = (int)$state[
'step_pos'];
761 $state[
'percent'] = (int)floor(($done / $total) * 100);
762 $state[
'step_percent'] = 100;
763 if ($done >= $total) {
764 $state[
'status'] =
'finished';
765 $state[
'percent'] = 100;
766 $state[
'message'] = $this->finishedMessage($state);
770 private function finishedMessage(array $state): string {
771 $parts = array(
'Bundle abgeschlossen.');
772 foreach (is_array($state[
'step_results'] ??
null) ? $state[
'step_results'] : array() as $id =>
$result) {
773 if (!empty(
$result[
'page_id'])) {
774 $parts[] =
'Seite #' . (int)
$result[
'page_id'];
777 $parts[] = $id .
' id=' . (int)
$result[
'id'];
780 return implode(
' ', $parts);
783 private function normalizeStepResult(
string $action, array
$result): array {
786 $out[
'id'] = (int)
$result[
'id'];
788 if (strpos($action,
'page.') === 0) {
789 $out[
'page_id'] = (int)(
$result[
'id'] ?? 0);
791 if (strpos($action,
'media.create') === 0) {
792 $out[
'media_id'] = (int)(
$result[
'id'] ?? 0);
794 if ($action ===
'media.assign' && !empty(
$result[
'usage_id'])) {
795 $out[
'usage_id'] = (int)
$result[
'usage_id'];
797 if ($action ===
'translation.apply') {
798 $out[
'page_id'] = (int)(
$result[
'id'] ?? 0);
800 $out[
'lng'] = (string)
$result[
'lng'];
803 if (strpos($action,
'folder.') === 0 && !empty(
$result[
'id'])) {
804 $out[
'folder_id'] = (int)
$result[
'id'];
809 private function resolveParams($value, array $stepResults,
string $assetsDir,
bool $dryRun) {
810 if (is_array($value)) {
812 foreach ($value as $key => $item) {
813 $out[$key] = $this->resolveParams($item, $stepResults, $assetsDir, $dryRun);
815 if (isset($out[
'asset_ref']) && $assetsDir !==
'') {
816 $out = $this->applyAssetRef($out, $assetsDir, $dryRun);
821 if (!is_string($value)) {
825 if (strpos($value,
'$ref:') === 0) {
829 return $this->resolveRef($value, $stepResults);
832 if (strpos($value,
'$ref:') !==
false) {
836 return preg_replace_callback(
'/\$ref:([A-Za-z0-9_.-]+)/',
function($match) use ($stepResults) {
837 return (
string)$this->resolveRef(
'$ref:' . $match[1], $stepResults);
844 private function paramsContainRef($value): bool {
845 if (is_array($value)) {
846 foreach ($value as $item) {
847 if ($this->paramsContainRef($item)) {
853 return is_string($value) && strpos($value,
'$ref:') !==
false;
856 private function validateRefTargets($value, array $completedStepIds): void {
857 if (is_array($value)) {
858 foreach ($value as $item) {
859 $this->validateRefTargets($item, $completedStepIds);
863 if (!is_string($value) || strpos($value,
'$ref:') !== 0) {
866 $raw = substr($value, 5);
867 $parts = explode(
'.', $raw, 2);
868 if (count($parts) !== 2 || $parts[0] ===
'' || $parts[1] ===
'') {
869 throw new \InvalidArgumentException(
'Ungueltige Referenz: ' . $value);
871 if (!in_array($parts[0], $completedStepIds,
true)) {
872 throw new \InvalidArgumentException(
'Referenz auf noch nicht ausgefuehrten Step: ' . $value);
876 private function isPreviewRefDependencyError(\Throwable $e): bool {
877 $msg = $e->getMessage();
878 return strpos($msg,
'größer als 0') !==
false
879 || strpos($msg,
'groesser als 0') !==
false
880 || strpos($msg,
'nicht gefunden') !==
false;
883 private function syntheticStepResult(
string $action): array {
884 if (strpos($action,
'media.create') === 0) {
885 return array(
'id' => 900001,
'media_id' => 900001);
887 if ($action ===
'page.create' || $action ===
'page.update') {
888 return array(
'id' => 900002,
'page_id' => 900002);
890 if ($action ===
'media.assign') {
891 return array(
'usage_id' => 900003);
893 if (strpos($action,
'folder.create') === 0) {
894 return array(
'id' => 900004,
'folder_id' => 900004);
896 return array(
'id' => 900000);
899 private function resolveRef(
string $value, array $stepResults) {
900 $raw = substr($value, 5);
901 $parts = explode(
'.', $raw, 2);
902 if (count($parts) !== 2) {
903 throw new \InvalidArgumentException(
'Ungueltige Referenz: ' . $value);
907 if (!isset($stepResults[$stepId]) || !is_array($stepResults[$stepId])) {
908 throw new \RuntimeException(
'Referenz nicht aufloesbar: ' . $value);
910 if (!array_key_exists(
$field, $stepResults[$stepId])) {
911 throw new \RuntimeException(
'Feld in Referenz fehlt: ' . $value);
913 return $stepResults[$stepId][
$field];
916 private function applyAssetRef(array $params,
string $assetsDir,
bool $dryRun): array {
917 $ref = ltrim(str_replace(
'\\',
'/', (string)($params[
'asset_ref'] ??
'')),
'/');
918 if (strpos($ref,
'assets/') === 0) {
919 $ref = substr($ref, 7);
921 if ($ref ===
'' || strpos($ref,
'..') !==
false) {
922 throw new \InvalidArgumentException(
'Ungueltiger asset_ref.');
924 $path = rtrim(str_replace(
'\\',
'/', $assetsDir),
'/') .
'/' . $ref;
925 $realAssets = realpath($assetsDir);
926 $realFile = realpath($path);
927 if (!$realAssets || !$realFile || strpos($realFile, $realAssets) !== 0 || !is_file($realFile)) {
928 throw new \InvalidArgumentException(
'Asset nicht gefunden: ' . $ref);
931 $params[
'data_base64'] = base64_encode(
'dry-run');
933 $bytes = file_get_contents($realFile);
934 if ($bytes ===
false) {
935 throw new \RuntimeException(
'Asset konnte nicht gelesen werden: ' . $ref);
937 $params[
'data_base64'] = base64_encode($bytes);
939 unset($params[
'asset_ref']);
940 if (empty($params[
'file_name'])) {
941 $params[
'file_name'] = basename($realFile);
946 private function renderProcess(array $state,
string $nextUrl): string {
947 $tpl =
dbx()->get_system_obj(
'dbxTPL');
948 $status = strtolower((
string)($state[
'status'] ??
'running'));
949 $percent = max(0, min(100, (
int)($state[
'percent'] ?? 0)));
950 $stepPercent = max(0, min(100, (
int)($state[
'step_percent'] ?? 0)));
951 $token = (string)($state[
'proc_key'] ??
'');
953 $statusLabels = array(
954 'preview_ready' =>
'Bereit',
955 'running' =>
'Laeuft',
956 'paused' =>
'Pausiert',
957 'finished' =>
'Fertig',
959 'canceled' =>
'Abgebrochen',
961 $statusClasses = array(
962 'running' =>
'text-bg-primary',
963 'paused' =>
'text-bg-warning',
964 'finished' =>
'text-bg-success',
965 'error' =>
'text-bg-danger',
966 'canceled' =>
'text-bg-secondary',
967 'preview_ready' =>
'text-bg-info',
969 $statusIcons = array(
970 'running' =>
'bi bi-arrow-repeat',
971 'paused' =>
'bi bi-pause-fill',
972 'finished' =>
'bi bi-check-lg',
973 'error' =>
'bi bi-exclamation-triangle',
974 'canceled' =>
'bi bi-x-lg',
975 'preview_ready' =>
'bi bi-hourglass',
978 $append =
function(
string $url, array $params): string {
979 foreach ($params as $key => $value) {
980 if ($value ===
null || $value ===
'') {
983 $url .= (strpos($url,
'?') ===
false ?
'?' :
'&')
984 . rawurlencode((
string)$key) .
'=' . rawurlencode((
string)$value);
989 $targetId =
'dbx_ki_bundle_' . substr(md5($token), 0, 12);
990 $autostart = ($status ===
'running') ? 1 : 0;
991 $tokenParam = $this->cms()->bundleExecuteToken();
992 $backUrl = $this->returnUrlFromState($state);
993 $finishedActions =
'';
994 if ($status ===
'finished') {
995 $finishedActions = $this->buildImportFooterActions($state,
false,
'', $backUrl);
998 $barTitle = trim((
string)($state[
'title'] ??
''));
1000 return $tpl->get_tpl(
'dbxKi|ki-bundle-process', $this->withModuleBar(array(
1001 'target_id' => $this->esc($targetId),
1002 'status_key' => $this->esc($status),
1003 'status_label' => $this->esc($statusLabels[$status] ?? $status),
1004 'status_class' => $this->esc($statusClasses[$status] ??
'text-bg-secondary'),
1005 'status_icon' => $this->esc($statusIcons[$status] ??
'bi bi-circle'),
1006 'message' => $this->esc((
string)($state[
'message'] ??
'')),
1007 'percent' => $percent,
1008 'step_percent' => $stepPercent,
1009 'step_label' => $this->esc((
int)($state[
'step_pos'] ?? 0) .
' / ' . (
int)($state[
'total'] ?? 0)),
1010 'updated_at' => $this->esc((
string)($state[
'updated_at'] ??
'')),
1011 'next_url' => $this->esc($nextUrl),
1012 'pause_url' => $this->esc($append($nextUrl, array(
'proc_cmd' =>
'pause',
'token' => $tokenParam))),
1013 'resume_url' => $this->esc($append($nextUrl, array(
'proc_cmd' =>
'resume',
'token' => $tokenParam))),
1014 'cancel_url' => $this->esc($append($nextUrl, array(
'proc_cmd' =>
'cancel',
'token' => $tokenParam))),
1015 'restart_url' => $this->esc($append($nextUrl, array(
'reset' => 1,
'proc_cmd' =>
'restart',
'token' => $tokenParam))),
1016 'back_url' => $this->esc($backUrl),
1017 'autostart' => $autostart,
1019 'pause_visible' =>
'running',
1020 'resume_visible' =>
'paused',
1021 'restart_visible' =>
'paused,canceled,error,finished',
1022 'cancel_visible' =>
'running,paused',
1023 'result_html' => $this->renderResultHtml($state),
1024 'finished_actions' => $finishedActions,
1025 ),
'bundle_process',
'', $barTitle));
1028 private function renderResultHtml(array $state): string {
1029 if (($state[
'status'] ??
'') !==
'finished') {
1033 foreach (is_array($state[
'step_results'] ??
null) ? $state[
'step_results'] : array() as $id =>
$result) {
1037 $bits = array($this->esc($id));
1038 foreach (array(
'page_id',
'media_id',
'folder_id',
'usage_id',
'id') as $key) {
1040 $bits[] = $key .
'=' . (int)
$result[$key];
1043 $items .=
'<li class="list-group-item py-1 px-2 small">' . implode(
' ', $bits) .
'</li>';
1045 if ($items ===
'') {
1048 return '<ul class="list-group list-group-flush small mb-0">' . $items .
'</ul>';
1052 if (!class_exists(
'ZipArchive')) {
1053 dbx()->json_response(array(
'ok' => 0,
'error' =>
'ZipArchive nicht verfuegbar.'),
true);
1056 $cms = $this->cms();
1057 $lng = dbxContentLng::current();
1058 $describe = $this->describeBundle();
1059 $snapshot =
$cms->bundleSnapshot(array(
'lng' => $lng,
'limit' => 50));
1060 $instructionsPath = dirname(__DIR__) .
'/KI-INSTRUCTIONS.md';
1061 $instructions = is_file($instructionsPath) ? file_get_contents($instructionsPath) :
'';
1064 'bundle_version' => self::BUNDLE_VERSION,
1065 'api_version' =>
'0.1',
1066 'title' =>
'dbxKi CMS Briefing',
1067 'recipe' =>
'page.create.v1',
1069 'intent' =>
'create',
1070 'area' => self::AREA,
1071 'auto_execute' =>
true,
1074 $exampleJob = array(
1078 'action' =>
'media.create_base64',
1080 'file_name' =>
'hero.jpg',
1081 'asset_ref' =>
'hero.jpg',
1083 'alt' =>
'Hero-Bild',
1088 'action' =>
'page.create',
1092 'title' =>
'Neue KI-Seite',
1093 'template' =>
'c-title-hero_header-body1-footer',
1094 'content' =>
'<p>Inhalt der Seite</p>',
1098 'id' =>
'hero_assign',
1099 'action' =>
'media.assign',
1101 'media_id' =>
'$ref:hero.media_id',
1102 'content_id' =>
'$ref:page.page_id',
1112 'snapshot' => $snapshot,
1113 'note' =>
'Ordner- und Seiten-IDs aus snapshot fuer folder_id / page.update verwenden.',
1116 $readme =
"# dbxKi CMS Bundle\n\n"
1117 .
"1. job.json und optionale assets/ bearbeiten\n"
1118 .
"2. ZIP mit manifest.json, job.json, README.md erstellen\n"
1119 .
"3. In dbxKi unter Bundle importieren\n";
1121 $tmp = tempnam(sys_get_temp_dir(),
'dbxki');
1122 $zip = new \ZipArchive();
1123 if ($zip->open($tmp, \ZipArchive::OVERWRITE) !==
true) {
1125 throw new \RuntimeException(
'Export-ZIP konnte nicht erstellt werden.');
1128 $zip->addFromString(
'manifest.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
1129 $zip->addFromString(
'job.json', json_encode($exampleJob, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
1130 $zip->addFromString(
'context.json', json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
1131 $zip->addFromString(
'bundle.describe.json', json_encode($describe, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
1132 $zip->addFromString(
'README.md', $readme);
1133 if ($instructions !==
'') {
1134 $zip->addFromString(
'KI-INSTRUCTIONS.md', $instructions);
1138 $name =
'dbxki-cms-briefing-' . $lng .
'.zip';
1139 header(
'Content-Type: application/zip');
1140 header(
'Content-Disposition: attachment; filename="' . $name .
'"');
1141 header(
'Content-Length: ' . filesize($tmp));