dbXapp 2.0
RAD, CMS, Module und Runtime-IDE fuer dbXapp
Loading...
Searching...
No Matches
dbxKiBundleService.class.php
Go to the documentation of this file.
1<?php
2namespace dbx\dbxKi;
3
4use dbx\dbxContent\dbxContentLng;
5
6require_once dirname(__DIR__, 2) . '/dbxContent/include/dbxContent_bootstrap_sync.php';
7
9
10 private const BUNDLE_VERSION = '1.0';
11 private const SESSION_KEY = 'bundle_jobs';
12 private const AREA = 'cms';
13
14 private function cms(): dbxKiCmsService {
15 return dbx()->get_include_obj('dbxKiCmsService', 'dbxKi');
16 }
17
18 private function help(): dbxKiHelp {
19 return dbx()->get_include_obj('dbxKiHelp', 'dbxKi');
20 }
21
22 private function withModuleBar(array $data, string $screen, string $actionsHtml = '', string $barTitle = ''): array {
23 return array_merge($data, $this->help()->moduleBarTemplateData($screen, $actionsHtml, $barTitle));
24 }
25
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 === '') {
30 continue;
31 }
32 $url .= '&' . rawurlencode((string)$key) . '=' . rawurlencode((string)$value);
33 }
34 return $url;
35 }
36
37 private function esc($value): string {
38 return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
39 }
40
41 private function configInt(string $key, int $default): int {
42 return max(1, (int)dbx()->get_config('dbxKi', $key, $default));
43 }
44
45 private function sessionBucket(): array {
46 if (!isset($_SESSION['dbx']['dbxKi']) || !is_array($_SESSION['dbx']['dbxKi'])) {
47 $_SESSION['dbx']['dbxKi'] = array();
48 }
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();
51 }
52 return $_SESSION['dbx']['dbxKi'][self::SESSION_KEY];
53 }
54
55 private function getJob(string $token): array {
56 $token = $this->sanitizeToken($token);
57 if ($token === '') {
58 return array();
59 }
60 $bucket = $this->sessionBucket();
61 return is_array($bucket[$token] ?? null) ? $bucket[$token] : array();
62 }
63
64 private function setJob(string $token, array $state): array {
65 $token = $this->sanitizeToken($token);
66 if ($token === '') {
67 return array();
68 }
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;
72 return $state;
73 }
74
75 private function sanitizeToken(string $token): string {
76 return preg_replace('/[^A-Za-z0-9_-]+/', '', (string)$token);
77 }
78
79 private function newToken(): string {
80 return substr(md5(session_id() . microtime(true) . mt_rand()), 0, 16);
81 }
82
83 private function tempRoot(): string {
84 $dir = rtrim(dbx()->get_file_dir(), '/\\') . '/tmp/ki-bundle';
85 $dir = function_exists('dbx_os_path_file') ? dbx_os_path_file($dir) : $dir;
86 if (!is_dir($dir)) {
87 @mkdir($dir, 0777, true);
88 }
89 return $dir;
90 }
91
92 private function jobDir(string $token): string {
93 return rtrim($this->tempRoot(), '/\\') . DIRECTORY_SEPARATOR . $this->sanitizeToken($token);
94 }
95
96 private function removeJobDir(string $token): void {
97 $dir = $this->jobDir($token);
98 if (!is_dir($dir)) {
99 return;
100 }
101 $it = new \RecursiveIteratorIterator(
102 new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
103 \RecursiveIteratorIterator::CHILD_FIRST
104 );
105 foreach ($it as $file) {
106 if ($file->isDir()) {
107 @rmdir($file->getPathname());
108 } else {
109 @unlink($file->getPathname());
110 }
111 }
112 @rmdir($dir);
113 }
114
115 public function describeBundle(): array {
116 $cms = $this->cms();
117 return array(
118 'ok' => 1,
119 'bundle_version' => self::BUNDLE_VERSION,
120 'api_version' => '0.1',
121 'area' => self::AREA,
122 'module' => 'dbxKi',
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(),
127 'files' => array(
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',
133 ),
134 'refs' => array(
135 'syntax' => '$ref:{step_id}.{field}',
136 'examples' => array('$ref:hero.id', '$ref:page.id', '$ref:hero_assign.usage_id'),
137 ),
138 'asset_ref' => 'In media.create_base64: asset_ref = Dateiname relativ zu assets/ (z.B. hero.jpg). Kein data_base64.',
139 'recipes' => array(
140 'page.create.v1' => 'Medien hochladen, Seite anlegen, Hero/Gallery zuweisen',
141 'page.update.v1' => 'Seite per id patchen',
142 'translation.v1' => 'translation.apply',
143 ),
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.',
148 ),
149 'cms' => $cms->bundleSystemDescribe(),
150 );
151 }
152
153 private function allowedActions(): array {
154 $out = array();
155 foreach ($this->cms()->bundleActionCatalog() as $action => $meta) {
156 if ($this->cms()->bundleIsAllowedInPackage($action)) {
157 $out[$action] = $meta;
158 }
159 }
160 return $out;
161 }
162
163 public function renderStartPage(): string {
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()),
172 ), 'bundle'));
173 }
174
175 public function handleImport(): string {
176 try {
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'] ?? '');
184
185 $validation = $this->validateJob($job, $assetsDir);
186 $preview = $this->buildPreview($job, $assetsDir, $manifest);
187
188 $state = array(
189 'area' => self::AREA,
190 'status' => 'preview_ready',
191 'percent' => 0,
192 'step_percent' => 0,
193 'message' => 'Bundle geprueft. Ausfuehrung starten.',
194 'manifest' => $manifest,
195 'context' => $context,
196 'job' => $job,
197 'assets_dir' => $assetsDir,
198 'readme' => $readme,
199 'validation' => $validation,
200 'preview' => $preview,
201 'step_pos' => 0,
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'] ?? '')),
208 );
209 $this->setJob($token, $state);
210
211 if ($this->manifestAutoExecute($manifest)) {
212 try {
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);
225 }
226 }
227
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')),
238 ), 'bundle'));
239 }
240 }
241
242 private function readImportPayload(string $token): array {
243 $file = $this->firstUploadFile();
244 if ($file) {
245 return $this->extractZipUpload($file, $token);
246 }
247
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.');
253 }
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);
260 }
261 }
262 return array(
263 'manifest' => $manifest,
264 'context' => array(),
265 'job' => $job,
266 'assets_dir' => '',
267 'readme' => '',
268 );
269 }
270
271 throw new \InvalidArgumentException('Bitte ZIP-Datei oder job.json senden.');
272 }
273
274 private function firstUploadFile(): array {
275 if (empty($_FILES) || !is_array($_FILES)) {
276 return array();
277 }
278 foreach ($_FILES as $file) {
279 if (!is_array($file)) {
280 continue;
281 }
282 if (isset($file['tmp_name']) && !is_array($file['tmp_name'])) {
283 return $file;
284 }
285 }
286 return array();
287 }
288
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.');
292 }
293 $tmp = (string)($file['tmp_name'] ?? '');
294 if ($tmp === '' || !is_uploaded_file($tmp)) {
295 throw new \InvalidArgumentException('ZIP-Upload ungueltig.');
296 }
297 if (!class_exists('ZipArchive')) {
298 throw new \RuntimeException('ZipArchive ist auf dem Server nicht verfuegbar.');
299 }
300
301 $maxBytes = $this->configInt('max_bundle_bytes', 52428800);
302 if ((int)($file['size'] ?? 0) > $maxBytes) {
303 throw new \InvalidArgumentException('ZIP ueberschreitet max_bundle_bytes.');
304 }
305
306 $token = $this->sanitizeToken($token);
307 if ($token === '') {
308 throw new \InvalidArgumentException('Interner Bundle-Token fehlt.');
309 }
310 $dest = $this->jobDir($token);
311 if (is_dir($dest)) {
312 $this->removeJobDir($token);
313 }
314 @mkdir($dest, 0777, true);
315
316 $zip = new \ZipArchive();
317 if ($zip->open($tmp) !== true) {
318 throw new \InvalidArgumentException('ZIP konnte nicht geoeffnet werden.');
319 }
320
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] === '/') {
326 $zip->close();
327 throw new \InvalidArgumentException('Unzulaessiger ZIP-Pfad: ' . $name);
328 }
329 $stat = $zip->statIndex($i);
330 $totalUncompressed += (int)($stat['size'] ?? 0);
331 if ($totalUncompressed > $maxBytes) {
332 $zip->close();
333 throw new \InvalidArgumentException('Entpacktes Bundle zu gross.');
334 }
335 }
336 if ($zip->numFiles > $maxFiles) {
337 $zip->close();
338 throw new \InvalidArgumentException('Zu viele Dateien im Bundle.');
339 }
340
341 if (!$zip->extractTo($dest)) {
342 $zip->close();
343 throw new \RuntimeException('ZIP konnte nicht entpackt werden.');
344 }
345 $zip->close();
346
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' : '';
353
354 return array(
355 'manifest' => is_array($manifest) ? $manifest : array(),
356 'context' => is_array($context) ? $context : array(),
357 'job' => $job,
358 'assets_dir' => $assetsDir,
359 'readme' => $readme,
360 );
361 }
362
363 private function findBundleRoot(string $dest): string {
364 if (is_file($dest . '/job.json')) {
365 return $dest;
366 }
367 foreach (glob($dest . '/*', GLOB_ONLYDIR) ?: array() as $dir) {
368 if (is_file($dir . '/job.json')) {
369 return $dir;
370 }
371 }
372 throw new \InvalidArgumentException('job.json im Bundle nicht gefunden.');
373 }
374
375 private function readJsonFile(string $path, bool $required): array {
376 if (!is_file($path)) {
377 if ($required) {
378 throw new \InvalidArgumentException('Pflichtdatei fehlt: ' . basename($path));
379 }
380 return array();
381 }
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));
386 }
387 return $data;
388 }
389
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.');
394 }
395
396 $warnings = array();
397 $errors = array();
398 $seenIds = array();
399 $stepResults = array();
400 $cms = $this->cms();
401
402 foreach ($steps as $index => $step) {
403 if (!is_array($step)) {
404 $errors[] = 'Step ' . $index . ' ist kein Objekt.';
405 continue;
406 }
407 $id = trim((string)($step['id'] ?? ''));
408 if ($id === '') {
409 $errors[] = 'Step ' . $index . ' ohne id.';
410 continue;
411 }
412 if (isset($seenIds[$id])) {
413 $errors[] = 'Doppelte step id: ' . $id;
414 }
415 $seenIds[$id] = true;
416
417 $action = trim((string)($step['action'] ?? ''));
418 if ($action === '') {
419 $errors[] = 'Step ' . $id . ' ohne action.';
420 continue;
421 }
422 if (!$cms->bundleIsAllowedInPackage($action)) {
423 $errors[] = 'Step ' . $id . ': Aktion nicht erlaubt (' . $action . ').';
424 continue;
425 }
426
427 $params = is_array($step['params'] ?? null) ? $step['params'] : array();
428 try {
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);
436 continue;
437 }
438 $warnings[] = 'Step ' . $id . ' (' . $action . '): ' . $e->getMessage();
439 }
440 }
441
442 if ($errors) {
443 throw new \InvalidArgumentException(implode(' ', $errors));
444 }
445
446 return array(
447 'ok' => 1,
448 'step_count' => count($steps),
449 'warnings' => $warnings,
450 );
451 }
452
453 private function buildPreview(array $job, string $assetsDir, array $manifest): array {
454 $lines = array();
455 $title = trim((string)($manifest['title'] ?? ''));
456 if ($title !== '') {
457 $lines[] = 'Titel: ' . $title;
458 }
459 $recipe = trim((string)($manifest['recipe'] ?? ''));
460 if ($recipe !== '') {
461 $lines[] = 'Rezept: ' . $recipe;
462 }
463 $lng = trim((string)($manifest['lng'] ?? ''));
464 if ($lng !== '') {
465 $lines[] = 'Sprache: ' . $lng;
466 }
467
468 foreach ($job['steps'] ?? array() as $step) {
469 if (!is_array($step)) {
470 continue;
471 }
472 $lines[] = '- ' . ($step['id'] ?? '?') . ': ' . ($step['action'] ?? '?');
473 }
474
475 if ($assetsDir !== '' && is_dir($assetsDir)) {
476 $count = count(glob(rtrim($assetsDir, '/\\') . '/*') ?: array());
477 $lines[] = 'Assets: ' . $count . ' Datei(en)';
478 }
479
480 return array(
481 'lines' => $lines,
482 'step_count' => count($job['steps'] ?? array()),
483 );
484 }
485
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();
490
491 $warningHtml = '<li class="text-muted">Keine</li>';
492 foreach ($warnings as $warning) {
493 $warningHtml = '';
494 break;
495 }
496 if ($warningHtml === '') {
497 foreach ($warnings as $warning) {
498 $warningHtml .= '<li>' . $this->esc($warning) . '</li>';
499 }
500 }
501
502 $lineHtml = '';
503 foreach ($lines as $line) {
504 $lineHtml .= '<li class="list-group-item py-1 px-2">' . $this->esc($line) . '</li>';
505 }
506
507 $warningBlock = '';
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>';
511 }
512
513 $readme = trim((string)($state['readme'] ?? ''));
514 $readmeHtml = '';
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>';
519 }
520
521 $backUrl = $this->returnUrlFromState($state);
522 $startUrl = $this->moduleUrl('bundle_process', array(
523 'proc_key' => $token,
524 'reset' => 1,
525 'proc_cmd' => 'start',
526 'token' => $this->cms()->bundleExecuteToken(),
527 ));
528 $footerActions = $this->buildImportFooterActions($state, true, $startUrl, $backUrl);
529 $barTitle = trim((string)($state['title'] ?? ''));
530
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));
539 }
540
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));
544 if ($lng !== '') {
545 $url .= '&dbx_lng=' . rawurlencode($lng);
546 }
547 return $url;
548 }
549
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) {
554 if (!is_array($result)) {
555 continue;
556 }
557 $cid = (int)($result['page_id'] ?? 0);
558 if ($cid > 0) {
559 $resultLng = strtolower(trim((string)($result['lng'] ?? '')));
560 return array(
561 'cid' => $cid,
562 'lng' => $resultLng !== '' ? $resultLng : $lng,
563 );
564 }
565 }
566
567 $job = is_array($state['job'] ?? null) ? $state['job'] : array();
568 foreach ($job['steps'] ?? array() as $step) {
569 if (!is_array($step)) {
570 continue;
571 }
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);
576 if ($cid > 0) {
577 $stepLng = strtolower(trim((string)($params['lng'] ?? '')));
578 return array('cid' => $cid, 'lng' => $stepLng !== '' ? $stepLng : $lng);
579 }
580 }
581 }
582 return array('cid' => 0, 'lng' => $lng);
583 }
584
585 private function buildImportFooterActions(array $state, bool $showExecute, string $executeUrl, string $backUrl): string {
586 $html = '';
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>';
590 }
591
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>';
596 }
597
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>';
602 return $html;
603 }
604
605 private function sanitizeReturnRun1(string $run1): string {
606 $allowed = array(
607 'briefing',
608 'briefing_page_create',
609 'briefing_page_update',
610 'briefing_page_translate',
611 'bundle',
612 );
613 $run1 = strtolower(trim($run1));
614 return in_array($run1, $allowed, true) ? $run1 : 'bundle';
615 }
616
617 private function returnUrlFromState(array $state): string {
618 $run1 = trim((string)($state['return_run1'] ?? ''));
619 if ($run1 === '') {
620 return $this->moduleUrl('bundle');
621 }
622 return $this->moduleUrl($this->sanitizeReturnRun1($run1));
623 }
624
625 public function handleProcess(): string {
626 $token = $this->sanitizeToken((string)($_GET['proc_key'] ?? ''));
627 if ($token === '') {
628 $token = $this->newToken();
629 }
630
631 $cmd = strtolower(preg_replace('/[^a-z_]+/', '', (string)($_GET['proc_cmd'] ?? '')));
632 $reset = (int)($_GET['reset'] ?? 0);
633 $state = $this->getJob($token);
634
635 if (!$state) {
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>';
638 }
639
640 if ($reset || $cmd === 'restart') {
641 try {
642 if ($cmd === 'start' || $reset || $cmd === 'restart') {
643 $this->authorizeBundleExecute();
644 }
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);
651 }
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') {
659 try {
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);
667 }
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.';
674 }
675
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.';
685 }
686
687 if (($state['status'] ?? '') === 'running') {
688 $this->tickJob($state);
689 }
690
691 $this->setJob($token, $state);
692 $next = $this->moduleUrl('bundle_process', array('proc_key' => $token));
693 return $this->renderProcess($state, $next);
694 }
695
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).');
699 }
700 $token = trim((string)($_GET['token'] ?? $_POST['token'] ?? ''));
701 if (!$this->cms()->bundleCheckExecuteToken($token)) {
702 throw new \RuntimeException('Ungueltiges oder abgelaufenes Ausfuehrungs-Token.');
703 }
704 }
705
706 private function manifestAutoExecute(array $manifest): bool {
707 $value = $manifest['auto_execute'] ?? false;
708 if (is_bool($value)) {
709 return $value;
710 }
711 $value = strtolower(trim((string)$value));
712 return in_array($value, array('1', 'true', 'yes', 'ja', 'on'), true);
713 }
714
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).');
718 }
719 }
720
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);
725 if ($total <= 0) {
726 $state['status'] = 'error';
727 $state['message'] = 'Keine Steps im Bundle.';
728 return;
729 }
730 if ($pos >= $total) {
731 $state['status'] = 'finished';
732 $state['percent'] = 100;
733 $state['step_percent'] = 100;
734 $state['message'] = $this->finishedMessage($state);
735 return;
736 }
737
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();
743
744 try {
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());
757 return;
758 }
759
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);
767 }
768 }
769
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'];
775 }
776 if (!empty($result['id']) && empty($result['page_id']) && empty($result['media_id'])) {
777 $parts[] = $id . ' id=' . (int)$result['id'];
778 }
779 }
780 return implode(' ', $parts);
781 }
782
783 private function normalizeStepResult(string $action, array $result): array {
784 $out = $result;
785 if (!empty($result['id'])) {
786 $out['id'] = (int)$result['id'];
787 }
788 if (strpos($action, 'page.') === 0) {
789 $out['page_id'] = (int)($result['id'] ?? 0);
790 }
791 if (strpos($action, 'media.create') === 0) {
792 $out['media_id'] = (int)($result['id'] ?? 0);
793 }
794 if ($action === 'media.assign' && !empty($result['usage_id'])) {
795 $out['usage_id'] = (int)$result['usage_id'];
796 }
797 if ($action === 'translation.apply') {
798 $out['page_id'] = (int)($result['id'] ?? 0);
799 if (!empty($result['lng'])) {
800 $out['lng'] = (string)$result['lng'];
801 }
802 }
803 if (strpos($action, 'folder.') === 0 && !empty($result['id'])) {
804 $out['folder_id'] = (int)$result['id'];
805 }
806 return $out;
807 }
808
809 private function resolveParams($value, array $stepResults, string $assetsDir, bool $dryRun) {
810 if (is_array($value)) {
811 $out = array();
812 foreach ($value as $key => $item) {
813 $out[$key] = $this->resolveParams($item, $stepResults, $assetsDir, $dryRun);
814 }
815 if (isset($out['asset_ref']) && $assetsDir !== '') {
816 $out = $this->applyAssetRef($out, $assetsDir, $dryRun);
817 }
818 return $out;
819 }
820
821 if (!is_string($value)) {
822 return $value;
823 }
824
825 if (strpos($value, '$ref:') === 0) {
826 if ($dryRun) {
827 return 0;
828 }
829 return $this->resolveRef($value, $stepResults);
830 }
831
832 if (strpos($value, '$ref:') !== false) {
833 if ($dryRun) {
834 return $value;
835 }
836 return preg_replace_callback('/\$ref:([A-Za-z0-9_.-]+)/', function($match) use ($stepResults) {
837 return (string)$this->resolveRef('$ref:' . $match[1], $stepResults);
838 }, $value);
839 }
840
841 return $value;
842 }
843
844 private function paramsContainRef($value): bool {
845 if (is_array($value)) {
846 foreach ($value as $item) {
847 if ($this->paramsContainRef($item)) {
848 return true;
849 }
850 }
851 return false;
852 }
853 return is_string($value) && strpos($value, '$ref:') !== false;
854 }
855
856 private function validateRefTargets($value, array $completedStepIds): void {
857 if (is_array($value)) {
858 foreach ($value as $item) {
859 $this->validateRefTargets($item, $completedStepIds);
860 }
861 return;
862 }
863 if (!is_string($value) || strpos($value, '$ref:') !== 0) {
864 return;
865 }
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);
870 }
871 if (!in_array($parts[0], $completedStepIds, true)) {
872 throw new \InvalidArgumentException('Referenz auf noch nicht ausgefuehrten Step: ' . $value);
873 }
874 }
875
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;
881 }
882
883 private function syntheticStepResult(string $action): array {
884 if (strpos($action, 'media.create') === 0) {
885 return array('id' => 900001, 'media_id' => 900001);
886 }
887 if ($action === 'page.create' || $action === 'page.update') {
888 return array('id' => 900002, 'page_id' => 900002);
889 }
890 if ($action === 'media.assign') {
891 return array('usage_id' => 900003);
892 }
893 if (strpos($action, 'folder.create') === 0) {
894 return array('id' => 900004, 'folder_id' => 900004);
895 }
896 return array('id' => 900000);
897 }
898
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);
904 }
905 $stepId = $parts[0];
906 $field = $parts[1];
907 if (!isset($stepResults[$stepId]) || !is_array($stepResults[$stepId])) {
908 throw new \RuntimeException('Referenz nicht aufloesbar: ' . $value);
909 }
910 if (!array_key_exists($field, $stepResults[$stepId])) {
911 throw new \RuntimeException('Feld in Referenz fehlt: ' . $value);
912 }
913 return $stepResults[$stepId][$field];
914 }
915
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);
920 }
921 if ($ref === '' || strpos($ref, '..') !== false) {
922 throw new \InvalidArgumentException('Ungueltiger asset_ref.');
923 }
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);
929 }
930 if ($dryRun) {
931 $params['data_base64'] = base64_encode('dry-run');
932 } else {
933 $bytes = file_get_contents($realFile);
934 if ($bytes === false) {
935 throw new \RuntimeException('Asset konnte nicht gelesen werden: ' . $ref);
936 }
937 $params['data_base64'] = base64_encode($bytes);
938 }
939 unset($params['asset_ref']);
940 if (empty($params['file_name'])) {
941 $params['file_name'] = basename($realFile);
942 }
943 return $params;
944 }
945
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'] ?? '');
952
953 $statusLabels = array(
954 'preview_ready' => 'Bereit',
955 'running' => 'Laeuft',
956 'paused' => 'Pausiert',
957 'finished' => 'Fertig',
958 'error' => 'Fehler',
959 'canceled' => 'Abgebrochen',
960 );
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',
968 );
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',
976 );
977
978 $append = function(string $url, array $params): string {
979 foreach ($params as $key => $value) {
980 if ($value === null || $value === '') {
981 continue;
982 }
983 $url .= (strpos($url, '?') === false ? '?' : '&')
984 . rawurlencode((string)$key) . '=' . rawurlencode((string)$value);
985 }
986 return $url;
987 };
988
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);
996 }
997
998 $barTitle = trim((string)($state['title'] ?? ''));
999
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,
1018 'interval' => 900,
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));
1026 }
1027
1028 private function renderResultHtml(array $state): string {
1029 if (($state['status'] ?? '') !== 'finished') {
1030 return '';
1031 }
1032 $items = '';
1033 foreach (is_array($state['step_results'] ?? null) ? $state['step_results'] : array() as $id => $result) {
1034 if (!is_array($result)) {
1035 continue;
1036 }
1037 $bits = array($this->esc($id));
1038 foreach (array('page_id', 'media_id', 'folder_id', 'usage_id', 'id') as $key) {
1039 if (!empty($result[$key])) {
1040 $bits[] = $key . '=' . (int)$result[$key];
1041 }
1042 }
1043 $items .= '<li class="list-group-item py-1 px-2 small">' . implode(' ', $bits) . '</li>';
1044 }
1045 if ($items === '') {
1046 return '';
1047 }
1048 return '<ul class="list-group list-group-flush small mb-0">' . $items . '</ul>';
1049 }
1050
1051 public function handleExport(): void {
1052 if (!class_exists('ZipArchive')) {
1053 dbx()->json_response(array('ok' => 0, 'error' => 'ZipArchive nicht verfuegbar.'), true);
1054 }
1055
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) : '';
1062
1063 $manifest = array(
1064 'bundle_version' => self::BUNDLE_VERSION,
1065 'api_version' => '0.1',
1066 'title' => 'dbxKi CMS Briefing',
1067 'recipe' => 'page.create.v1',
1068 'lng' => $lng,
1069 'intent' => 'create',
1070 'area' => self::AREA,
1071 'auto_execute' => true,
1072 );
1073
1074 $exampleJob = array(
1075 'steps' => array(
1076 array(
1077 'id' => 'hero',
1078 'action' => 'media.create_base64',
1079 'params' => array(
1080 'file_name' => 'hero.jpg',
1081 'asset_ref' => 'hero.jpg',
1082 'title' => 'Hero',
1083 'alt' => 'Hero-Bild',
1084 ),
1085 ),
1086 array(
1087 'id' => 'page',
1088 'action' => 'page.create',
1089 'params' => array(
1090 'lng' => $lng,
1091 'folder_id' => 1,
1092 'title' => 'Neue KI-Seite',
1093 'template' => 'c-title-hero_header-body1-footer',
1094 'content' => '<p>Inhalt der Seite</p>',
1095 ),
1096 ),
1097 array(
1098 'id' => 'hero_assign',
1099 'action' => 'media.assign',
1100 'params' => array(
1101 'media_id' => '$ref:hero.media_id',
1102 'content_id' => '$ref:page.page_id',
1103 'slot' => 'hero',
1104 'lng' => $lng,
1105 ),
1106 ),
1107 ),
1108 );
1109
1110 $context = array(
1111 'lng' => $lng,
1112 'snapshot' => $snapshot,
1113 'note' => 'Ordner- und Seiten-IDs aus snapshot fuer folder_id / page.update verwenden.',
1114 );
1115
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";
1120
1121 $tmp = tempnam(sys_get_temp_dir(), 'dbxki');
1122 $zip = new \ZipArchive();
1123 if ($zip->open($tmp, \ZipArchive::OVERWRITE) !== true) {
1124 @unlink($tmp);
1125 throw new \RuntimeException('Export-ZIP konnte nicht erstellt werden.');
1126 }
1127
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);
1135 }
1136 $zip->close();
1137
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));
1142 readfile($tmp);
1143 @unlink($tmp);
1144 exit;
1145 }
1146
1147 public function handleDescribeJson(): void {
1148 dbx()->json_response($this->describeBundle(), true);
1149 }
1150}
1151
1152?>
$index['name']
Definition .dd.php:162
$field
Definition config.dd.php:4
dbx_os_path_file($path_file)
Definition index.php:164
exit
Definition index.php:532
if( $syncRequest)
Definition index.php:520
DBX schema administration.
if(! $db->connect_db_server($server)) $result
$cms
Definition run_job.php:206
try
Definition run_job.php:204
foreach( $topics as $file=> $html)