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';
23 private function contentTemplateDir():
string {
24 $dir =
dbx()->get_system_var(
'dbx_dir',
'') .
'/modules/dbxContent/tpl/htm/';
26 $dir = dirname(__DIR__, 2) .
'/dbxContent/tpl/htm/';
28 return rtrim(
$dir,
'/\\') . DIRECTORY_SEPARATOR;
31 private function listContentTemplates(): array {
32 $files = glob($this->contentTemplateDir() .
'c-*.htm');
34 if (is_array($files)) {
36 foreach ($files as $file) {
37 $out[] = basename($file,
'.htm');
40 return $out ?: array(
'c-content');
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';
49 if (!in_array($template, $allowed,
true)) {
50 return $heroEnabled ? self::CONTENT_TEMPLATE_DEFAULT :
'parent';
52 if (!$heroEnabled && strpos($template,
'hero') !==
false) {
58 private function analyzeTemplateSlots(
string $template): array {
59 if ($template ===
'' || $template ===
'parent') {
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);
72 $html = (string) file_get_contents($path);
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,
80 if (strpos($html,
'{cms:col3}') !==
false) {
82 } elseif (strpos($html,
'{cms:col2}') !==
false) {
88 private function buildContentTemplateOptions(
string $selected,
bool $heroEnabled):
string {
89 $selected = $this->sanitizeContentTemplate($selected, $heroEnabled);
92 $html .=
'<option value="parent"' . ($selected ===
'parent' ?
' selected' :
'') .
'>parent — vom Ordner</option>';
94 foreach ($this->listContentTemplates() as $name) {
95 if (!$heroEnabled && strpos($name,
'hero') !==
false) {
98 if ($heroEnabled && strpos($name,
'hero') ===
false && $name !==
'c-content') {
101 $sel = ($name === $selected) ?
' selected' :
'';
102 $html .=
'<option value="' . $this->esc($name) .
'"' . $sel .
'>' . $this->esc($name) .
'</option>';
107 private function contentTemplateForCreate(
bool $heroEnabled,
string $selected =
''):
string {
108 return $this->sanitizeContentTemplate($selected, $heroEnabled);
111 private function contentMarkerHr(
string $name):
string {
113 'hero' =>
'Hero-Text',
114 'header' =>
'Header',
115 'footer' =>
'Footer',
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 .
'">';
124 private function contentMarkersMeta(array $slots): array {
126 if (!empty($slots[
'hero_text'])) {
127 $markers[
'hero'] = $this->contentMarkerHr(
'hero');
129 if (!empty($slots[
'header'])) {
130 $markers[
'header'] = $this->contentMarkerHr(
'header');
132 if (!empty($slots[
'footer'])) {
133 $markers[
'footer'] = $this->contentMarkerHr(
'footer');
138 private function contentExampleHtml(array $slots,
string $heroTextHint =
''):
string {
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');
145 if (!empty($slots[
'header'])) {
146 $parts[] = $this->contentMarkerHr(
'header');
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>';
153 return implode(
'', $parts);
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);
162 '## Content-Template und Bereichs-Marker',
164 'Content-Template: `' . $template .
'`',
166 'Der Inhalt in `content` wird mit **`<hr>`-Markern** getrennt (Reihenfolge von oben nach unten):',
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 .
'*';
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">`';
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}`';
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.';
191 foreach ($markers as $name => $hr) {
192 $lines[] =
'**' . ucfirst($name) .
'-Marker:**';
193 $lines[] =
'```html';
198 $lines[] =
'**Beispiel `content` fuer dieses Template:**';
199 $lines[] =
'```html';
202 if ($withHeroImage) {
204 $lines[] =
'Hero-**Bild** kommt ueber dbxKi-Medienschritte — nicht ins HTML. Neue Hero-Bilder liegen verbindlich in `img/hero`.';
206 return implode(
"\n", $lines);
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>`';
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) .
'.';
222 private function allowedBootstrapComponents(): array {
225 'label' =>
'Hinweis',
226 'classes' =>
'alert alert-info / alert-warning / alert-success',
227 'use' =>
'Kurze Hinweis-, Info- oder Erfolgsbox.',
231 'classes' =>
'card, card-body, row, row-cols-*, g-*',
232 'use' =>
'Teaser, Leistungsboxen oder Paket-/Feature-Kacheln.',
234 'list_group' => array(
235 'label' =>
'Listenbox',
236 'classes' =>
'list-group, list-group-item',
237 'use' =>
'Kompakte Nutzen-, Schritt- oder Funktionslisten.',
241 'classes' =>
'badge text-bg-*',
242 'use' =>
'Status, Kategorien, kleine Hervorhebungen.',
245 'label' =>
'Buttons',
246 'classes' =>
'btn btn-primary / btn-outline-primary',
247 'use' =>
'CTA-Links ohne eigenes JavaScript.',
250 'label' =>
'Tabelle',
251 'classes' =>
'table table-striped table-hover',
252 'use' =>
'Vergleichs- oder Preis-/Datenuebersichten.',
254 'accordion' => array(
255 'label' =>
'Akkordeon',
256 'classes' =>
'accordion, accordion-item, accordion-button',
257 'use' =>
'FAQ oder aufklappbare Detailbereiche.',
261 'classes' =>
'nav nav-tabs, tab-content, tab-pane',
262 'use' =>
'Alternative Sichten auf denselben Inhalt.',
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);
272 $allowed = $this->allowedBootstrapComponents();
274 foreach ($raw as $key) {
275 $key = strtolower(trim((
string) $key));
276 if (isset($allowed[$key]) && !in_array($key, $out,
true)) {
283 private function buildBootstrapComponentChoices(array $selected):
string {
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>';
293 private function bootstrapComponentsMeta(array $selected): array {
294 $allowed = $this->allowedBootstrapComponents();
296 foreach ($selected as $key) {
297 if (isset($allowed[$key])) {
298 $out[$key] = $allowed[$key];
304 private function bootstrapComponentsGuide(array $selected):
string {
305 $meta = $this->bootstrapComponentsMeta($selected);
307 return "Keine Bootstrap-Komponenten im Content verwenden. Erlaubt sind nur normales Jodit-HTML wie h2, h3, p, ul/ol, Links und einfache Textstruktur.";
310 'Die KI darf im Content nur diese ausgewaehlten Bootstrap-5-Komponenten verwenden:',
313 foreach ($meta as $key => $row) {
314 $lines[] =
'- `' . $key .
'` (' . ($row[
'label'] ?? $key) .
'): ' . ($row[
'use'] ??
'') .
' Klassen: `' . ($row[
'classes'] ??
'') .
'`.';
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);
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;
327 private function heroImageBriefingMeta(): array {
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,
340 private function ensureContentBootstrap():
void {
341 if (!class_exists(dbxContentLng::class)) {
342 require_once dirname(__DIR__, 2) .
'/dbxContent/include/dbxContent_bootstrap_sync.php';
347 return dbx()->get_include_obj(
'dbxKiCmsService',
'dbxKi');
351 return dbx()->get_include_obj(
'dbxKiHelp',
'dbxKi');
355 return dbx()->get_include_obj(
'dbxKiBundleService',
'dbxKi');
358 private function tpl() {
359 return dbx()->get_system_obj(
'dbxTPL');
362 private function esc($value):
string {
363 return htmlspecialchars((
string) $value, ENT_QUOTES,
'UTF-8');
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 ===
'') {
372 $url .=
'&' . rawurlencode((
string) $key) .
'=' . rawurlencode((
string) $value);
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 ===
'') {
383 $url .=
'&' . rawurlencode((
string) $key) .
'=' . rawurlencode((
string) $value);
388 private function withContentLng(
string $lng, callable $fn) {
389 $lng = strtolower(trim($lng));
390 $prev = (string)
dbx()->get_system_var(
'dbx_lng',
'');
392 dbx()->set_system_var(
'dbx_lng', $lng);
397 dbx()->set_system_var(
'dbx_lng', $prev);
401 private function withModuleBar(array $data,
string $screen,
string $actionsHtml =
''): array {
402 return array_merge($data, $this->help()->moduleBarTemplateData($screen, $actionsHtml));
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>';
409 private function briefingWorkflowData(
string $returnRun1): 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),
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',
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',
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',
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',
459 return $this->moduleUrl(
'briefing_page_update');
460 case 'page_translate':
461 return $this->moduleUrl(
'briefing_page_translate');
463 return $this->moduleUrl(
'briefing_page_create');
468 $this->ensureContentBootstrap();
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>';
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>'));
491 $this->ensureContentBootstrap();
492 $lng = strtolower(trim((
string)
dbx()->get_request_var(
'lng',
'',
'*')));
494 $lng = strtolower(trim((
string)
dbx()->get_request_var(
'dbx_lng', dbxContentLng::current(),
'*')));
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) {
502 $afterPage = $this->loadPage($lng, $sorterAfterPageId);
503 $folderId = (int) ($afterPage[
'folder'] ?? $folderId);
504 }
catch (\Throwable $e) {
505 $sorterAfterPageId = 0;
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';
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),
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()));
541 $this->ensureContentBootstrap();
542 $lng = dbxContentLng::current();
543 $pageId = (int)
dbx()->get_request_var(
'page_id', 0,
'int');
545 $styleOptions = $this->buildStyleOptions((
string)
dbx()->get_request_var(
'writing_style',
'sachlich',
'*'));
546 $selectedBootstrapComponents = $this->selectedBootstrapComponentsFromRequest();
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>';
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) {
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),
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()));
590 $this->ensureContentBootstrap();
592 $lng = strtolower(trim((
string)
dbx()->get_request_var(
'dbx_lng', dbxContentLng::current(),
'*')));
593 $pageId = (int)
dbx()->get_request_var(
'id', 0,
'int');
595 dbx()->json_response(array(
'ok' => 0,
'error' =>
'Keine Seite gewaehlt.'),
true);
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);
604 dbx()->json_response(array(
607 'title' => (
string) ($page[
'title'] ??
''),
609 'context_html' => $this->renderPageContextHtml($lng, $page),
611 }
catch (\Throwable $e) {
612 dbx()->json_response(array(
'ok' => 0,
'error' => $e->getMessage()),
true);
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(),
'*')));
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';
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);
635 $currentContext = $this->renderPageContextHtml($sourceLng, $source);
636 }
catch (\Throwable $e) {
637 $currentRendered =
'<div class="dbx-cms-empty">Quellseite konnte nicht geladen werden.</div>';
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()));
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>'
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>'
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()));
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(),
'*')
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());
694 return $this->renderStylesAdmin();
699 dbx()->sys_msg(
'info',
'dbxKi',
'styles',
'Schreibstile',
'Standard wiederhergestellt');
704 $this->ensureContentBootstrap();
705 if (!class_exists(
'ZipArchive')) {
706 dbx()->json_response(array(
'ok' => 0,
'error' =>
'ZipArchive nicht verfuegbar.'),
true);
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());
717 dbx()->json_response(array(
'ok' => 0,
'error' =>
'Unbekanntes Rezept: ' . $recipe),
true);
721 $this->sendZipDownload($package);
724 private function collectPageCreateInput(): array {
725 $lng = strtolower(trim((string)
dbx()->get_request_var(
'lng', dbxContentLng::current(),
'*')));
727 'recipe' =>
'page_create',
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',
'',
'*')),
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';
753 if (
dbx()->get_request_var(
'change_title',
'0',
'*') ===
'1') {
754 $changeFields[] =
'title';
756 if (
dbx()->get_request_var(
'change_description',
'0',
'*') ===
'1') {
757 $changeFields[] =
'description';
759 if (
dbx()->get_request_var(
'change_hero',
'0',
'*') ===
'1') {
760 $changeFields[] =
'hero';
762 if (!$changeFields) {
763 $changeFields = array(
'content');
767 'recipe' =>
'page_update',
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(),
781 private function collectPageTranslateInput(): array {
782 $sourceLng = strtolower(trim((string)
dbx()->get_request_var(
'source_lng', dbxContentLng::current(),
'*')));
783 $targetLngs = $this->selectedTargetLngsFromRequest($sourceLng,
false);
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',
796 private function buildPageCreatePackage(array $in): array {
797 if ($in[
'folder_id'] <= 0) {
798 throw new \InvalidArgumentException(
'Bitte einen Zielordner waehlen.');
800 if ($in[
'title'] ===
'') {
801 throw new \InvalidArgumentException(
'Titel ist erforderlich.');
803 if ($in[
'content_brief'] ===
'') {
804 throw new \InvalidArgumentException(
'Beschreiben Sie, worum es im Text gehen soll.');
807 $sorterAfterPage = array();
808 $sorterAfterLabel =
'';
810 if ($in[
'sorter_after_page_id'] > 0) {
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;
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';
827 $stylePrompt = (string) ($styles[$styleKey][
'prompt'] ??
'');
829 $permalink = $in[
'permalink'];
830 if ($permalink ===
'') {
831 $permalink =
'(automatisch aus Titel)';
834 $contentTemplate = $this->contentTemplateForCreate($in[
'hero_enabled'], $in[
'content_template'] ??
'');
835 $templateSlots = $this->analyzeTemplateSlots($contentTemplate);
838 'briefing_version' => self::BRIEFING_VERSION,
839 'recipe' =>
'page.create.v1',
840 'task' =>
'page_create',
841 'created_at' => date(
'c'),
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']),
856 'enabled' => $in[
'hero_enabled'],
857 'brief' => $in[
'hero_brief'],
858 'image' => $this->heroImageBriefingMeta(),
860 'default' => self::HERO_DEFAULT_HEIGHT,
861 'rule' =>
'In page.create hero_height=' . self::HERO_DEFAULT_HEIGHT .
' setzen, wenn nichts anderes angegeben ist.',
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.',
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'],
876 $jobVorlage = $this->jobVorlagePageCreate($in);
878 'bundle_version' => self::BRIEFING_VERSION,
879 'title' => $in[
'title'],
880 'recipe' =>
'page.create.v1',
882 'intent' =>
'create',
884 'auto_execute' =>
true,
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,
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']),
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()),
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,
930 if ($in[
'hero_enabled']) {
931 $files[
'assets/README.txt'] = $this->assetsReadmeHero($in[
'hero_brief']);
935 'filename' =>
'dbxki-auftrag-neue-seite-' . preg_replace(
'/[^a-z0-9_-]+/i',
'-', $in[
'title']) .
'.zip',
940 private function buildPageUpdatePackage(array $in): array {
941 if ($in[
'page_id'] <= 0) {
942 throw new \InvalidArgumentException(
'Bitte eine Seite waehlen.');
944 if ($in[
'change_brief'] ===
'') {
945 throw new \InvalidArgumentException(
'Beschreiben Sie die gewuenschte Aenderung.');
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';
954 $pageTemplate = trim((
string) ($page[
'template'] ??
'parent'));
955 if ($pageTemplate ===
'' || $pageTemplate ===
'parent') {
956 $pageTemplate =
'c-body1-footer';
958 $pageContext = $this->pageContextForKi($in[
'lng'], $page);
959 $embeddedPolicy = $this->embeddedPolicyText($in[
'embedded_policy'], $in[
'embedded_change_notes']);
962 'briefing_version' => self::BRIEFING_VERSION,
963 'recipe' =>
'page.update.v1',
964 'task' =>
'page_update',
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',
980 'brief' => $in[
'hero_brief'],
981 'image' => $this->heroImageBriefingMeta(),
983 'custom_notes' => $in[
'custom_notes'],
986 $jobVorlage = $this->jobVorlagePageUpdate($in, $page);
988 'bundle_version' => self::BRIEFING_VERSION,
989 'title' =>
'Update: ' . ($page[
'title'] ??
''),
990 'recipe' =>
'page.update.v1',
992 'intent' =>
'update',
994 'auto_execute' =>
true,
999 'current_page' => $this->slimPageForUpdate($page),
1000 'current_page_context' => $pageContext,
1003 $excerpt = $this->truncate((
string) ($page[
'content'] ??
''), 4000);
1005 $heroChange = in_array(
'hero', $in[
'change_fields'],
true);
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.)',
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()),
1045 private function buildPageTranslatePackage(array $in): array {
1046 if ($in[
'source_id'] <= 0) {
1047 throw new \InvalidArgumentException(
'Bitte eine Quellseite waehlen.');
1049 $targets = $this->normalizeTargetLngs((array) ($in[
'target_lngs'] ?? array()),
true);
1051 throw new \InvalidArgumentException(
'Bitte mindestens eine Ziel- oder Korrektursprache waehlen.');
1053 $in[
'target_lngs'] = $targets;
1054 $in[
'target_lng'] = (string) ($targets[0] ??
'');
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';
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'];
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'],
1086 $jobVorlage = $this->jobVorlagePageTranslate($in);
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'),
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,
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),
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,
1137 private function jobVorlagePageCreate(array $in): array {
1139 if ($in[
'hero_enabled']) {
1142 'action' =>
'media.create_base64',
1144 'file_name' =>
'hero.jpg',
1145 'asset_ref' =>
'hero.jpg',
1146 'media_folder' =>
'img/hero',
1147 'title' => $in[
'title'],
1148 'alt' =>
'___KI_FUELLEN___',
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___',
1161 if ($in[
'hero_enabled']) {
1162 $pageParams[
'hero_height'] = self::HERO_DEFAULT_HEIGHT;
1164 if (!empty($in[
'sorter'])) {
1165 $pageParams[
'sorter'] = (string) $in[
'sorter'];
1167 if ($in[
'permalink'] !==
'') {
1168 $pageParams[
'permalink'] = $in[
'permalink'];
1170 if ($in[
'description'] !==
'') {
1171 $pageParams[
'description'] = $in[
'description'];
1173 if ($in[
'keywords'] !==
'') {
1174 $pageParams[
'keywords'] = $in[
'keywords'];
1179 'action' =>
'page.create',
1180 'params' => $pageParams,
1183 if ($in[
'hero_enabled']) {
1185 'id' =>
'hero_assign',
1186 'action' =>
'media.assign',
1188 'media_id' =>
'$ref:hero.media_id',
1189 'content_id' =>
'$ref:page.page_id',
1191 'lng' => $in[
'lng'],
1196 return array(
'steps' => $steps);
1199 private function jobVorlagePageUpdate(array $in, array $page): array {
1201 if (in_array(
'content', $in[
'change_fields'],
true)) {
1202 $patch[
'content'] =
'___KI_FUELLEN___';
1204 if (in_array(
'title', $in[
'change_fields'],
true)) {
1205 $patch[
'title'] =
'___KI_FUELLEN___';
1207 if (in_array(
'description', $in[
'change_fields'],
true)) {
1208 $patch[
'description'] =
'___KI_FUELLEN___';
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;
1218 if (in_array(
'hero', $in[
'change_fields'],
true)) {
1221 'action' =>
'media.create_base64',
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___',
1231 'id' =>
'hero_assign',
1232 'action' =>
'media.assign',
1234 'media_id' =>
'$ref:hero.media_id',
1235 'content_id' => (
int) $in[
'page_id'],
1237 'lng' => $in[
'lng'],
1244 'action' =>
'page.update',
1246 'lng' => $in[
'lng'],
1247 'id' => (
int) $in[
'page_id'],
1252 return array(
'steps' => $steps);
1255 private function jobVorlagePageTranslate(array $in): array {
1257 foreach ((array) ($in[
'target_lngs'] ?? array($in[
'target_lng'])) as $targetLng) {
1258 $targetLng = strtolower(trim((
string) $targetLng));
1259 if ($targetLng ===
'') {
1262 if ($targetLng === $in[
'source_lng']) {
1264 'id' =>
'proofread_' . $targetLng,
1265 'action' =>
'page.update',
1267 'lng' => $in[
'source_lng'],
1268 'id' => (
int) $in[
'source_id'],
1270 'title' =>
'___KI_FUELLEN___',
1271 'description' =>
'___KI_FUELLEN___',
1272 'keywords' =>
'___KI_FUELLEN___',
1273 'content' =>
'___KI_FUELLEN___',
1280 'id' =>
'translation_' . $targetLng,
1281 'action' =>
'translation.apply',
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___',
1301 private function packBriefingFiles(array $in): array {
1302 $recipe = (string) ($in[
'recipe'] ??
'');
1303 $heroAssets = !empty($in[
'hero_assets']);
1305 '00-START.md' => $this->buildStartMd(
1307 (
string) ($in[
'task_label'] ??
''),
1309 (
string) ($in[
'context_hint'] ??
''),
1310 (
string) ($in[
'content_template'] ??
'')
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'] ??
'')),
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';
1325 $zipExtra = $heroAssets
1326 ?
"- `assets/hero.jpg` (" . $this->heroImageSpecText() .
")\n"
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),
1341 private function bundleRulesForRecipe(
string $recipe,
bool $withHero =
false,
string $contentTemplate =
''): array {
1344 case 'page.create.v1':
1345 $actions = $withHero
1346 ? array(
'media.create_base64',
'page.create',
'media.assign')
1347 : array(
'page.create');
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');
1354 case 'translation.v1':
1355 $actions = array(
'translation.apply',
'page.update');
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),
1368 private function contentRulesForBriefing(
string $contentTemplate,
bool $withHero): array {
1369 if ($contentTemplate ===
'') {
1370 $contentTemplate = $withHero ? self::CONTENT_TEMPLATE_DEFAULT :
'parent';
1372 $slots = $this->analyzeTemplateSlots($contentTemplate);
1373 $markers = $this->contentMarkersMeta($slots);
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})',
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.',
1394 $rules[
'template'] = $contentTemplate;
1395 $rules[
'template_slots'] = $slots;
1399 private function sanitizeEmbeddedPolicy(
string $policy): string {
1400 $policy = strtolower(trim($policy));
1401 return in_array($policy, array(
'preserve',
'reorder',
'remove'),
true) ? $policy :
'preserve';
1404 private function embeddedPolicyText(
string $policy,
string $notes): string {
1405 $policy = $this->sanitizeEmbeddedPolicy($policy);
1406 $notes = trim($notes);
1408 'Standard: Bestehende eingebettete Medien, Videos und `[modul=...]...[/modul]`-Aufrufe exakt beibehalten.',
1409 'Keine bestehenden Medienpfade manuell umschreiben; dbxKi/CMS-Befehle loesen Speicherpfade automatisch.',
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.';
1416 $lines[] =
'Nicht erlaubt: Medien/Module entfernen, ersetzen oder neu anordnen.';
1418 if ($notes !==
'') {
1419 $lines[] =
'Konkrete Anweisung zu Medien/Modulen: ' . $notes;
1421 return implode(
"\n", $lines);
1424 private function moduleCallsFromContent(
string $content): 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) {
1429 'index' => $idx + 1,
1430 'modul' => (
string) ($match[1] ??
''),
1431 'params' => trim((
string) ($match[2] ??
'')),
1432 'marker' => (
string) ($match[0] ??
''),
1439 private function inlineMediaIdsFromContent(
string $content): 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;
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;
1451 return array_values(array_filter($ids));
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();
1461 foreach ($usageRows as $usage) {
1462 $id = (int) ($usage[
'media_id'] ?? 0);
1464 $mediaIds[$id] = $id;
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);
1475 $usage = array_values(array_filter($usageRows,
function ($row) use ($id) {
1476 return (
int) ($row[
'media_id'] ?? 0) === (
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'] ??
'');
1487 'inline_reference' => in_array((
int) $id, $this->inlineMediaIdsFromContent($content),
true) ? 1 : 0,
1493 private function renderedTextForPage(
string $lng,
int $pageId): string {
1495 $renderer =
dbx()->get_include_obj(
'dbxContentRenderer',
'dbxContent');
1496 $html = $this->withContentLng($lng,
function () use ($renderer, $pageId) {
1497 return $renderer->render($pageId);
1499 return $this->truncate(strip_tags((
string) $html), 12000);
1500 }
catch (\Throwable $e) {
1505 private function pageContextForKi(
string $lng, array $page): array {
1506 $pageId = (int) ($page[
'id'] ?? 0);
1507 $content = (string) ($page[
'content'] ??
'');
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),
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.',
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();
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 .
']' :
'');
1533 $lines[] =
'Modul-Aufrufe: ' . count($modules);
1534 foreach ($modules as $call) {
1535 $lines[] =
'- [modul=' . (string) ($call[
'modul'] ??
'') .
']' . (string) ($call[
'params'] ??
'') .
'[/modul]';
1537 return implode(
"\n", $lines);
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();
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>' :
'')
1553 if ($mediaHtml ===
'') {
1554 $mediaHtml =
'<li class="text-muted">Keine eingebetteten Medien erkannt.</li>';
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>';
1562 if ($moduleHtml ===
'') {
1563 $moduleHtml =
'<li class="text-muted">Keine Modul-Aufrufe erkannt.</li>';
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,
1579 private function createPlacementTitle(
string $lng,
int $folderId,
int $afterPageId): string {
1580 $folder = $folderId > 0 ? $this->folderLabel($lng, $folderId) :
'';
1581 if ($afterPageId > 0) {
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';
1589 return $folder !==
'' ? $folder .
' / am Ende' :
'Zielposition waehlen';
1592 private function renderCreatePlacementHtml(
string $lng,
int $folderId,
int $afterPageId): string {
1593 $folderLabel = $folderId > 0 ? $this->folderLabel($lng, $folderId) :
'';
1596 if ($afterPageId > 0) {
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) {
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'),
1616 private function slimPageForUpdate(array $page): 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'] ??
''),
1627 private function slimPageForTranslation(array $page): 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'] ??
''),
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) {
1643 throw new \RuntimeException(
'ZIP konnte nicht erstellt werden.');
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);
1650 $zip->addFromString($path, (
string) $content);
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));
1663 private function writingStyles(): array {
1664 return dbxKiWritingStyles::all();
1667 private function zipStructureCreate(
bool $heroEnabled): string {
1669 return "antwort.zip\n"
1670 .
"├── manifest.json\n"
1676 return "antwort.zip\n"
1677 .
"├── manifest.json\n"
1682 private function zipStructureUpdate(
bool $heroChange): string {
1684 return "antwort.zip\n"
1685 .
"├── manifest.json\n"
1691 return "antwort.zip\n"
1692 .
"├── manifest.json\n"
1697 private function assetsRulesCreate(
bool $heroEnabled,
string $heroBrief): string {
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.";
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.";
1714 private function assetsRulesUpdate(
bool $heroChange,
string $heroBrief): string {
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.";
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.";
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";
1736 private function renderTemplateFile(
string $basename, array $vars): string {
1737 $path = dirname(__DIR__) .
'/tpl/briefing/' . $basename;
1738 if (!is_file($path)) {
1741 $text = file_get_contents($path);
1742 if (!is_string($text)) {
1745 foreach ($vars as $key => $value) {
1746 $text = str_replace(
'{' . $key .
'}', (
string) $value, $text);
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";
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";
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";
1786 private function buildLngOptions(
string $selected): string {
1787 $lngs = $this->availableLngs();
1789 foreach ($lngs as $lng) {
1790 $lng = strtolower(trim((
string) $lng));
1794 $sel = $lng === $selected ?
' selected' :
'';
1795 $html .=
'<option value="' . $this->esc($lng) .
'"' . $sel .
'>' . strtoupper($this->esc($lng)) .
'</option>';
1800 private function buildTargetLngCheckboxes(
string $sourceLng, array $selected): string {
1801 $selected = $this->normalizeTargetLngs($selected, true);
1803 foreach ($this->availableLngs() as $lng) {
1804 $lng = strtolower(trim((
string) $lng));
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')
1821 private function buildTargetLngOptions(
string $sourceLng,
string $selected): string {
1822 $lngs = $this->availableLngs();
1824 foreach ($lngs as $lng) {
1825 $lng = strtolower(trim((
string) $lng));
1826 if ($lng ===
'' || $lng === $sourceLng) {
1829 $sel = $lng === $selected ?
' selected' :
'';
1830 $html .=
'<option value="' . $this->esc($lng) .
'"' . $sel .
'>' . strtoupper($this->esc($lng)) .
'</option>';
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();
1840 $fallback = strtolower(trim((
string)
dbx()->get_request_var(
'target_lng',
'',
'*')));
1841 if ($fallback !==
'') {
1844 $selected = $this->normalizeTargetLngs($raw,
true);
1845 if (!$selected && $defaultAllOthers) {
1846 foreach ($this->availableLngs() as $lng) {
1847 if ($lng !== $sourceLng) {
1852 return array_values(array_unique($selected));
1855 private function normalizeTargetLngs(array $lngs,
bool $allowSource): array {
1856 $allowed = array_fill_keys($this->availableLngs(), true);
1858 foreach ($lngs as $lng) {
1859 $lng = strtolower(trim((
string) $lng));
1860 if ($lng ===
'' || !isset($allowed[$lng])) {
1863 if (!$allowSource && $lng === strtolower(trim((
string) dbxContentLng::current()))) {
1868 return array_values($out);
1871 private function availableLngs(): array {
1873 if (class_exists(dbxContentLngSync::class)) {
1874 $lngs = dbxContentLngSync::accessibleLngs();
1876 if (!is_array($lngs) || !$lngs) {
1877 $lngs = array(dbxContentLng::current());
1880 foreach ($lngs as $lng) {
1881 $lng = strtolower(trim((
string) $lng));
1886 return array_values($out);
1889 private function targetInstructionLabels(
string $sourceLng, array $targets): array {
1891 foreach ($targets as $targetLng) {
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',
1903 private function targetInstructionsForPrompt(
string $sourceLng, array $targets): string {
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 .
'`.';
1909 $lines[] =
'- ' . strtoupper($sourceLng) .
' -> ' . strtoupper($targetLng) .
': Vollstaendige Uebersetzung aller Felder. Nutze den `translation.apply`-Step `translation_' . $targetLng .
'`.';
1912 return implode(
"\n", $lines);
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>';
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)) {
1934 $id = (int) ($row[
'id'] ?? 0);
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>';
1947 private function buildStyleOptions(
string $selected): string {
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>';
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();
1960 foreach ($rows as $row) {
1961 if (!is_array($row)) {
1964 $byId[(int) ($row[
'id'] ?? 0)] = $row;
1967 foreach ($byId as $id => $row) {
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);
1975 $labels[$id] = implode(
' / ', array_filter($parts));
1977 asort($labels, SORT_NATURAL | SORT_FLAG_CASE);
1981 private function folderLabel(
string $lng,
int $folderId): string {
1982 $labels = $this->folderLabels($lng);
1983 return (
string) ($labels[$folderId] ?? (
'Ordner #' . $folderId));
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)) {
1993 $folder = (int) ($page[
'folder'] ?? 0);
1994 $sorter = (int) ($page[
'sorter'] ?? 0);
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);
2003 return sprintf(
'%04d', $sorter);
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.');
2015 private function pageContentExcerpt(
string $lng,
int $pageId): string {
2017 $row = $this->loadPage($lng, $pageId);
2018 return $this->truncate(strip_tags((
string) ($row[
'content'] ??
'')), 2000);
2019 }
catch (\Throwable $e) {
2024 private function truncate(
string $text,
int $max): string {
2025 $text = trim(preg_replace(
'/\s+/u',
' ', $text));
2026 if (mb_strlen($text) <= $max) {
2029 return mb_substr($text, 0, $max) .
'…';