dbxapp 4.1.3
CMS, Shop, Workflows und modulare Geschäftsanwendungen
Loading...
Searching...
No Matches
dbxKiBriefingService.class.php
Go to the documentation of this file.
1<?php
2namespace dbx\dbxKi;
3
4use dbx\dbxContent\dbxContentLng;
5use dbx\dbxContent\dbxContentLngSync;
6use dbx\dbxContent\dbxContentMediaUsageScope;
7
8require_once dirname(__DIR__, 2) . '/dbxContent/include/dbxContent_bootstrap_sync.php';
9require_once __DIR__ . '/dbxKiWritingStyles.class.php';
10
12
13 private const BRIEFING_VERSION = '1.3';
14 private const HERO_MAX_WIDTH = 1280;
15 private const HERO_MAX_HEIGHT = 400;
16 private const HERO_OPTIMAL_WIDTH = 1280;
17 private const HERO_OPTIMAL_HEIGHT = 300;
18 private const HERO_DEFAULT_HEIGHT = '300px';
19 private const HERO_DEFAULT_IMAGE_WIDTH = 1280;
20 private const HERO_DEFAULT_IMAGE_HEIGHT = 300;
21 private const HERO_TEXT_MAX_LINES = 3;
22 private const CONTENT_TEMPLATE_DEFAULT = 'c-title-hero_header-body1-footer';
23
24 private function contentTemplateDir(): string {
25 $dir = dbx()->get_system_var('dbx_dir', '') . '/modules/dbxContent/tpl/htm/';
26 if (!is_dir($dir)) {
27 $dir = dirname(__DIR__, 2) . '/dbxContent/tpl/htm/';
28 }
29 return rtrim($dir, '/\\') . DIRECTORY_SEPARATOR;
30 }
31
32 private function listContentTemplates(): array {
33 $files = glob($this->contentTemplateDir() . 'c-*.htm');
34 $out = array();
35 if (is_array($files)) {
36 sort($files);
37 foreach ($files as $file) {
38 $out[] = basename($file, '.htm');
39 }
40 }
41 return $out ?: array('c-content');
42 }
43
44 private function sanitizeContentTemplate(string $template, bool $heroEnabled): string {
45 $template = trim($template);
46 $allowed = $this->listContentTemplates();
47 if ($template === '' || $template === 'parent') {
48 return $heroEnabled ? self::CONTENT_TEMPLATE_DEFAULT : 'parent';
49 }
50 if (!in_array($template, $allowed, true)) {
51 return $heroEnabled ? self::CONTENT_TEMPLATE_DEFAULT : 'parent';
52 }
53 if (!$heroEnabled && strpos($template, 'hero') !== false) {
54 return 'parent';
55 }
56 return $template;
57 }
58
59 private function analyzeTemplateSlots(string $template): array {
60 if ($template === '' || $template === 'parent') {
61 return array(
62 'hero_text' => false,
63 'header' => false,
64 'footer' => false,
65 'cols' => 1,
66 'gallery' => false,
67 );
68 }
69 $path = $this->contentTemplateDir() . $template . '.htm';
70 if (!is_file($path)) {
71 return array('hero_text' => true, 'header' => true, 'footer' => true, 'cols' => 1, 'gallery' => false);
72 }
73 $html = (string) file_get_contents($path);
74 $slots = array(
75 'hero_text' => strpos($html, '{cms:hero_text}') !== false,
76 'header' => strpos($html, '{cms:header}') !== false,
77 'footer' => strpos($html, '{cms:footer}') !== false,
78 'gallery' => strpos($html, '{cms:gallery}') !== false,
79 'cols' => 1,
80 );
81 if (strpos($html, '{cms:col3}') !== false) {
82 $slots['cols'] = 3;
83 } elseif (strpos($html, '{cms:col2}') !== false) {
84 $slots['cols'] = 2;
85 }
86 return $slots;
87 }
88
89 private function buildContentTemplateOptions(string $selected, bool $heroEnabled): string {
90 $selected = $this->sanitizeContentTemplate($selected, $heroEnabled);
91 $html = '';
92 if (!$heroEnabled) {
93 $html .= '<option value="parent"' . ($selected === 'parent' ? ' selected' : '') . '>parent — vom Ordner</option>';
94 }
95 foreach ($this->listContentTemplates() as $name) {
96 if (!$heroEnabled && strpos($name, 'hero') !== false) {
97 continue;
98 }
99 if ($heroEnabled && strpos($name, 'hero') === false && $name !== 'c-content') {
100 continue;
101 }
102 $sel = ($name === $selected) ? ' selected' : '';
103 $html .= '<option value="' . $this->esc($name) . '"' . $sel . '>' . $this->esc($name) . '</option>';
104 }
105 return $html;
106 }
107
108 private function contentTemplateForCreate(bool $heroEnabled, string $selected = ''): string {
109 return $this->sanitizeContentTemplate($selected, $heroEnabled);
110 }
111
112 private function contentMarkerHr(string $name): string {
113 $labels = array(
114 'hero' => 'Hero-Text',
115 'header' => 'Header',
116 'footer' => 'Footer',
117 );
118 $name = strtolower(trim($name));
119 $label = $labels[$name] ?? $name;
120 return '<hr class="dbx-cms-marker dbx-cms-marker-' . $name
121 . '" contenteditable="false" data-dbx-marker="dbx:' . $name
122 . '" data-label="' . $label . '">';
123 }
124
125 private function contentMarkersMeta(array $slots): array {
126 $markers = array();
127 if (!empty($slots['hero_text'])) {
128 $markers['hero'] = $this->contentMarkerHr('hero');
129 }
130 if (!empty($slots['header'])) {
131 $markers['header'] = $this->contentMarkerHr('header');
132 }
133 if (!empty($slots['footer'])) {
134 $markers['footer'] = $this->contentMarkerHr('footer');
135 }
136 return $markers;
137 }
138
139 private function contentExampleHtml(array $slots, string $heroTextHint = ''): string {
140 $parts = array();
141 if (!empty($slots['hero_text'])) {
142 $lead = $heroTextHint !== '' ? $heroTextHint : 'Kurzer Hero-Text';
143 $parts[] = '<p class="lead">' . $lead . '</p>';
144 $parts[] = $this->contentMarkerHr('hero');
145 }
146 if (!empty($slots['header'])) {
147 $parts[] = $this->contentMarkerHr('header');
148 }
149 $parts[] = '<h2>Ueberschrift</h2><p>Haupttext...</p>';
150 if (!empty($slots['footer'])) {
151 $parts[] = $this->contentMarkerHr('footer');
152 $parts[] = '<p><small>Optionale Fusszeile</small></p>';
153 }
154 return implode('', $parts);
155 }
156
157 private function contentMarkersGuide(string $template, bool $withHeroImage, string $heroTextBrief = ''): string {
158 $slots = $this->analyzeTemplateSlots($template);
159 $markers = $this->contentMarkersMeta($slots);
160 $example = $this->contentExampleHtml($slots, $heroTextBrief);
161
162 $lines = array(
163 '## Content-Template und Bereichs-Marker',
164 '',
165 'Content-Template: `' . $template . '`',
166 '',
167 'Der Inhalt in `content` wird mit **`<hr>`-Markern** getrennt (Reihenfolge von oben nach unten):',
168 '',
169 );
170
171 if (!empty($slots['hero_text'])) {
172 $lines[] = '1. Text **vor** Hero-Marker → Slot `{cms:hero_text}` (Text im Hero-Bereich neben/unter dem Bild)';
173 $lines[] = ' Standard: maximal ' . self::HERO_TEXT_MAX_LINES . ' Zeilen Hero-Text, wenn nicht anders angegeben.';
174 $lines[] = '2. `<hr data-dbx-marker="dbx:hero">`';
175 if ($heroTextBrief !== '') {
176 $lines[] = ' Hero-Text laut Auftrag: *' . $heroTextBrief . '*';
177 }
178 }
179 if (!empty($slots['header'])) {
180 $lines[] = '- Text zwischen Hero- und Header-Marker → `{cms:header}` (eigener Block zwischen Hero und Body)';
181 $lines[] = '- `<hr data-dbx-marker="dbx:header">`';
182 }
183 $lines[] = '- Text bis Footer-Marker → Body (`{cms:col1}`' . ((int)($slots['cols'] ?? 1) > 1 ? ' / Spalten' : '') . ')';
184 if (!empty($slots['footer'])) {
185 $lines[] = '- `<hr data-dbx-marker="dbx:footer">`';
186 $lines[] = '- Text danach → `{cms:footer}`';
187 }
188 $lines[] = '';
189 $lines[] = 'Fehlende Marker: der jeweilige Bereich entfaellt, der Text gehoert zum Body.';
190 $lines[] = '**Spalten-Marker (`col2`, `col3a`, `col3b`) nicht setzen** — werden manuell im CMS gesetzt.';
191 $lines[] = '';
192 foreach ($markers as $name => $hr) {
193 $lines[] = '**' . ucfirst($name) . '-Marker:**';
194 $lines[] = '```html';
195 $lines[] = $hr;
196 $lines[] = '```';
197 $lines[] = '';
198 }
199 $lines[] = '**Beispiel `content` fuer dieses Template:**';
200 $lines[] = '```html';
201 $lines[] = $example;
202 $lines[] = '```';
203 if ($withHeroImage) {
204 $lines[] = '';
205 $lines[] = 'Hero-**Bild** kommt ueber dbxKi-Medienschritte — nicht ins HTML. Neue Hero-Bilder liegen verbindlich in `img/hero`.';
206 }
207 return implode("\n", $lines);
208 }
209
210 private function contentMarkersGuideShort(string $template): string {
211 $slots = $this->analyzeTemplateSlots($template);
212 $bits = array('Template `' . $template . '`');
213 if (!empty($slots['hero_text'])) {
214 $bits[] = 'Hero-/Header-/Footer-Marker per `<hr>`';
215 } elseif (!empty($slots['header']) || !empty($slots['footer'])) {
216 $bits[] = 'Header-/Footer-Marker per `<hr>`';
217 }
218 $bits[] = 'keine Spalten-Marker';
219 $bits[] = 'Bootstrap-5-Content-Komponenten nur wenn im Auftrag ausgewaehlt, nur Bootstrap-Klassen, kein eigenes CSS/JS';
220 return implode('; ', $bits) . '.';
221 }
222
223 private function allowedBootstrapComponents(): array {
224 return array(
225 'alert' => array(
226 'label' => 'Hinweis',
227 'classes' => 'alert alert-info / alert-warning / alert-success',
228 'use' => 'Kurze Hinweis-, Info- oder Erfolgsbox.',
229 ),
230 'card' => array(
231 'label' => 'Cards',
232 'classes' => 'card, card-body, row, row-cols-*, g-*',
233 'use' => 'Teaser, Leistungsboxen oder Paket-/Feature-Kacheln.',
234 ),
235 'list_group' => array(
236 'label' => 'Listenbox',
237 'classes' => 'list-group, list-group-item',
238 'use' => 'Kompakte Nutzen-, Schritt- oder Funktionslisten.',
239 ),
240 'badges' => array(
241 'label' => 'Badges',
242 'classes' => 'badge text-bg-*',
243 'use' => 'Status, Kategorien, kleine Hervorhebungen.',
244 ),
245 'buttons' => array(
246 'label' => 'Buttons',
247 'classes' => 'btn btn-primary / btn-outline-primary',
248 'use' => 'CTA-Links ohne eigenes JavaScript.',
249 ),
250 'table' => array(
251 'label' => 'Tabelle',
252 'classes' => 'table table-striped table-hover',
253 'use' => 'Vergleichs- oder Preis-/Datenuebersichten.',
254 ),
255 'accordion' => array(
256 'label' => 'Akkordeon',
257 'classes' => 'accordion, accordion-item, accordion-button',
258 'use' => 'FAQ oder aufklappbare Detailbereiche.',
259 ),
260 'tabs' => array(
261 'label' => 'Tabs',
262 'classes' => 'nav nav-tabs, tab-content, tab-pane',
263 'use' => 'Alternative Sichten auf denselben Inhalt.',
264 ),
265 );
266 }
267
268 private function selectedBootstrapComponentsFromRequest(): array {
269 $raw = dbx()->get_request_var('bootstrap_components', array(), '*');
270 if (!is_array($raw)) {
271 $raw = $raw === '' ? array() : explode(',', (string) $raw);
272 }
273 $allowed = $this->allowedBootstrapComponents();
274 $out = array();
275 foreach ($raw as $key) {
276 $key = strtolower(trim((string) $key));
277 if (isset($allowed[$key]) && !in_array($key, $out, true)) {
278 $out[] = $key;
279 }
280 }
281 return $out;
282 }
283
284 private function buildBootstrapComponentChoices(array $selected): string {
285 $html = '';
286 foreach ($this->allowedBootstrapComponents() as $key => $meta) {
287 $checked = in_array($key, $selected, true) ? ' checked' : '';
288 $html .= '<label><input type="checkbox" name="bootstrap_components[]" value="' . $this->esc($key) . '"' . $checked . '>'
289 . '<span><strong>' . $this->esc($meta['label'] ?? $key) . '</strong><small>' . $this->esc($meta['use'] ?? '') . '</small></span></label>';
290 }
291 return $html;
292 }
293
294 private function bootstrapComponentsMeta(array $selected): array {
295 $allowed = $this->allowedBootstrapComponents();
296 $out = array();
297 foreach ($selected as $key) {
298 if (isset($allowed[$key])) {
299 $out[$key] = $allowed[$key];
300 }
301 }
302 return $out;
303 }
304
305 private function bootstrapComponentsGuide(array $selected): string {
306 $meta = $this->bootstrapComponentsMeta($selected);
307 if (!$meta) {
308 return "Keine Bootstrap-Komponenten im Content verwenden. Erlaubt sind nur normales Jodit-HTML wie h2, h3, p, ul/ol, Links und einfache Textstruktur.";
309 }
310 $lines = array(
311 'Die KI darf im Content nur diese ausgewaehlten Bootstrap-5-Komponenten verwenden:',
312 '',
313 );
314 foreach ($meta as $key => $row) {
315 $lines[] = '- `' . $key . '` (' . ($row['label'] ?? $key) . '): ' . ($row['use'] ?? '') . ' Klassen: `' . ($row['classes'] ?? '') . '`.';
316 }
317 $lines[] = '';
318 $lines[] = 'Nicht ausgewaehlte Bootstrap-Komponenten sind verboten. Kein eigenes CSS, kein eigenes JavaScript, keine Inline-Styles. HTML muss in Jodit bearbeitbar bleiben.';
319 return implode("\n", $lines);
320 }
321
322 private function heroImageSpecText(): string {
323 return 'JPG, Standard ' . self::HERO_DEFAULT_IMAGE_WIDTH . '×' . self::HERO_DEFAULT_IMAGE_HEIGHT . ' px'
324 . ' (nur bei ausdruecklicher Vorgabe abweichend, maximal ' . self::HERO_MAX_WIDTH . '×' . self::HERO_MAX_HEIGHT . ' px),'
325 . ' CMS-Hero-Hoehe Standard ' . self::HERO_DEFAULT_HEIGHT;
326 }
327
328 private function heroImageBriefingMeta(): array {
329 return array(
330 'format' => 'jpg',
331 'max_width' => self::HERO_MAX_WIDTH,
332 'max_height' => self::HERO_MAX_HEIGHT,
333 'default_width' => self::HERO_DEFAULT_IMAGE_WIDTH,
334 'default_image_height' => self::HERO_DEFAULT_IMAGE_HEIGHT,
335 'default_dimensions' => self::HERO_DEFAULT_IMAGE_WIDTH . 'x' . self::HERO_DEFAULT_IMAGE_HEIGHT,
336 'recommended' => self::HERO_OPTIMAL_WIDTH . 'x' . self::HERO_OPTIMAL_HEIGHT,
337 'default_height' => self::HERO_DEFAULT_HEIGHT,
338 );
339 }
340
341 private function ensureContentBootstrap(): void {
342 if (!class_exists(dbxContentLng::class)) {
343 require_once dirname(__DIR__, 2) . '/dbxContent/include/dbxContent_bootstrap_sync.php';
344 }
345 }
346
347 private function cms(): dbxKiCmsService {
348 return dbx()->get_include_obj('dbxKiCmsService', 'dbxKi');
349 }
350
351 private function help(): dbxKiHelp {
352 return dbx()->get_include_obj('dbxKiHelp', 'dbxKi');
353 }
354
355 private function bundle(): dbxKiBundleService {
356 return dbx()->get_include_obj('dbxKiBundleService', 'dbxKi');
357 }
358
359 private function tpl() {
360 return dbx()->get_system_obj('dbxTPL');
361 }
362
363 private function esc($value): string {
364 return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
365 }
366
367 private function moduleUrl(string $run1, array $params = array()): string {
368 $url = '?dbx_modul=dbxKi&dbx_run1=' . rawurlencode($run1);
369 foreach ($params as $key => $value) {
370 if ($value === null || $value === '') {
371 continue;
372 }
373 $url .= '&' . rawurlencode((string) $key) . '=' . rawurlencode((string) $value);
374 }
375 return $url;
376 }
377
378 private function contentAdminUrl(string $run1, array $params = array()): string {
379 $url = '?dbx_modul=dbxContent_admin&dbx_run1=' . rawurlencode($run1);
380 foreach ($params as $key => $value) {
381 if ($value === null || $value === '') {
382 continue;
383 }
384 $url .= '&' . rawurlencode((string) $key) . '=' . rawurlencode((string) $value);
385 }
386 return $url;
387 }
388
389 private function withContentLng(string $lng, callable $fn) {
390 $lng = strtolower(trim($lng));
391 $prev = (string) dbx()->get_system_var('dbx_lng', '');
392 if ($lng !== '') {
393 dbx()->set_system_var('dbx_lng', $lng);
394 }
395 try {
396 return $fn();
397 } finally {
398 dbx()->set_system_var('dbx_lng', $prev);
399 }
400 }
401
402 private function withModuleBar(array $data, string $screen, string $actionsHtml = ''): array {
403 return array_merge($data, $this->help()->moduleBarTemplateData($screen, $actionsHtml));
404 }
405
424 private function briefingForm(string $fid, string $template, string $action, array $replacements = array()) {
425 $form = dbx()->get_system_obj('dbxForm');
426 $form->init($fid, $template);
427 $form->_action = $action;
428 $form->_msg_info = '';
429
430 foreach ($replacements as $key => $value) {
431 $form->add_rep((string) $key, $value);
432 }
433
434 return $form;
435 }
436
440 private function briefingFormId(string $recipe): string {
441 return 'ki-briefing-' . str_replace('_', '-', strtolower(trim($recipe)));
442 }
443
444 private function barBackHub(): string {
445 return '<a class="btn btn-outline-secondary btn-sm" href="' . $this->esc($this->moduleUrl('briefing')) . '" title="Zurueck zum Hub"><i class="bi bi-arrow-left"></i></a>';
446 }
447
448 private function briefingWorkflowData(string $returnRun1): array {
449 return array(
450 'workflow_hint' => $this->tpl()->get_tpl('dbxKi|ki-briefing-workflow-hint', array()),
451 'import_panel' => $this->bundle()->renderImportPanel($returnRun1),
452 );
453 }
454
455 public function recipes(): array {
456 return array(
457 'page_create' => array(
458 'title' => 'Neue Content-Seite anlegen',
459 'icon' => 'bi-file-earmark-plus',
460 'subtitle' => 'Ordner, Titel, Text, Hero — KI-Auftrag erzeugen',
461 'recipe' => 'page.create.v1',
462 'form_url' => $this->moduleUrl('briefing_page_create'),
463 'export_action' => 'briefing_export',
464 ),
465 'page_update' => array(
466 'title' => 'Bestehende Seite aendern',
467 'icon' => 'bi-pencil-square',
468 'subtitle' => 'Inhalt aktualisieren, Stil anpassen, Hero tauschen',
469 'recipe' => 'page.update.v1',
470 'form_url' => $this->moduleUrl('briefing_page_update'),
471 'export_action' => 'briefing_export',
472 ),
473 'page_translate' => array(
474 'title' => 'Seite uebersetzen',
475 'icon' => 'bi-translate',
476 'subtitle' => 'Quellseite in andere Sprache uebertragen',
477 'recipe' => 'translation.v1',
478 'form_url' => $this->moduleUrl('briefing_page_translate'),
479 'export_action' => 'briefing_export',
480 ),
481 'module_update' => array(
482 'title' => 'Bestehendes Modul bearbeiten',
483 'icon' => 'bi-box-seam',
484 'subtitle' => 'Komplettes Modul als Kontext, harte dbxapp-Regeln, Antwort-ZIP',
485 'recipe' => 'module.update.v1',
486 'form_url' => $this->moduleUrl('briefing_module'),
487 'export_action' => 'briefing_module_export',
488 ),
489 'design' => array(
490 'title' => 'Design entwickeln',
491 'icon' => 'bi-palette',
492 'subtitle' => 'Aufteilung, Menue, Footer und Branding mit komplettem Designkontext',
493 'recipe' => 'design.update.v1',
494 'form_url' => $this->moduleUrl('briefing_design'),
495 'export_action' => 'briefing_design_export',
496 ),
497 );
498 }
499
500 public function exportBackUrl(string $recipe): string {
501 switch ($recipe) {
502 case 'page_update':
503 return $this->moduleUrl('briefing_page_update');
504 case 'page_translate':
505 return $this->moduleUrl('briefing_page_translate');
506 default:
507 return $this->moduleUrl('briefing_page_create');
508 }
509 }
510
511 public function renderHub(): string {
512 $this->ensureContentBootstrap();
513 $cards = '';
514 foreach ($this->recipes() as $key => $meta) {
515 $cards .= '<div class="col-md-6">'
516 . '<div class="card h-100">'
517 . '<div class="card-body d-flex flex-column">'
518 . '<h2 class="h5"><i class="bi ' . $this->esc($meta['icon'] ?? 'bi-grid') . ' me-2"></i>'
519 . $this->esc($meta['title'] ?? $key) . '</h2>'
520 . '<p class="small text-muted flex-grow-1">' . $this->esc($meta['subtitle'] ?? '') . '</p>'
521 . '<a class="btn btn-primary" href="' . $this->esc($meta['form_url'] ?? '#') . '">Formular oeffnen</a>'
522 . '</div></div></div>';
523 }
524
525 return $this->tpl()->get_tpl('dbxKi|ki-briefing-hub', $this->withModuleBar(array_merge(array(
526 'recipe_cards' => $cards,
527 'import_url' => $this->esc($this->moduleUrl('bundle')),
528 'styles_url' => $this->esc($this->moduleUrl('briefing_styles')),
529 'bundle_version' => $this->esc(self::BRIEFING_VERSION),
530 ), $this->briefingWorkflowData('briefing')), 'briefing',
531 '<a class="btn btn-outline-secondary btn-sm" href="' . $this->esc($this->moduleUrl('briefing_styles')) . '" title="Schreibstile"><i class="bi bi-type"></i></a>'));
532 }
533
534 public function renderPageCreateForm(): string {
535 $this->ensureContentBootstrap();
536 $lng = strtolower(trim((string) dbx()->get_request_var('lng', '', '*')));
537 if ($lng === '') {
538 $lng = strtolower(trim((string) dbx()->get_request_var('dbx_lng', dbxContentLng::current(), '*')));
539 }
540 $lngOptions = $this->buildLngOptions($lng);
541 $styleOptions = $this->buildStyleOptions((string) dbx()->get_request_var('writing_style', 'sachlich', '*'));
542 $folderId = (int) dbx()->get_request_var('folder_id', 0, 'int');
543 $sorterAfterPageId = (int) dbx()->get_request_var('sorter_after_page_id', 0, 'int');
544 if ($sorterAfterPageId > 0) {
545 try {
546 $afterPage = $this->loadPage($lng, $sorterAfterPageId);
547 $folderId = (int) ($afterPage['folder'] ?? $folderId);
548 } catch (\Throwable $e) {
549 $sorterAfterPageId = 0;
550 }
551 }
552
553 $heroEnabled = dbx()->get_request_var('hero_enabled', '1', '*') !== '0';
554 $selectedTemplate = (string) dbx()->get_request_var('content_template', self::CONTENT_TEMPLATE_DEFAULT, '*');
555 $selectedBootstrapComponents = $this->selectedBootstrapComponentsFromRequest();
556 $placementTitle = $folderId > 0 ? $this->createPlacementTitle($lng, $folderId, $sorterAfterPageId) : 'Zielposition waehlen';
557
558 $data = $this->withModuleBar(array_merge(array(
559 'hub_url' => $this->esc($this->moduleUrl('briefing')),
560 'export_url' => $this->esc($this->moduleUrl('briefing_export')),
561 'tree_url' => $this->esc($this->contentAdminUrl('cms_tree')),
562 'lng' => $this->esc($lng),
563 'lng_options' => $lngOptions,
564 'selected_folder_id' => (string) $folderId,
565 'selected_sorter_after_page_id' => (string) $sorterAfterPageId,
566 'selected_placement_title' => $this->esc($placementTitle),
567 'current_context' => $this->renderCreatePlacementHtml($lng, $folderId, $sorterAfterPageId),
568 'style_options' => $styleOptions,
569 'template_options' => $this->buildContentTemplateOptions($selectedTemplate, $heroEnabled),
570 'bootstrap_component_choices' => $this->buildBootstrapComponentChoices($selectedBootstrapComponents),
571 'title' => $this->esc((string) dbx()->get_request_var('title', '', '*')),
572 'permalink' => $this->esc((string) dbx()->get_request_var('permalink', '', '*')),
573 'description' => $this->esc((string) dbx()->get_request_var('description', '', '*')),
574 'keywords' => $this->esc((string) dbx()->get_request_var('keywords', '', '*')),
575 'content_brief' => $this->esc((string) dbx()->get_request_var('content_brief', '', '*')),
576 'hero_brief' => $this->esc((string) dbx()->get_request_var('hero_brief', '', '*')),
577 'hero_text_brief' => $this->esc((string) dbx()->get_request_var('hero_text_brief', '', '*')),
578 'custom_notes' => $this->esc((string) dbx()->get_request_var('custom_notes', '', '*')),
579 'hero_checked' => $heroEnabled ? 'checked' : '',
580 'activ_checked' => dbx()->get_request_var('activ', '1', '*') !== '0' ? 'checked' : '',
581 ), $this->briefingWorkflowData('briefing_page_create')), 'briefing_page_create', $this->barBackHub());
582
583 return $this->briefingForm(
584 $this->briefingFormId('page_create'),
585 'ki-briefing-page-create',
586 $this->moduleUrl('briefing_export'),
587 $data
588 )->run();
589 }
590
591 public function renderPageUpdateForm(): string {
592 $this->ensureContentBootstrap();
593 $lng = dbxContentLng::current();
594 $pageId = (int) dbx()->get_request_var('page_id', 0, 'int');
595 $lngOptions = $this->buildLngOptions($lng);
596 $styleOptions = $this->buildStyleOptions((string) dbx()->get_request_var('writing_style', 'sachlich', '*'));
597 $selectedBootstrapComponents = $this->selectedBootstrapComponentsFromRequest();
598 $page = array();
599 $pageTitle = '';
600 $rendered = '<div class="dbx-cms-empty">Seite links im Content Tree waehlen.</div>';
601 $contextHtml = '<div class="dbx-ki-context-empty">Noch keine Seite gewaehlt.</div>';
602 if ($pageId > 0) {
603 try {
604 $page = $this->loadPage($lng, $pageId);
605 $pageTitle = (string) ($page['title'] ?? ('Seite #' . $pageId));
606 $rendered = '[modul=dbxContent]dbx_run1=show&cid=' . $pageId . '[/modul]';
607 $contextHtml = $this->renderPageContextHtml($lng, $page);
608 } catch (\Throwable $e) {
609 $pageId = 0;
610 }
611 }
612
613 $data = $this->withModuleBar(array_merge(array(
614 'hub_url' => $this->esc($this->moduleUrl('briefing')),
615 'export_url' => $this->esc($this->moduleUrl('briefing_export')),
616 'tree_url' => $this->esc($this->contentAdminUrl('cms_tree')),
617 'preview_url' => $this->esc($this->moduleUrl('briefing_page_update_preview')),
618 'lng' => $this->esc($lng),
619 'lng_options' => $lngOptions,
620 'selected_page_id' => (string) $pageId,
621 'selected_page_title' => $this->esc($pageTitle !== '' ? $pageTitle : 'Keine Seite gewaehlt'),
622 'current_rendered' => $rendered,
623 'current_context' => $contextHtml,
624 'style_options' => $styleOptions,
625 'bootstrap_component_choices' => $this->buildBootstrapComponentChoices($selectedBootstrapComponents),
626 'change_brief' => $this->esc((string) dbx()->get_request_var('change_brief', '', '*')),
627 'custom_notes' => $this->esc((string) dbx()->get_request_var('custom_notes', '', '*')),
628 'field_content_checked' => dbx()->get_request_var('change_content', '1', '*') !== '0' ? 'checked' : '',
629 'field_title_checked' => dbx()->get_request_var('change_title', '', '*') === '1' ? 'checked' : '',
630 'field_description_checked' => dbx()->get_request_var('change_description', '', '*') === '1' ? 'checked' : '',
631 'field_hero_checked' => dbx()->get_request_var('change_hero', '', '*') === '1' ? 'checked' : '',
632 'hero_brief' => $this->esc((string) dbx()->get_request_var('hero_brief', '', '*')),
633 'embedded_policy_preserve_checked' => dbx()->get_request_var('embedded_policy', 'preserve', '*') === 'preserve' ? 'checked' : '',
634 'embedded_policy_reorder_checked' => dbx()->get_request_var('embedded_policy', 'preserve', '*') === 'reorder' ? 'checked' : '',
635 'embedded_policy_remove_checked' => dbx()->get_request_var('embedded_policy', 'preserve', '*') === 'remove' ? 'checked' : '',
636 'embedded_change_notes' => $this->esc((string) dbx()->get_request_var('embedded_change_notes', '', '*')),
637 ), $this->briefingWorkflowData('briefing_page_update')), 'briefing_page_update', $this->barBackHub());
638
639 return $this->briefingForm(
640 $this->briefingFormId('page_update'),
641 'ki-briefing-page-update',
642 $this->moduleUrl('briefing_export'),
643 $data
644 )->run();
645 }
646
647 public function handlePageUpdatePreviewJson(): void {
648 $this->ensureContentBootstrap();
649
650 $lng = strtolower(trim((string) dbx()->get_request_var('dbx_lng', dbxContentLng::current(), '*')));
651 $pageId = (int) dbx()->get_request_var('id', 0, 'int');
652 if ($pageId <= 0) {
653 dbx()->json_response(array('ok' => 0, 'error' => 'Keine Seite gewaehlt.'), true);
654 }
655
656 try {
657 $page = $this->loadPage($lng, $pageId);
658 $renderer = dbx()->get_include_obj('dbxContentRenderer', 'dbxContent');
659 $html = $this->withContentLng($lng, function () use ($renderer, $pageId) {
660 return $renderer->render($pageId);
661 });
662 dbx()->json_response(array(
663 'ok' => 1,
664 'id' => $pageId,
665 'title' => (string) ($page['title'] ?? ''),
666 'html' => $html,
667 'context_html' => $this->renderPageContextHtml($lng, $page),
668 ), true);
669 } catch (\Throwable $e) {
670 dbx()->json_response(array('ok' => 0, 'error' => $e->getMessage()), true);
671 }
672 }
673
674 public function renderPageTranslateForm(): string {
675 $this->ensureContentBootstrap();
676 $sourceLng = strtolower(trim((string) dbx()->get_request_var('source_lng', '', '*')));
677 if ($sourceLng === '') {
678 $sourceLng = strtolower(trim((string) dbx()->get_request_var('dbx_lng', dbxContentLng::current(), '*')));
679 }
680 $sourceId = (int) dbx()->get_request_var('source_id', 0, 'int');
681 $targetLngs = $this->selectedTargetLngsFromRequest($sourceLng, true);
682 $currentRendered = '<div class="dbx-cms-empty">Seite links im Content Tree waehlen.</div>';
683 $currentContext = '<p class="dbx-ki-context-empty">Noch keine Seite gewaehlt.</p>';
684 $selectedTitle = 'Keine Seite gewaehlt';
685 if ($sourceId > 0) {
686 try {
687 $source = $this->loadPage($sourceLng, $sourceId);
688 $selectedTitle = trim((string) ($source['title'] ?? '')) !== '' ? (string) $source['title'] : ('Seite #' . $sourceId);
689 $renderer = dbx()->get_include_obj('dbxContentRenderer', 'dbxContent');
690 $currentRendered = $this->withContentLng($sourceLng, function () use ($renderer, $sourceId) {
691 return $renderer->render($sourceId);
692 });
693 $currentContext = $this->renderPageContextHtml($sourceLng, $source);
694 } catch (\Throwable $e) {
695 $currentRendered = '<div class="dbx-cms-empty">Quellseite konnte nicht geladen werden.</div>';
696 }
697 }
698
699 $data = $this->withModuleBar(array_merge(array(
700 'hub_url' => $this->esc($this->moduleUrl('briefing')),
701 'export_url' => $this->esc($this->moduleUrl('briefing_export')),
702 'tree_url' => $this->esc($this->contentAdminUrl('cms_tree')),
703 'preview_url' => $this->esc($this->moduleUrl('briefing_page_update_preview')),
704 'source_lng' => $this->esc($sourceLng),
705 'source_lng_options' => $this->buildLngOptions($sourceLng),
706 'target_lng_checkboxes' => $this->buildTargetLngCheckboxes($sourceLng, $targetLngs),
707 'selected_page_id' => (string) $sourceId,
708 'selected_page_title' => $this->esc($selectedTitle),
709 'style_options' => $this->buildStyleOptions((string) dbx()->get_request_var('writing_style', 'sachlich', '*')),
710 'translation_notes' => $this->esc((string) dbx()->get_request_var('translation_notes', '', '*')),
711 'current_rendered' => $currentRendered,
712 'current_context' => $currentContext,
713 'copy_media_checked' => dbx()->get_request_var('copy_media', '1', '*') !== '0' ? 'checked' : '',
714 ), $this->briefingWorkflowData('briefing_page_translate')), 'briefing_page_translate', $this->barBackHub());
715
716 return $this->briefingForm(
717 $this->briefingFormId('page_translate'),
718 'ki-briefing-page-translation',
719 $this->moduleUrl('briefing_export'),
720 $data
721 )->run();
722 }
723
724 public function renderStylesAdmin(): string {
725 $rows = '';
726 foreach (dbxKiWritingStyles::all() as $key => $meta) {
727 $rows .= '<tr>'
728 . '<td><input class="form-control form-control-sm" name="style_key[]" value="' . $this->esc($key) . '"></td>'
729 . '<td><input class="form-control form-control-sm" name="style_label[]" value="' . $this->esc($meta['label'] ?? '') . '"></td>'
730 . '<td><textarea class="form-control form-control-sm" name="style_prompt[]" rows="2">' . $this->esc($meta['prompt'] ?? '') . '</textarea></td>'
731 . '</tr>';
732 }
733 $rows .= '<tr>'
734 . '<td><input class="form-control form-control-sm" name="style_key[]" placeholder="neuer_stil"></td>'
735 . '<td><input class="form-control form-control-sm" name="style_label[]" placeholder="Bezeichnung"></td>'
736 . '<td><textarea class="form-control form-control-sm" name="style_prompt[]" rows="2" placeholder="KI-Anweisung"></textarea></td>'
737 . '</tr>';
738
739 $data = $this->withModuleBar(array(
740 'hub_url' => $this->esc($this->moduleUrl('briefing')),
741 'save_url' => $this->esc($this->moduleUrl('briefing_styles_save')),
742 'style_rows' => $rows,
743 ), 'briefing_styles', $this->barBackHub());
744
745 return $this->briefingForm(
746 'ki-briefing-styles',
747 'ki-briefing-styles',
748 $this->moduleUrl('briefing_styles_save'),
749 $data
750 )->run();
751 }
752
753 public function handleStylesSave(): string {
754 try {
755 $form = $this->briefingForm(
756 'ki-briefing-styles',
757 'ki-briefing-styles',
758 $this->moduleUrl('briefing_styles_save')
759 );
760 if (!$form->submit()) {
761 throw new \RuntimeException('Ungueltiger oder abgelaufener Formular-Token.');
762 }
763 if (dbx()->get_request_var('styles_action', 'save', 'parameter') === 'reset') {
765 dbx()->sys_msg('info', 'dbxKi', 'styles', 'Schreibstile', 'Standard wiederhergestellt');
766 return $this->renderStylesAdmin();
767 }
769 (array) dbx()->get_request_var('style_key', array(), '*'),
770 (array) dbx()->get_request_var('style_label', array(), '*'),
771 (array) dbx()->get_request_var('style_prompt', array(), '*')
772 );
774 dbx()->sys_msg('info', 'dbxKi', 'styles', 'Schreibstile gespeichert', count($styles) . ' Stile');
775 } catch (\Throwable $e) {
776 dbx()->sys_msg('error', 'dbxKi', 'styles', 'Speichern fehlgeschlagen', $e->getMessage());
777 }
778 return $this->renderStylesAdmin();
779 }
780
781 public function handleStylesReset(): string {
782 // Rueckwaertskompatibler GET-Endpunkt ohne schreibende Wirkung. Das
783 // Zuruecksetzen erfolgt nur noch als geschuetzter dbxForm-POST.
784 dbx()->sys_msg('warning', 'dbxKi', 'styles', 'Schreibstile',
785 'Standardwerte bitte ueber das geschuetzte Formular zuruecksetzen.');
786 return $this->renderStylesAdmin();
787 }
788
789 public function handleExport(): void {
790 $this->ensureContentBootstrap();
791 if (!class_exists('ZipArchive')) {
792 dbx()->json_response(array('ok' => 0, 'error' => 'ZipArchive nicht verfuegbar.'), true);
793 }
794
795 $recipe = strtolower(trim((string) dbx()->get_request_var('recipe', '', '*')));
796 if (!in_array($recipe, array('page_create', 'page_update', 'page_translate'), true)) {
797 dbx()->json_response(array('ok' => 0, 'error' => 'Unbekanntes Rezept: ' . $recipe), true);
798 return;
799 }
800
801 $form = $this->briefingForm(
802 $this->briefingFormId($recipe),
803 'ki-briefing-' . str_replace('_', '-', $recipe),
804 $this->moduleUrl('briefing_export')
805 );
806 if (!$form->submit()) {
807 dbx()->json_response(array('ok' => 0, 'error' => 'Ungueltiger oder abgelaufener Formular-Token.'), true);
808 return;
809 }
810
811 if ($recipe === 'page_create') {
812 $package = $this->buildPageCreatePackage($this->collectPageCreateInput());
813 } elseif ($recipe === 'page_update') {
814 $package = $this->buildPageUpdatePackage($this->collectPageUpdateInput());
815 } elseif ($recipe === 'page_translate') {
816 $package = $this->buildPageTranslatePackage($this->collectPageTranslateInput());
817 }
818
819 $this->sendZipDownload($package);
820 }
821
822 private function collectPageCreateInput(): array {
823 $lng = strtolower(trim((string) dbx()->get_request_var('lng', dbxContentLng::current(), '*')));
824 return array(
825 'recipe' => 'page_create',
826 'lng' => $lng,
827 'folder_id' => max(0, (int) dbx()->get_request_var('folder_id', 0, 'int')),
828 'sorter_after_page_id' => max(0, (int) dbx()->get_request_var('sorter_after_page_id', 0, 'int')),
829 'title' => trim((string) dbx()->get_request_var('title', '', '*')),
830 'permalink' => trim((string) dbx()->get_request_var('permalink', '', '*')),
831 'description' => trim((string) dbx()->get_request_var('description', '', '*')),
832 'keywords' => trim((string) dbx()->get_request_var('keywords', '', '*')),
833 'writing_style' => trim((string) dbx()->get_request_var('writing_style', 'sachlich', '*')),
834 'content_brief' => trim((string) dbx()->get_request_var('content_brief', '', '*')),
835 'hero_enabled' => dbx()->get_request_var('hero_enabled', '0', '*') === '1',
836 'hero_brief' => trim((string) dbx()->get_request_var('hero_brief', '', '*')),
837 'hero_text_brief' => trim((string) dbx()->get_request_var('hero_text_brief', '', '*')),
838 'content_template' => trim((string) dbx()->get_request_var('content_template', '', '*')),
839 'bootstrap_components' => $this->selectedBootstrapComponentsFromRequest(),
840 'activ' => dbx()->get_request_var('activ', '0', '*') === '1',
841 'custom_notes' => trim((string) dbx()->get_request_var('custom_notes', '', '*')),
842 );
843 }
844
845 private function collectPageUpdateInput(): array {
846 $lng = strtolower(trim((string) dbx()->get_request_var('lng', dbxContentLng::current(), '*')));
847 $changeFields = array();
848 if (dbx()->get_request_var('change_content', '0', '*') === '1') {
849 $changeFields[] = 'content';
850 }
851 if (dbx()->get_request_var('change_title', '0', '*') === '1') {
852 $changeFields[] = 'title';
853 }
854 if (dbx()->get_request_var('change_description', '0', '*') === '1') {
855 $changeFields[] = 'description';
856 }
857 if (dbx()->get_request_var('change_hero', '0', '*') === '1') {
858 $changeFields[] = 'hero';
859 }
860 if (!$changeFields) {
861 $changeFields = array('content');
862 }
863
864 return array(
865 'recipe' => 'page_update',
866 'lng' => $lng,
867 'page_id' => max(0, (int) dbx()->get_request_var('page_id', 0, 'int')),
868 'writing_style' => trim((string) dbx()->get_request_var('writing_style', 'sachlich', '*')),
869 'change_brief' => trim((string) dbx()->get_request_var('change_brief', '', '*')),
870 'change_fields' => $changeFields,
871 'hero_brief' => trim((string) dbx()->get_request_var('hero_brief', '', '*')),
872 'custom_notes' => trim((string) dbx()->get_request_var('custom_notes', '', '*')),
873 'embedded_policy' => $this->sanitizeEmbeddedPolicy((string) dbx()->get_request_var('embedded_policy', 'preserve', '*')),
874 'embedded_change_notes' => trim((string) dbx()->get_request_var('embedded_change_notes', '', '*')),
875 'bootstrap_components' => $this->selectedBootstrapComponentsFromRequest(),
876 );
877 }
878
879 private function collectPageTranslateInput(): array {
880 $sourceLng = strtolower(trim((string) dbx()->get_request_var('source_lng', dbxContentLng::current(), '*')));
881 $targetLngs = $this->selectedTargetLngsFromRequest($sourceLng, false);
882 return array(
883 'recipe' => 'page_translate',
884 'source_lng' => $sourceLng,
885 'target_lng' => (string) ($targetLngs[0] ?? ''),
886 'target_lngs' => $targetLngs,
887 'source_id' => max(0, (int) dbx()->get_request_var('source_id', 0, 'int')),
888 'writing_style' => trim((string) dbx()->get_request_var('writing_style', 'sachlich', '*')),
889 'translation_notes' => trim((string) dbx()->get_request_var('translation_notes', '', '*')),
890 'copy_media' => dbx()->get_request_var('copy_media', '1', '*') !== '0',
891 );
892 }
893
894 private function buildPageCreatePackage(array $in): array {
895 if ($in['folder_id'] <= 0) {
896 throw new \InvalidArgumentException('Bitte einen Zielordner waehlen.');
897 }
898 if ($in['title'] === '') {
899 throw new \InvalidArgumentException('Titel ist erforderlich.');
900 }
901 if ($in['content_brief'] === '') {
902 throw new \InvalidArgumentException('Beschreiben Sie, worum es im Text gehen soll.');
903 }
904
905 $sorterAfterPage = array();
906 $sorterAfterLabel = '';
907 $sorterValue = '';
908 if ($in['sorter_after_page_id'] > 0) {
909 try {
910 $sorterAfterPage = $this->loadPage($in['lng'], $in['sorter_after_page_id']);
911 $in['folder_id'] = (int) ($sorterAfterPage['folder'] ?? $in['folder_id']);
912 $sorterAfterLabel = '#' . (int) $in['sorter_after_page_id'] . ' ' . (string) ($sorterAfterPage['title'] ?? '');
913 $sorterValue = $this->sorterAfterPage($in['lng'], $in['sorter_after_page_id']);
914 } catch (\Throwable $e) {
915 $in['sorter_after_page_id'] = 0;
916 }
917 }
918 $in['sorter'] = $sorterValue;
919 $folderLabel = $this->folderLabel($in['lng'], $in['folder_id']);
920 $styles = $this->writingStyles();
921 $styleKey = $in['writing_style'];
922 if (!isset($styles[$styleKey])) {
923 $styleKey = 'sachlich';
924 }
925 $stylePrompt = (string) ($styles[$styleKey]['prompt'] ?? '');
926
927 $permalink = $in['permalink'];
928 if ($permalink === '') {
929 $permalink = '(automatisch aus Titel)';
930 }
931
932 $contentTemplate = $this->contentTemplateForCreate($in['hero_enabled'], $in['content_template'] ?? '');
933 $templateSlots = $this->analyzeTemplateSlots($contentTemplate);
934
935 $briefing = array(
936 'briefing_version' => self::BRIEFING_VERSION,
937 'recipe' => 'page.create.v1',
938 'task' => 'page_create',
939 'created_at' => date('c'),
940 'lng' => $in['lng'],
941 'folder_id' => $in['folder_id'],
942 'folder_label' => $folderLabel,
943 'sorter_after_page_id' => $in['sorter_after_page_id'],
944 'sorter_after_label' => $sorterAfterLabel,
945 'sorter' => $sorterValue,
946 'title' => $in['title'],
947 'permalink' => $in['permalink'],
948 'description' => $in['description'],
949 'keywords' => $in['keywords'],
950 'activ' => $in['activ'],
951 'writing_style' => $styleKey,
952 'content' => array('brief' => $in['content_brief']),
953 'hero' => array(
954 'enabled' => $in['hero_enabled'],
955 'brief' => $in['hero_brief'],
956 'image' => $this->heroImageBriefingMeta(),
957 'height' => array(
958 'default' => self::HERO_DEFAULT_HEIGHT,
959 'rule' => 'In page.create hero_height=' . self::HERO_DEFAULT_HEIGHT . ' setzen, wenn nichts anderes angegeben ist.',
960 ),
961 ),
962 'hero_text' => array(
963 'brief' => $in['hero_text_brief'] ?? '',
964 'max_lines' => self::HERO_TEXT_MAX_LINES,
965 'rule' => 'Wenn nicht anders angegeben, maximal ' . self::HERO_TEXT_MAX_LINES . ' Zeilen.',
966 ),
967 'content_template' => $contentTemplate,
968 'template_slots' => $templateSlots,
969 'content_markers' => $this->contentMarkersMeta($templateSlots),
970 'bootstrap_components' => $this->bootstrapComponentsMeta($in['bootstrap_components'] ?? array()),
971 'custom_notes' => $in['custom_notes'],
972 );
973
974 $jobVorlage = $this->jobVorlagePageCreate($in);
975 $manifest = array(
976 'bundle_version' => self::BRIEFING_VERSION,
977 'title' => $in['title'],
978 'recipe' => 'page.create.v1',
979 'lng' => $in['lng'],
980 'intent' => 'create',
981 'area' => 'cms',
982 'auto_execute' => true,
983 );
984
985 $context = array(
986 'lng' => $in['lng'],
987 'target_folder_id' => $in['folder_id'],
988 'target_folder_label' => $folderLabel,
989 'sorter_after_page_id' => $in['sorter_after_page_id'],
990 'sorter_after' => $this->slimPageForUpdate($sorterAfterPage),
991 'sorter' => $sorterValue,
992 );
993
994 $auftrag = $this->renderTemplateFile('ki-page-create-auftrag.md', array(
995 'writing_style_prompt' => $stylePrompt,
996 'content_brief' => $in['content_brief'],
997 'custom_notes' => $in['custom_notes'] !== '' ? $in['custom_notes'] : '(keine)',
998 'zip_structure' => $this->zipStructureCreate($in['hero_enabled']),
999 'assets_rules' => $this->assetsRulesCreate($in['hero_enabled'], $in['hero_brief']),
1000 'lng' => $in['lng'],
1001 'folder_id' => (string) $in['folder_id'],
1002 'folder_label' => $folderLabel,
1003 'sorter_after_label' => $sorterAfterLabel !== '' ? $sorterAfterLabel : '(am Ende des Ordners)',
1004 'sorter' => $sorterValue !== '' ? $sorterValue : '(automatisch am Ende)',
1005 'title' => $in['title'],
1006 'permalink' => $permalink,
1007 'description' => $in['description'] !== '' ? $in['description'] : '(optional)',
1008 'keywords' => $in['keywords'] !== '' ? $in['keywords'] : '(optional)',
1009 'activ' => $in['activ'] ? '1 (aktiv)' : '0 (inaktiv)',
1010 'content_template' => $contentTemplate,
1011 'hero_text_brief' => ($in['hero_text_brief'] ?? '') !== '' ? $in['hero_text_brief'] : '(optional — Kurztext im Hero-Bereich)',
1012 'content_markers_guide' => $this->contentMarkersGuide($contentTemplate, $in['hero_enabled'], $in['hero_text_brief'] ?? ''),
1013 'bootstrap_components_guide' => $this->bootstrapComponentsGuide($in['bootstrap_components'] ?? array()),
1014 ));
1015
1016 $files = $this->packBriefingFiles(array(
1017 'recipe' => 'page.create.v1',
1018 'task_label' => 'Neue CMS-Seite: ' . $in['title'],
1019 'hero_assets' => $in['hero_enabled'],
1020 'content_template' => $contentTemplate,
1021 'context_hint' => 'Nein — Ordner und Sortierung stehen in briefing.json',
1022 'manifest' => $manifest,
1023 'briefing' => $briefing,
1024 'job_vorlage' => $jobVorlage,
1025 'context' => $context,
1026 'auftrag' => $auftrag,
1027 ));
1028 if ($in['hero_enabled']) {
1029 $files['assets/README.txt'] = $this->assetsReadmeHero($in['hero_brief']);
1030 }
1031
1032 return array(
1033 'filename' => 'dbxki-auftrag-neue-seite-' . preg_replace('/[^a-z0-9_-]+/i', '-', $in['title']) . '.zip',
1034 'files' => $files,
1035 );
1036 }
1037
1038 private function buildPageUpdatePackage(array $in): array {
1039 if ($in['page_id'] <= 0) {
1040 throw new \InvalidArgumentException('Bitte eine Seite waehlen.');
1041 }
1042 if ($in['change_brief'] === '') {
1043 throw new \InvalidArgumentException('Beschreiben Sie die gewuenschte Aenderung.');
1044 }
1045
1046 $page = $this->loadPage($in['lng'], $in['page_id']);
1047 $styles = $this->writingStyles();
1048 $styleKey = $in['writing_style'];
1049 if (!isset($styles[$styleKey])) {
1050 $styleKey = 'sachlich';
1051 }
1052 $pageTemplate = trim((string) ($page['template'] ?? 'parent'));
1053 if ($pageTemplate === '' || $pageTemplate === 'parent') {
1054 $pageTemplate = 'c-body1-footer';
1055 }
1056 $pageContext = $this->pageContextForKi($in['lng'], $page);
1057 $embeddedPolicy = $this->embeddedPolicyText($in['embedded_policy'], $in['embedded_change_notes']);
1058
1059 $briefing = array(
1060 'briefing_version' => self::BRIEFING_VERSION,
1061 'recipe' => 'page.update.v1',
1062 'task' => 'page_update',
1063 'lng' => $in['lng'],
1064 'page_id' => $in['page_id'],
1065 'page_title' => (string) ($page['title'] ?? ''),
1066 'permalink' => (string) ($page['permalink'] ?? ''),
1067 'writing_style' => $styleKey,
1068 'change_brief' => $in['change_brief'],
1069 'change_fields' => $in['change_fields'],
1070 'content_template' => $pageTemplate,
1071 'bootstrap_components' => $this->bootstrapComponentsMeta($in['bootstrap_components'] ?? array()),
1072 'embedded_content_policy' => array(
1073 'mode' => $in['embedded_policy'],
1074 'notes' => $in['embedded_change_notes'],
1075 'default' => 'preserve existing embedded media and module calls exactly',
1076 ),
1077 'hero' => array(
1078 'brief' => $in['hero_brief'],
1079 'image' => $this->heroImageBriefingMeta(),
1080 ),
1081 'custom_notes' => $in['custom_notes'],
1082 );
1083
1084 $jobVorlage = $this->jobVorlagePageUpdate($in, $page);
1085 $manifest = array(
1086 'bundle_version' => self::BRIEFING_VERSION,
1087 'title' => 'Update: ' . ($page['title'] ?? ''),
1088 'recipe' => 'page.update.v1',
1089 'lng' => $in['lng'],
1090 'intent' => 'update',
1091 'area' => 'cms',
1092 'auto_execute' => true,
1093 );
1094
1095 $context = array(
1096 'lng' => $in['lng'],
1097 'current_page' => $this->slimPageForUpdate($page),
1098 'current_page_context' => $pageContext,
1099 );
1100
1101 $excerpt = $this->truncate((string) ($page['content'] ?? ''), 4000);
1102
1103 $heroChange = in_array('hero', $in['change_fields'], true);
1104
1105 $auftrag = $this->renderTemplateFile('ki-page-update-auftrag.md', array(
1106 'writing_style_prompt' => (string) ($styles[$styleKey]['prompt'] ?? ''),
1107 'change_brief' => $in['change_brief'],
1108 'custom_notes' => $in['custom_notes'] !== '' ? $in['custom_notes'] : '(keine)',
1109 'embedded_policy' => $embeddedPolicy,
1110 'embedded_summary' => $this->embeddedSummaryForPrompt($pageContext),
1111 'zip_structure' => $this->zipStructureUpdate($heroChange),
1112 'assets_rules' => $this->assetsRulesUpdate($heroChange, $in['hero_brief']),
1113 'page_id' => (string) $in['page_id'],
1114 'page_title' => (string) ($page['title'] ?? ''),
1115 'lng' => $in['lng'],
1116 'permalink' => (string) ($page['permalink'] ?? ''),
1117 'current_content_excerpt' => $excerpt,
1118 'content_markers_guide' => in_array('content', $in['change_fields'], true)
1119 ? $this->contentMarkersGuide($pageTemplate, $heroChange)
1120 : '(Inhalt wird nicht geaendert.)',
1121 'bootstrap_components_guide' => in_array('content', $in['change_fields'], true)
1122 ? $this->bootstrapComponentsGuide($in['bootstrap_components'] ?? array())
1123 : '(Inhalt wird nicht geaendert.)',
1124 ));
1125
1126 return array(
1127 'filename' => 'dbxki-auftrag-update-seite-' . (int) $in['page_id'] . '.zip',
1128 'files' => array_merge($this->packBriefingFiles(array(
1129 'recipe' => 'page.update.v1',
1130 'task_label' => 'Seite #' . $in['page_id'] . ' aendern',
1131 'hero_assets' => $heroChange,
1132 'content_template' => $pageTemplate,
1133 'context_hint' => 'Ja — bisheriger Seiteninhalt',
1134 'manifest' => $manifest,
1135 'briefing' => $briefing,
1136 'job_vorlage' => $jobVorlage,
1137 'context' => $context,
1138 'auftrag' => $auftrag,
1139 )), $heroChange ? array('assets/README.txt' => $this->assetsReadmeHero($in['hero_brief'])) : array()),
1140 );
1141 }
1142
1143 private function buildPageTranslatePackage(array $in): array {
1144 if ($in['source_id'] <= 0) {
1145 throw new \InvalidArgumentException('Bitte eine Quellseite waehlen.');
1146 }
1147 $targets = $this->normalizeTargetLngs((array) ($in['target_lngs'] ?? array()), true);
1148 if (!$targets) {
1149 throw new \InvalidArgumentException('Bitte mindestens eine Ziel- oder Korrektursprache waehlen.');
1150 }
1151 $in['target_lngs'] = $targets;
1152 $in['target_lng'] = (string) ($targets[0] ?? '');
1153
1154 $source = $this->loadPage($in['source_lng'], $in['source_id']);
1155 $sourceContext = $this->pageContextForKi($in['source_lng'], $source);
1156 $styles = $this->writingStyles();
1157 $styleKey = $in['writing_style'];
1158 if (!isset($styles[$styleKey])) {
1159 $styleKey = 'sachlich';
1160 }
1161 $targetLabels = $this->targetInstructionLabels($in['source_lng'], $targets);
1162 $hasCorrections = in_array($in['source_lng'], $targets, true);
1163 $realTargets = array_values(array_filter($targets, function ($lng) use ($in) {
1164 return $lng !== $in['source_lng'];
1165 }));
1166
1167 $briefing = array(
1168 'briefing_version' => self::BRIEFING_VERSION,
1169 'recipe' => 'translation.v1',
1170 'task' => 'page_translate',
1171 'source_lng' => $in['source_lng'],
1172 'target_lng' => $in['target_lng'],
1173 'target_lngs' => $targets,
1174 'targets' => $targetLabels,
1175 'correction_mode_lngs' => $hasCorrections ? array($in['source_lng']) : array(),
1176 'source_id' => $in['source_id'],
1177 'source_title' => (string) ($source['title'] ?? ''),
1178 'source_permalink' => (string) ($source['permalink'] ?? ''),
1179 'writing_style' => $styleKey,
1180 'translation_notes' => $in['translation_notes'],
1181 'copy_media' => $in['copy_media'],
1182 );
1183
1184 $jobVorlage = $this->jobVorlagePageTranslate($in);
1185 $manifest = array(
1186 'bundle_version' => self::BRIEFING_VERSION,
1187 'title' => 'Uebersetzung: ' . ($source['title'] ?? ''),
1188 'recipe' => 'translation.v1',
1189 'source_lng' => $in['source_lng'],
1190 'target_lng' => $in['target_lng'],
1191 'target_lngs' => $targets,
1192 'intent' => $realTargets && $hasCorrections ? 'translate_and_correct' : ($hasCorrections ? 'correct' : 'translate'),
1193 );
1194
1195 $context = array(
1196 'source_lng' => $in['source_lng'],
1197 'target_lng' => $in['target_lng'],
1198 'target_lngs' => $targets,
1199 'targets' => $targetLabels,
1200 'source' => $this->slimPageForTranslation($source),
1201 'source_page_context' => $sourceContext,
1202 );
1203
1204 $auftrag = $this->renderTemplateFile('ki-page-translation-auftrag.md', array(
1205 'writing_style_prompt' => (string) ($styles[$styleKey]['prompt'] ?? ''),
1206 'translation_notes' => $in['translation_notes'] !== '' ? $in['translation_notes'] : '(keine)',
1207 'source_lng' => $in['source_lng'],
1208 'target_lng' => $in['target_lng'],
1209 'target_lngs' => implode(', ', array_map('strtoupper', $targets)),
1210 'target_instructions' => $this->targetInstructionsForPrompt($in['source_lng'], $targets),
1211 'source_id' => (string) $in['source_id'],
1212 'source_title' => (string) ($source['title'] ?? ''),
1213 'source_permalink' => (string) ($source['permalink'] ?? ''),
1214 'render_reference' => (string) ($sourceContext['render_reference'] ?? ''),
1215 'embedded_summary' => $this->embeddedSummaryForPrompt($sourceContext),
1216 ));
1217
1218 return array(
1219 'filename' => 'dbxki-auftrag-uebersetzung-' . (int) $in['source_id'] . '-' . implode('-', $targets) . '.zip',
1220 'files' => $this->packBriefingFiles(array(
1221 'recipe' => 'translation.v1',
1222 'task_label' => 'Uebersetzung/Korrektur ' . strtoupper($in['source_lng']) . ' -> ' . implode(', ', array_map('strtoupper', $targets)),
1223 'hero_assets' => false,
1224 'context_hint' => 'Ja — Quelltext in source.content',
1225 'content_template' => (string) ($sourceContext['template'] ?? ''),
1226 'manifest' => $manifest,
1227 'briefing' => $briefing,
1228 'job_vorlage' => $jobVorlage,
1229 'context' => $context,
1230 'auftrag' => $auftrag,
1231 )),
1232 );
1233 }
1234
1235 private function jobVorlagePageCreate(array $in): array {
1236 $steps = array();
1237 if ($in['hero_enabled']) {
1238 $steps[] = array(
1239 'id' => 'hero',
1240 'action' => 'media.create_base64',
1241 'params' => array(
1242 'file_name' => 'hero.jpg',
1243 'asset_ref' => 'hero.jpg',
1244 'media_folder' => 'img/hero',
1245 'title' => $in['title'],
1246 'alt' => '___KI_FUELLEN___',
1247 ),
1248 );
1249 }
1250
1251 $pageParams = array(
1252 'lng' => $in['lng'],
1253 'folder_id' => $in['folder_id'],
1254 'title' => $in['title'],
1255 'template' => $this->contentTemplateForCreate($in['hero_enabled'], $in['content_template'] ?? ''),
1256 'activ' => $in['activ'] ? 1 : 0,
1257 'content' => '___KI_FUELLEN___',
1258 );
1259 if ($in['hero_enabled']) {
1260 $pageParams['hero_height'] = self::HERO_DEFAULT_HEIGHT;
1261 }
1262 if (!empty($in['sorter'])) {
1263 $pageParams['sorter'] = (string) $in['sorter'];
1264 }
1265 if ($in['permalink'] !== '') {
1266 $pageParams['permalink'] = $in['permalink'];
1267 }
1268 if ($in['description'] !== '') {
1269 $pageParams['description'] = $in['description'];
1270 }
1271 if ($in['keywords'] !== '') {
1272 $pageParams['keywords'] = $in['keywords'];
1273 }
1274
1275 $steps[] = array(
1276 'id' => 'page',
1277 'action' => 'page.create',
1278 'params' => $pageParams,
1279 );
1280
1281 if ($in['hero_enabled']) {
1282 $steps[] = array(
1283 'id' => 'hero_assign',
1284 'action' => 'media.assign',
1285 'params' => array(
1286 'media_id' => '$ref:hero.media_id',
1287 'content_id' => '$ref:page.page_id',
1288 'slot' => 'hero',
1289 'lng' => $in['lng'],
1290 ),
1291 );
1292 }
1293
1294 return array('steps' => $steps);
1295 }
1296
1297 private function jobVorlagePageUpdate(array $in, array $page): array {
1298 $patch = array();
1299 if (in_array('content', $in['change_fields'], true)) {
1300 $patch['content'] = '___KI_FUELLEN___';
1301 }
1302 if (in_array('title', $in['change_fields'], true)) {
1303 $patch['title'] = '___KI_FUELLEN___';
1304 }
1305 if (in_array('description', $in['change_fields'], true)) {
1306 $patch['description'] = '___KI_FUELLEN___';
1307 }
1308 if (in_array('hero', $in['change_fields'], true)) {
1309 $currentHeroHeight = trim((string) ($page['hero_height'] ?? ''));
1310 if ($currentHeroHeight === '' || $currentHeroHeight === 'parent') {
1311 $patch['hero_height'] = self::HERO_DEFAULT_HEIGHT;
1312 }
1313 }
1314
1315 $steps = array();
1316 if (in_array('hero', $in['change_fields'], true)) {
1317 $steps[] = array(
1318 'id' => 'hero',
1319 'action' => 'media.create_base64',
1320 'params' => array(
1321 'file_name' => 'hero.jpg',
1322 'asset_ref' => 'hero.jpg',
1323 'media_folder' => 'img/hero',
1324 'title' => (string) ($page['title'] ?? 'Hero'),
1325 'alt' => '___KI_FUELLEN___',
1326 ),
1327 );
1328 $steps[] = array(
1329 'id' => 'hero_assign',
1330 'action' => 'media.assign',
1331 'params' => array(
1332 'media_id' => '$ref:hero.media_id',
1333 'content_id' => (int) $in['page_id'],
1334 'slot' => 'hero',
1335 'lng' => $in['lng'],
1336 ),
1337 );
1338 }
1339
1340 $steps[] = array(
1341 'id' => 'page',
1342 'action' => 'page.update',
1343 'params' => array(
1344 'lng' => $in['lng'],
1345 'id' => (int) $in['page_id'],
1346 'patch' => $patch,
1347 ),
1348 );
1349
1350 return array('steps' => $steps);
1351 }
1352
1353 private function jobVorlagePageTranslate(array $in): array {
1354 $steps = array();
1355 foreach ((array) ($in['target_lngs'] ?? array($in['target_lng'])) as $targetLng) {
1356 $targetLng = strtolower(trim((string) $targetLng));
1357 if ($targetLng === '') {
1358 continue;
1359 }
1360 if ($targetLng === $in['source_lng']) {
1361 $steps[] = array(
1362 'id' => 'proofread_' . $targetLng,
1363 'action' => 'page.update',
1364 'params' => array(
1365 'lng' => $in['source_lng'],
1366 'id' => (int) $in['source_id'],
1367 'patch' => array(
1368 'title' => '___KI_FUELLEN___',
1369 'description' => '___KI_FUELLEN___',
1370 'keywords' => '___KI_FUELLEN___',
1371 'content' => '___KI_FUELLEN___',
1372 ),
1373 ),
1374 );
1375 continue;
1376 }
1377 $steps[] = array(
1378 'id' => 'translation_' . $targetLng,
1379 'action' => 'translation.apply',
1380 'params' => array(
1381 'source_lng' => $in['source_lng'],
1382 'target_lng' => $targetLng,
1383 'source_id' => (int) $in['source_id'],
1384 'copy_media' => $in['copy_media'] ? 1 : 0,
1385 'translation' => array(
1386 'title' => '___KI_FUELLEN___',
1387 'description' => '___KI_FUELLEN___',
1388 'keywords' => '___KI_FUELLEN___',
1389 'content' => '___KI_FUELLEN___',
1390 ),
1391 ),
1392 );
1393 }
1394 return array(
1395 'steps' => $steps,
1396 );
1397 }
1398
1399 private function packBriefingFiles(array $in): array {
1400 $recipe = (string) ($in['recipe'] ?? '');
1401 $heroAssets = !empty($in['hero_assets']);
1402 return array(
1403 '00-START.md' => $this->buildStartMd(
1404 $recipe,
1405 (string) ($in['task_label'] ?? ''),
1406 $heroAssets,
1407 (string) ($in['context_hint'] ?? ''),
1408 (string) ($in['content_template'] ?? '')
1409 ),
1410 'manifest.json' => $in['manifest'],
1411 'briefing.json' => $in['briefing'],
1412 'job.vorlage.json' => $in['job_vorlage'],
1413 'context.json' => $in['context'],
1414 'KI-AUFTRAG.md' => $in['auftrag'],
1415 'bundle.rules.json' => $this->bundleRulesForRecipe($recipe, $heroAssets, (string) ($in['content_template'] ?? '')),
1416 );
1417 }
1418
1419 private function buildStartMd(string $recipe, string $taskLabel, bool $heroAssets, string $contextHint, string $contentTemplate = ''): string {
1420 if ($contentTemplate === '') {
1421 $contentTemplate = $heroAssets ? self::CONTENT_TEMPLATE_DEFAULT : 'parent';
1422 }
1423 $zipExtra = $heroAssets
1424 ? "- `assets/hero.jpg` (" . $this->heroImageSpecText() . ")\n"
1425 : '';
1426 $assetsShort = $heroAssets
1427 ? '**Ja:** genau `assets/hero.jpg` — ' . $this->heroImageSpecText() . '. `asset_ref` = `hero.jpg` (nicht aendern).'
1428 : '**Nein.** Keinen `assets/` Ordner anlegen.';
1429 return $this->renderTemplateFile('ki-start.md', array(
1430 'task_label' => $taskLabel,
1431 'recipe' => $recipe,
1432 'zip_extra' => $zipExtra,
1433 'assets_short' => $assetsShort,
1434 'context_hint' => $contextHint,
1435 'content_layout_short' => $this->contentMarkersGuideShort($contentTemplate),
1436 ));
1437 }
1438
1439 private function bundleRulesForRecipe(string $recipe, bool $withHero = false, string $contentTemplate = ''): array {
1440 $actions = array();
1441 switch ($recipe) {
1442 case 'page.create.v1':
1443 $actions = $withHero
1444 ? array('media.create_base64', 'page.create', 'media.assign')
1445 : array('page.create');
1446 break;
1447 case 'page.update.v1':
1448 $actions = $withHero
1449 ? array('page.hero_replace_image', 'page.hero_create_image', 'media.create_base64', 'page.update', 'media.assign')
1450 : array('page.update');
1451 break;
1452 case 'translation.v1':
1453 $actions = array('translation.apply', 'page.update');
1454 break;
1455 }
1456 return array(
1457 'recipe' => $recipe,
1458 'allowed_actions' => $actions,
1459 'refs' => '$ref:{step_id}.{field}',
1460 'asset_ref' => 'Dateiname relativ zu assets/, z.B. hero.jpg',
1461 'forbidden' => array('*.delete', 'data_base64'),
1462 'content' => $this->contentRulesForBriefing($contentTemplate, $withHero),
1463 );
1464 }
1465
1466 private function contentRulesForBriefing(string $contentTemplate, bool $withHero): array {
1467 if ($contentTemplate === '') {
1468 $contentTemplate = $withHero ? self::CONTENT_TEMPLATE_DEFAULT : 'parent';
1469 }
1470 $slots = $this->analyzeTemplateSlots($contentTemplate);
1471 $markers = $this->contentMarkersMeta($slots);
1472 $rules = array(
1473 'format' => 'HTML in page.create/page.update content oder translation.content',
1474 'marker_type' => 'hr',
1475 'markers' => $markers,
1476 'marker_order' => array('hero', 'header', 'footer'),
1477 'marker_attributes' => 'class="dbx-cms-marker", data-dbx-marker="dbx:hero|header|footer", data-label',
1478 'sections' => array(
1479 'before_hero' => 'Hero-Text ({cms:hero_text})',
1480 'between_hero_and_header' => 'Header ({cms:header}) — eigener Abschnitt nach Hero, vor Body',
1481 'between_header_and_footer' => 'Body ({cms:col1})',
1482 'after_footer' => 'Footer ({cms:footer})',
1483 ),
1484 'missing_marker' => 'Bereich entfaellt, Text gehoert zum Body',
1485 'no_column_markers' => 'Spalten-Marker (col2/col3a/col3b) nicht von der KI setzen',
1486 'hero_image' => 'Hero-Bild nicht im HTML. Bestehendes Hero aendern: page.hero_replace_image. Neues Hero setzen: page.hero_create_image oder media.create_base64 + media.assign mit media_folder=img/hero.',
1487 'fake_hero_forbidden' => 'Ein Seitenkopf aus Inline-Bild plus position-relative/position-absolute Textebene ist verboten. Das Bild gehoert in slot=hero/hero_image_id; der Text vor den dbx:hero-Marker.',
1488 'hero_height_default' => self::HERO_DEFAULT_HEIGHT . ' in page.create/page.update setzen, wenn ein Hero-Bild neu angelegt wird und nichts anderes angegeben ist',
1489 'hero_text_default' => 'Hero-Text maximal ' . self::HERO_TEXT_MAX_LINES . ' Zeilen, wenn nicht anders angegeben',
1490 'inline_images' => 'CMS-Medien nie als files/media/... in img src setzen. Nach media.create_* inline_src oder inline_img verwenden: index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid={id} plus data-cms-media-id.',
1491 'package_pages' => 'Paket-Detailseiten (dbxapp-paket-*) per page.update mit patch.package_product_image=true auf home-package-* Produktbild umstellen. page.get liefert package_hint.',
1492 );
1493 $rules['template'] = $contentTemplate;
1494 $rules['template_slots'] = $slots;
1495 return $rules;
1496 }
1497
1498 private function sanitizeEmbeddedPolicy(string $policy): string {
1499 $policy = strtolower(trim($policy));
1500 return in_array($policy, array('preserve', 'reorder', 'remove'), true) ? $policy : 'preserve';
1501 }
1502
1503 private function embeddedPolicyText(string $policy, string $notes): string {
1504 $policy = $this->sanitizeEmbeddedPolicy($policy);
1505 $notes = trim($notes);
1506 $lines = array(
1507 'Standard: Bestehende eingebettete Medien, Videos und `[modul=...]...[/modul]`-Aufrufe exakt beibehalten.',
1508 'Keine bestehenden Medienpfade manuell umschreiben; dbxKi/CMS-Befehle loesen Speicherpfade automatisch.',
1509 );
1510 if ($policy === 'reorder') {
1511 $lines[] = 'Erlaubt: vorhandene Medien/Module neu anordnen, aber nur soweit es zur gewuenschten Aenderung passt.';
1512 } elseif ($policy === 'remove') {
1513 $lines[] = 'Erlaubt: vorhandene Medien/Module entfernen, aber nur wenn unten konkret beschrieben ist, was weg soll.';
1514 } else {
1515 $lines[] = 'Nicht erlaubt: Medien/Module entfernen, ersetzen oder neu anordnen.';
1516 }
1517 if ($notes !== '') {
1518 $lines[] = 'Konkrete Anweisung zu Medien/Modulen: ' . $notes;
1519 }
1520 return implode("\n", $lines);
1521 }
1522
1523 private function moduleCallsFromContent(string $content): array {
1524 $calls = array();
1525 if (preg_match_all('/\‍[modul=([A-Za-z0-9_]+)\‍]([\s\S]*?)\‍[\/modul\‍]/i', $content, $m, PREG_SET_ORDER)) {
1526 foreach ($m as $idx => $match) {
1527 $calls[] = array(
1528 'index' => $idx + 1,
1529 'modul' => (string) ($match[1] ?? ''),
1530 'params' => trim((string) ($match[2] ?? '')),
1531 'marker' => (string) ($match[0] ?? ''),
1532 );
1533 }
1534 }
1535 return $calls;
1536 }
1537
1538 private function inlineMediaIdsFromContent(string $content): array {
1539 $ids = array();
1540 if (preg_match_all('/data-cms-media-id=["\']?([0-9]+)/i', $content, $m)) {
1541 foreach ($m[1] as $id) {
1542 $ids[(int) $id] = (int) $id;
1543 }
1544 }
1545 if (preg_match_all('/(?:dbx_mid|media_id)=([0-9]+)/i', $content, $m)) {
1546 foreach ($m[1] as $id) {
1547 $ids[(int) $id] = (int) $id;
1548 }
1549 }
1550 return array_values(array_filter($ids));
1551 }
1552
1553 private function mediaContextForPage(int $pageId, string $content): array {
1554 $db = dbx()->get_system_obj('dbxDB');
1555 $mediaIds = $this->inlineMediaIdsFromContent($content);
1556 $usageRows = $db->select('dbxMediaUsage', dbxContentMediaUsageScope::withLanguage('content_id = ' . (int) $pageId . ' AND active = 1'), '*', 'slot,sorter,id', 'ASC', '', 0, 0, 0);
1557 if (!is_array($usageRows)) {
1558 $usageRows = array();
1559 }
1560 foreach ($usageRows as $usage) {
1561 $id = (int) ($usage['media_id'] ?? 0);
1562 if ($id > 0) {
1563 $mediaIds[$id] = $id;
1564 }
1565 }
1566
1567 $rows = array();
1568 foreach (array_values(array_unique($mediaIds)) as $id) {
1569 $media = $db->select1('dbxMedia', (int) $id);
1570 if (!is_array($media)) {
1571 $rows[] = array('id' => (int) $id, 'missing' => 1);
1572 continue;
1573 }
1574 $usage = array_values(array_filter($usageRows, function ($row) use ($id) {
1575 return (int) ($row['media_id'] ?? 0) === (int) $id;
1576 }));
1577 $rows[] = array(
1578 'id' => (int) $id,
1579 'title' => (string) ($media['title'] ?? $media['file_name'] ?? ''),
1580 'file_name' => (string) ($media['file_name'] ?? ''),
1581 'media_type' => (string) ($media['media_type'] ?? ''),
1582 'mime' => (string) ($media['mime'] ?? ''),
1583 'slots' => array_values(array_unique(array_map(function ($row) {
1584 return (string) ($row['slot'] ?? '');
1585 }, $usage))),
1586 'inline_reference' => in_array((int) $id, $this->inlineMediaIdsFromContent($content), true) ? 1 : 0,
1587 );
1588 }
1589 return $rows;
1590 }
1591
1592 private function renderedTextForPage(string $lng, int $pageId): string {
1593 try {
1594 $renderer = dbx()->get_include_obj('dbxContentRenderer', 'dbxContent');
1595 $html = $this->withContentLng($lng, function () use ($renderer, $pageId) {
1596 return $renderer->render($pageId);
1597 });
1598 return $this->truncate(strip_tags((string) $html), 12000);
1599 } catch (\Throwable $e) {
1600 return '';
1601 }
1602 }
1603
1604 private function pageContextForKi(string $lng, array $page): array {
1605 $pageId = (int) ($page['id'] ?? 0);
1606 $content = (string) ($page['content'] ?? '');
1607 return array(
1608 'render_reference' => '[modul=dbxContent]dbx_run1=show&cid=' . $pageId . '[/modul]',
1609 'folder_label' => $this->folderLabel($lng, (int) ($page['folder'] ?? 0)),
1610 'template' => (string) ($page['template'] ?? ''),
1611 'rendered_text_excerpt' => $this->renderedTextForPage($lng, $pageId),
1612 'embedded_media' => $this->mediaContextForPage($pageId, $content),
1613 'module_calls' => $this->moduleCallsFromContent($content),
1614 'rules' => array(
1615 'default' => 'Preserve embedded media and module calls exactly unless the briefing explicitly allows reorder/remove.',
1616 'media_paths' => 'Do not rewrite existing media paths manually. Keep existing HTML/media references or use dbxKi media steps for new hero assets.',
1617 ),
1618 );
1619 }
1620
1621 private function embeddedSummaryForPrompt(array $context): string {
1622 $media = is_array($context['embedded_media'] ?? null) ? $context['embedded_media'] : array();
1623 $modules = is_array($context['module_calls'] ?? null) ? $context['module_calls'] : array();
1624 $lines = array();
1625 $lines[] = 'Eingebettete Medien: ' . count($media);
1626 foreach ($media as $row) {
1627 $label = '#' . (int) ($row['id'] ?? 0);
1628 $title = trim((string) ($row['title'] ?? $row['file_name'] ?? ''));
1629 $slots = implode(',', array_filter((array) ($row['slots'] ?? array())));
1630 $lines[] = '- ' . $label . ($title !== '' ? ' ' . $title : '') . ($slots !== '' ? ' [' . $slots . ']' : '');
1631 }
1632 $lines[] = 'Modul-Aufrufe: ' . count($modules);
1633 foreach ($modules as $call) {
1634 $lines[] = '- [modul=' . (string) ($call['modul'] ?? '') . ']' . (string) ($call['params'] ?? '') . '[/modul]';
1635 }
1636 return implode("\n", $lines);
1637 }
1638
1639 private function renderPageContextHtml(string $lng, array $page): string {
1640 $context = $this->pageContextForKi($lng, $page);
1641 $media = is_array($context['embedded_media'] ?? null) ? $context['embedded_media'] : array();
1642 $modules = is_array($context['module_calls'] ?? null) ? $context['module_calls'] : array();
1643 $mediaHtml = '';
1644 foreach ($media as $row) {
1645 $title = trim((string) ($row['title'] ?? $row['file_name'] ?? ''));
1646 $slots = implode(', ', array_filter((array) ($row['slots'] ?? array())));
1647 $mediaHtml .= '<li><code>#' . (int) ($row['id'] ?? 0) . '</code> '
1648 . $this->esc($title !== '' ? $title : 'Medium')
1649 . ($slots !== '' ? ' <span class="text-muted">(' . $this->esc($slots) . ')</span>' : '')
1650 . '</li>';
1651 }
1652 if ($mediaHtml === '') {
1653 $mediaHtml = '<li class="text-muted">Keine eingebetteten Medien erkannt.</li>';
1654 }
1655
1656 $moduleHtml = '';
1657 foreach ($modules as $call) {
1658 $moduleHtml .= '<li><code>[modul=' . $this->esc($call['modul'] ?? '') . ']</code> '
1659 . '<span class="text-muted">' . $this->esc($call['params'] ?? '') . '</span></li>';
1660 }
1661 if ($moduleHtml === '') {
1662 $moduleHtml = '<li class="text-muted">Keine Modul-Aufrufe erkannt.</li>';
1663 }
1664
1665 return $this->tpl()->get_tpl('dbxKi|ki-briefing-page-update-context', array(
1666 'page_id' => (string) (int) ($page['id'] ?? 0),
1667 'page_title' => $this->esc((string) ($page['title'] ?? '')),
1668 'folder_label' => $this->esc((string) ($context['folder_label'] ?? '')),
1669 'template' => $this->esc((string) ($context['template'] ?? '')),
1670 'permalink' => $this->esc((string) ($page['permalink'] ?? '')),
1671 'media_count' => (string) count($media),
1672 'module_count' => (string) count($modules),
1673 'media_items' => $mediaHtml,
1674 'module_items' => $moduleHtml,
1675 ));
1676 }
1677
1678 private function createPlacementTitle(string $lng, int $folderId, int $afterPageId): string {
1679 $folder = $folderId > 0 ? $this->folderLabel($lng, $folderId) : '';
1680 if ($afterPageId > 0) {
1681 try {
1682 $page = $this->loadPage($lng, $afterPageId);
1683 return 'Unter #' . $afterPageId . ' ' . (string) ($page['title'] ?? '') . ' in ' . $folder;
1684 } catch (\Throwable $e) {
1685 return $folder !== '' ? $folder : 'Zielposition waehlen';
1686 }
1687 }
1688 return $folder !== '' ? $folder . ' / am Ende' : 'Zielposition waehlen';
1689 }
1690
1691 private function renderCreatePlacementHtml(string $lng, int $folderId, int $afterPageId): string {
1692 $folderLabel = $folderId > 0 ? $this->folderLabel($lng, $folderId) : '';
1693 $pageTitle = '';
1694 $sorter = '';
1695 if ($afterPageId > 0) {
1696 try {
1697 $page = $this->loadPage($lng, $afterPageId);
1698 $pageTitle = (string) ($page['title'] ?? '');
1699 $folderId = (int) ($page['folder'] ?? $folderId);
1700 $folderLabel = $this->folderLabel($lng, $folderId);
1701 $sorter = $this->sorterAfterPage($lng, $afterPageId);
1702 } catch (\Throwable $e) {
1703 $afterPageId = 0;
1704 }
1705 }
1706 return $this->tpl()->get_tpl('dbxKi|ki-briefing-page-create-placement', array(
1707 'folder_id' => $folderId > 0 ? (string) $folderId : '-',
1708 'folder_label' => $this->esc($folderLabel !== '' ? $folderLabel : 'Noch kein Zielordner gewaehlt'),
1709 'after_page_id' => $afterPageId > 0 ? (string) $afterPageId : '-',
1710 'after_page_title' => $this->esc($pageTitle !== '' ? $pageTitle : 'Keine Seite als Sortieranker'),
1711 'sorter' => $this->esc($sorter !== '' ? $sorter : 'automatisch am Ende'),
1712 ));
1713 }
1714
1715 private function slimPageForUpdate(array $page): array {
1716 return array(
1717 'id' => (int) ($page['id'] ?? 0),
1718 'title' => (string) ($page['title'] ?? ''),
1719 'permalink' => (string) ($page['permalink'] ?? ''),
1720 'description' => (string) ($page['description'] ?? ''),
1721 'keywords' => (string) ($page['keywords'] ?? ''),
1722 'content' => (string) ($page['content'] ?? ''),
1723 );
1724 }
1725
1726 private function slimPageForTranslation(array $page): array {
1727 return array(
1728 'id' => (int) ($page['id'] ?? 0),
1729 'title' => (string) ($page['title'] ?? ''),
1730 'permalink' => (string) ($page['permalink'] ?? ''),
1731 'description' => (string) ($page['description'] ?? ''),
1732 'keywords' => (string) ($page['keywords'] ?? ''),
1733 'content' => (string) ($page['content'] ?? ''),
1734 );
1735 }
1736
1737 private function sendZipDownload(array $package): void {
1738 $tmp = tempnam(sys_get_temp_dir(), 'dbxki_brief');
1739 $zip = new \ZipArchive();
1740 if ($zip->open($tmp, \ZipArchive::OVERWRITE) !== true) {
1741 @unlink($tmp);
1742 throw new \RuntimeException('ZIP konnte nicht erstellt werden.');
1743 }
1744
1745 foreach ($package['files'] as $path => $content) {
1746 if (is_array($content)) {
1747 $content = json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
1748 }
1749 $zip->addFromString($path, (string) $content);
1750 }
1751 $zip->close();
1752
1753 $name = (string) ($package['filename'] ?? 'dbxki-auftrag.zip');
1754 header('Content-Type: application/zip');
1755 header('Content-Disposition: attachment; filename="' . str_replace('"', '', $name) . '"');
1756 header('Content-Length: ' . filesize($tmp));
1757 readfile($tmp);
1758 @unlink($tmp);
1759 exit;
1760 }
1761
1762 private function writingStyles(): array {
1763 return dbxKiWritingStyles::all();
1764 }
1765
1766 private function zipStructureCreate(bool $heroEnabled): string {
1767 if ($heroEnabled) {
1768 return "antwort.zip\n"
1769 . "├── manifest.json\n"
1770 . "├── job.json\n"
1771 . "├── README.md\n"
1772 . "└── assets/\n"
1773 . " └── hero.jpg";
1774 }
1775 return "antwort.zip\n"
1776 . "├── manifest.json\n"
1777 . "├── job.json\n"
1778 . "└── README.md";
1779 }
1780
1781 private function zipStructureUpdate(bool $heroChange): string {
1782 if ($heroChange) {
1783 return "antwort.zip\n"
1784 . "├── manifest.json\n"
1785 . "├── job.json\n"
1786 . "├── README.md\n"
1787 . "└── assets/\n"
1788 . " └── hero.jpg";
1789 }
1790 return "antwort.zip\n"
1791 . "├── manifest.json\n"
1792 . "├── job.json\n"
1793 . "└── README.md";
1794 }
1795
1796 private function assetsRulesCreate(bool $heroEnabled, string $heroBrief): string {
1797 if ($heroEnabled) {
1798 $brief = $heroBrief !== '' ? $heroBrief : 'passend zum Seitenthema';
1799 return "**Hero-Bild ist vorgesehen** (`briefing.hero.enabled = true`):\n\n"
1800 . "1. Lege **genau eine** Datei an: `assets/hero.jpg` (" . $this->heroImageSpecText() . ").\n"
1801 . "2. Motiv: " . $brief . "\n"
1802 . "3. In `job.json` den Step `hero` **nicht** aendern — `asset_ref` bleibt `hero.jpg`.\n"
1803 . "4. Hero-Medienordner bleibt `img/hero`.\n"
1804 . "5. **Kein** `data_base64` eintragen. **Keine** weiteren Dateien in `assets/`.\n"
1805 . "6. Alt-Text (`alt` im hero-Step) ausfuellen.";
1806 }
1807 return "**Kein Hero-Bild** (`briefing.hero.enabled = false`):\n\n"
1808 . "1. **Keinen** `assets/` Ordner in der Antwort-ZIP anlegen.\n"
1809 . "2. Keine Medien-Steps — `job.vorlage.json` enthaelt nur `page.create`.\n"
1810 . "3. Nicht ueber Bilder nachdenken; direkt Text/HTML in `content` schreiben.";
1811 }
1812
1813 private function assetsRulesUpdate(bool $heroChange, string $heroBrief): string {
1814 if ($heroChange) {
1815 $brief = $heroBrief !== '' ? $heroBrief : 'passend zur Seite';
1816 return "**Neues Hero-Bild** (`hero` in change_fields):\n\n"
1817 . "1. Lege `assets/hero.jpg` an (" . $this->heroImageSpecText() . "). Motiv: " . $brief . "\n"
1818 . "2. Fuer ein wirklich neues Hero-Bild: neue Medienverknuepfung in `img/hero` setzen.\n"
1819 . "3. Fuer eine reine Aenderung des bestehenden Hero-Bildes: nur die bestehende Hero-Datei ersetzen, keine neue Verknuepfung.\n"
1820 . "4. `asset_ref` bleibt `hero.jpg`. Kein `data_base64`. Keine weiteren Assets.";
1821 }
1822 return "**Kein Hero-Wechsel** (kein `hero` in change_fields):\n\n"
1823 . "1. **Keinen** `assets/` Ordner anlegen.\n"
1824 . "2. Nur `page.update` in `job.json` — keine Medien-Steps.";
1825 }
1826
1827 private function assetsReadmeHero(string $heroBrief): string {
1828 return "KI: Lege hier die Datei hero.jpg ab.\n\n"
1829 . "Pfad in der Antwort-ZIP: assets/hero.jpg\n"
1830 . "Groesse: " . $this->heroImageSpecText() . "\n"
1831 . "In job.json: asset_ref = hero.jpg (bereits in job.vorlage.json)\n"
1832 . "Motiv: " . ($heroBrief !== '' ? $heroBrief : 'passend zum Seitenthema') . "\n";
1833 }
1834
1835 private function renderTemplateFile(string $basename, array $vars): string {
1836 $path = dirname(__DIR__) . '/tpl/briefing/' . $basename;
1837 if (!is_file($path)) {
1838 return '';
1839 }
1840 $text = file_get_contents($path);
1841 if (!is_string($text)) {
1842 return '';
1843 }
1844 foreach ($vars as $key => $value) {
1845 $text = str_replace('{' . $key . '}', (string) $value, $text);
1846 }
1847 return $text;
1848 }
1849
1850 private function menschAnleitungCreate(string $title): string {
1851 return "# Anleitung fuer Menschen\n\n"
1852 . "## Schritt 1 — Formular (erledigt)\n\n"
1853 . "Sie haben den Auftrag **" . $title . "** spezifiziert.\n\n"
1854 . "## Schritt 2 — ZIP an die KI\n\n"
1855 . "1. Diese ZIP bei ChatGPT, DeepSeek o.ae. hochladen **oder**\n"
1856 . "2. Den Inhalt von `KI-AUFTRAG.md` kopieren und einfügen.\n\n"
1857 . "Sagen Sie der KI: *„Arbeite KI-AUFTRAG.md exakt ab. Assets-Regeln nicht abweichen. Liefere antwort.zip mit job.json.“*\n\n"
1858 . "## Schritt 3 — Antwort importieren\n\n"
1859 . "1. In dbXapp: **dbxKi → Bundle importieren**\n"
1860 . "2. Die ZIP der KI hochladen\n"
1861 . "3. Vorschau pruefen → **Ausfuehren**\n";
1862 }
1863
1864 private function menschAnleitungUpdate(): string {
1865 return "# Anleitung fuer Menschen\n\n"
1866 . "1. ZIP an die KI geben (siehe KI-AUFTRAG.md)\n"
1867 . "2. Fertige ZIP mit `job.json` zurueck erhalten\n"
1868 . "3. Unter dbxKi → Bundle importieren und ausfuehren\n";
1869 }
1870
1871 private function menschAnleitungTranslate(string $title, string $targetLng): string {
1872 return "# Anleitung fuer Menschen\n\n"
1873 . "## Schritt 1 — Formular (erledigt)\n\n"
1874 . "Uebersetzungsauftrag fuer **" . $title . "** nach **" . strtoupper($targetLng) . "**.\n\n"
1875 . "## Schritt 2 — ZIP an die KI\n\n"
1876 . "1. Diese ZIP bei ChatGPT, DeepSeek o.ae. hochladen **oder**\n"
1877 . "2. Den Inhalt von `KI-AUFTRAG.md` kopieren und einfügen.\n\n"
1878 . "Sagen Sie der KI: *„Arbeite KI-AUFTRAG.md exakt ab und liefere antwort.zip mit job.json.“*\n\n"
1879 . "## Schritt 3 — Antwort importieren\n\n"
1880 . "1. In dbXapp: **dbxKi → Bundle importieren**\n"
1881 . "2. Die ZIP der KI hochladen\n"
1882 . "3. Vorschau pruefen → **Ausfuehren**\n";
1883 }
1884
1885 private function buildLngOptions(string $selected): string {
1886 $lngs = $this->availableLngs();
1887 $html = '';
1888 foreach ($lngs as $lng) {
1889 $lng = strtolower(trim((string) $lng));
1890 if ($lng === '') {
1891 continue;
1892 }
1893 $sel = $lng === $selected ? ' selected' : '';
1894 $html .= '<option value="' . $this->esc($lng) . '"' . $sel . '>' . strtoupper($this->esc($lng)) . '</option>';
1895 }
1896 return $html;
1897 }
1898
1899 private function buildTargetLngCheckboxes(string $sourceLng, array $selected): string {
1900 $selected = $this->normalizeTargetLngs($selected, true);
1901 $html = '';
1902 foreach ($this->availableLngs() as $lng) {
1903 $lng = strtolower(trim((string) $lng));
1904 if ($lng === '') {
1905 continue;
1906 }
1907 $checked = in_array($lng, $selected, true) ? ' checked' : '';
1908 $isSource = $lng === $sourceLng;
1909 $id = 'dbxKiTargetLng_' . preg_replace('/[^a-z0-9_]/', '_', $lng);
1910 $html .= '<label class="dbx-ki-lng-choice' . ($isSource ? ' is-source' : '') . '" data-ki-target-lng="' . $this->esc($lng) . '">'
1911 . '<input type="checkbox" name="target_lngs[]" id="' . $this->esc($id) . '" value="' . $this->esc($lng) . '"' . $checked . '>'
1912 . '<span><strong>' . strtoupper($this->esc($lng)) . '</strong><small data-ki-target-mode>'
1913 . ($isSource ? 'Rechtschreibung/Grammatik' : 'Uebersetzung')
1914 . '</small></span>'
1915 . '</label>';
1916 }
1917 return $html;
1918 }
1919
1920 private function buildTargetLngOptions(string $sourceLng, string $selected): string {
1921 $lngs = $this->availableLngs();
1922 $html = '';
1923 foreach ($lngs as $lng) {
1924 $lng = strtolower(trim((string) $lng));
1925 if ($lng === '' || $lng === $sourceLng) {
1926 continue;
1927 }
1928 $sel = $lng === $selected ? ' selected' : '';
1929 $html .= '<option value="' . $this->esc($lng) . '"' . $sel . '>' . strtoupper($this->esc($lng)) . '</option>';
1930 }
1931 return $html;
1932 }
1933
1934 private function selectedTargetLngsFromRequest(string $sourceLng, bool $defaultAllOthers): array {
1935 $raw = dbx()->get_request_var('target_lngs', array(), '*');
1936 if (!is_array($raw)) {
1937 $raw = $raw !== '' ? array($raw) : array();
1938 }
1939 $fallback = strtolower(trim((string) dbx()->get_request_var('target_lng', '', '*')));
1940 if ($fallback !== '') {
1941 $raw[] = $fallback;
1942 }
1943 $selected = $this->normalizeTargetLngs($raw, true);
1944 if (!$selected && $defaultAllOthers) {
1945 foreach ($this->availableLngs() as $lng) {
1946 if ($lng !== $sourceLng) {
1947 $selected[] = $lng;
1948 }
1949 }
1950 }
1951 return array_values(array_unique($selected));
1952 }
1953
1954 private function normalizeTargetLngs(array $lngs, bool $allowSource): array {
1955 $allowed = array_fill_keys($this->availableLngs(), true);
1956 $out = array();
1957 foreach ($lngs as $lng) {
1958 $lng = strtolower(trim((string) $lng));
1959 if ($lng === '' || !isset($allowed[$lng])) {
1960 continue;
1961 }
1962 if (!$allowSource && $lng === strtolower(trim((string) dbxContentLng::current()))) {
1963 continue;
1964 }
1965 $out[$lng] = $lng;
1966 }
1967 return array_values($out);
1968 }
1969
1970 private function availableLngs(): array {
1971 $lngs = array();
1972 if (class_exists(dbxContentLngSync::class)) {
1973 $lngs = dbxContentLngSync::accessibleLngs();
1974 }
1975 if (!is_array($lngs) || !$lngs) {
1976 $lngs = array(dbxContentLng::current());
1977 }
1978 $out = array();
1979 foreach ($lngs as $lng) {
1980 $lng = strtolower(trim((string) $lng));
1981 if ($lng !== '') {
1982 $out[$lng] = $lng;
1983 }
1984 }
1985 return array_values($out);
1986 }
1987
1988 private function targetInstructionLabels(string $sourceLng, array $targets): array {
1989 $out = array();
1990 foreach ($targets as $targetLng) {
1991 $out[] = array(
1992 'lng' => $targetLng,
1993 'mode' => $targetLng === $sourceLng ? 'proofread' : 'translate',
1994 'label' => $targetLng === $sourceLng
1995 ? strtoupper($targetLng) . ': Rechtschreib- und Grammatikpruefung der Quellsprache'
1996 : strtoupper($sourceLng) . ' -> ' . strtoupper($targetLng) . ': Uebersetzung',
1997 );
1998 }
1999 return $out;
2000 }
2001
2002 private function targetInstructionsForPrompt(string $sourceLng, array $targets): string {
2003 $lines = array();
2004 foreach ($targets as $targetLng) {
2005 if ($targetLng === $sourceLng) {
2006 $lines[] = '- ' . strtoupper($targetLng) . ': Kein Sprachwechsel. Korrigiere Rechtschreibung, Grammatik, Zeichensetzung und offensichtliche Tippfehler. Inhalt, Sinn, HTML-Struktur, Medien und Modul-Aufrufe beibehalten. Nutze den `page.update`-Step `proofread_' . $targetLng . '`.';
2007 } else {
2008 $lines[] = '- ' . strtoupper($sourceLng) . ' -> ' . strtoupper($targetLng) . ': Vollstaendige Uebersetzung aller Felder. Nutze den `translation.apply`-Step `translation_' . $targetLng . '`.';
2009 }
2010 }
2011 return implode("\n", $lines);
2012 }
2013
2014 private function buildFolderOptions(string $lng, int $selected): string {
2015 $labels = $this->folderLabels($lng);
2016 $html = '<option value="">— Ordner waehlen —</option>';
2017 foreach ($labels as $id => $label) {
2018 $sel = ((int) $id === $selected) ? ' selected' : '';
2019 $html .= '<option value="' . (int) $id . '"' . $sel . '>' . $this->esc($label) . ' (#' . (int) $id . ')</option>';
2020 }
2021 return $html;
2022 }
2023
2024 private function buildPageOptions(string $lng, int $selected): string {
2025 $snap = $this->cms()->bundleSnapshot(array('lng' => $lng, 'limit' => 300));
2026 $folders = $this->folderLabels($lng);
2027 $rows = is_array($snap['pages']['rows'] ?? null) ? $snap['pages']['rows'] : array();
2028 $html = '<option value="">— Seite waehlen —</option>';
2029 foreach ($rows as $row) {
2030 if (!is_array($row)) {
2031 continue;
2032 }
2033 $id = (int) ($row['id'] ?? 0);
2034 if ($id <= 0) {
2035 continue;
2036 }
2037 $fid = (int) ($row['folder'] ?? 0);
2038 $folder = $folders[$fid] ?? ('Ordner #' . $fid);
2039 $title = trim((string) ($row['title'] ?? ''));
2040 $sel = ($id === $selected) ? ' selected' : '';
2041 $html .= '<option value="' . $id . '"' . $sel . '>' . $this->esc($title) . ' — ' . $this->esc($folder) . ' (#' . $id . ')</option>';
2042 }
2043 return $html;
2044 }
2045
2046 private function buildStyleOptions(string $selected): string {
2047 $html = '';
2048 foreach ($this->writingStyles() as $key => $meta) {
2049 $sel = ($key === $selected) ? ' selected' : '';
2050 $html .= '<option value="' . $this->esc($key) . '"' . $sel . '>' . $this->esc($meta['label'] ?? $key) . '</option>';
2051 }
2052 return $html;
2053 }
2054
2055 private function folderLabels(string $lng): array {
2056 $snap = $this->cms()->bundleSnapshot(array('lng' => $lng, 'limit' => 500));
2057 $rows = is_array($snap['folders']['rows'] ?? null) ? $snap['folders']['rows'] : array();
2058 $byId = array();
2059 foreach ($rows as $row) {
2060 if (!is_array($row)) {
2061 continue;
2062 }
2063 $byId[(int) ($row['id'] ?? 0)] = $row;
2064 }
2065 $labels = array();
2066 foreach ($byId as $id => $row) {
2067 $parts = array();
2068 $cur = (int) $id;
2069 $guard = 0;
2070 while ($cur > 0 && isset($byId[$cur]) && $guard++ < 25) {
2071 array_unshift($parts, (string) ($byId[$cur]['name'] ?? ''));
2072 $cur = (int) ($byId[$cur]['parent_id'] ?? 0);
2073 }
2074 $labels[$id] = implode(' / ', array_filter($parts));
2075 }
2076 asort($labels, SORT_NATURAL | SORT_FLAG_CASE);
2077 return $labels;
2078 }
2079
2080 private function folderLabel(string $lng, int $folderId): string {
2081 $labels = $this->folderLabels($lng);
2082 return (string) ($labels[$folderId] ?? ('Ordner #' . $folderId));
2083 }
2084
2085 private function sorterAfterPage(string $lng, int $pageId): string {
2086 $db = dbx()->get_system_obj('dbxDB');
2087 $dd = dbxContentLng::ddContent($lng);
2088 $page = $db->select1($dd, $pageId);
2089 if (!is_array($page)) {
2090 return '';
2091 }
2092 $folder = (int) ($page['folder'] ?? 0);
2093 $sorter = (int) ($page['sorter'] ?? 0);
2094 if ($folder <= 0) {
2095 return '';
2096 }
2097 $rows = $db->select($dd, 'folder = ' . $folder . ' AND sorter > ' . $sorter, 'sorter,id', 'sorter,id', 'ASC', '', 1, 0, 0);
2098 $nextSorter = is_array($rows) && isset($rows[0]) ? (int) ($rows[0]['sorter'] ?? 0) : 0;
2099 if ($nextSorter > ($sorter + 1)) {
2100 return sprintf('%04d', $sorter + 1);
2101 }
2102 return sprintf('%04d', $sorter);
2103 }
2104
2105 private function loadPage(string $lng, int $pageId): array {
2106 $db = dbx()->get_system_obj('dbxDB');
2107 $row = $db->select1(dbxContentLng::ddContent($lng), $pageId);
2108 if (!is_array($row)) {
2109 throw new \InvalidArgumentException('Seite nicht gefunden.');
2110 }
2111 return $row;
2112 }
2113
2114 private function pageContentExcerpt(string $lng, int $pageId): string {
2115 try {
2116 $row = $this->loadPage($lng, $pageId);
2117 return $this->truncate(strip_tags((string) ($row['content'] ?? '')), 2000);
2118 } catch (\Throwable $e) {
2119 return '';
2120 }
2121 }
2122
2123 private function truncate(string $text, int $max): string {
2124 $text = trim(preg_replace('/\s+/u', ' ', $text));
2125 if (mb_strlen($text) <= $max) {
2126 return $text;
2127 }
2128 return mb_substr($text, 0, $max) . '…';
2129 }
2130}
static parseFormRows(array $keys, array $labels, array $prompts)
$lngOptions
esc($value)
Escaped einen Wert fuer HTML-Text und HTML-Attribute.
Definition dbxApi.php:2310
sys_msg($status='', $about='', $rid='', $why='', $what='')
Schreibt eine strukturierte Systemmeldung.
Definition dbxApi.php:2047
foreach($rows as $row) $targets
if(!is_array($rows)) $byId
if(!defined( 'IMG_WEBP')) define( 'IMG_WEBP'
exit
Definition index.php:146
DBX schema administration.
if($cinematic===''||!str_contains($cinematic, 'data-dbx-cinema')) $page
for( $step=0;$step< 1000;$step++) if(($schema['status'] ?? '') !=='finished') $media