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