13 private const BRIEFING_VERSION =
'1.3';
14 private const HERO_MAX_WIDTH = 1280;
15 private const HERO_MAX_HEIGHT = 400;
16 private const HERO_OPTIMAL_WIDTH = 1280;
17 private const HERO_OPTIMAL_HEIGHT = 300;
18 private const HERO_DEFAULT_HEIGHT =
'300px';
19 private const HERO_DEFAULT_IMAGE_WIDTH = 1280;
20 private const HERO_DEFAULT_IMAGE_HEIGHT = 300;
21 private const HERO_TEXT_MAX_LINES = 3;
22 private const CONTENT_TEMPLATE_DEFAULT =
'c-title-hero_header-body1-footer';
24 private function contentTemplateDir():
string {
25 $dir =
dbx()->get_system_var(
'dbx_dir',
'') .
'/modules/dbxContent/tpl/htm/';
27 $dir = dirname(__DIR__, 2) .
'/dbxContent/tpl/htm/';
29 return rtrim(
$dir,
'/\\') . DIRECTORY_SEPARATOR;
32 private function listContentTemplates(): array {
33 $files = glob($this->contentTemplateDir() .
'c-*.htm');
41 return $out ?: array(
'c-content');
44 private function sanitizeContentTemplate(
string $template,
bool $heroEnabled):
string {
46 $allowed = $this->listContentTemplates();
48 return $heroEnabled ? self::CONTENT_TEMPLATE_DEFAULT :
'parent';
51 return $heroEnabled ? self::CONTENT_TEMPLATE_DEFAULT :
'parent';
53 if (!$heroEnabled && strpos(
$template,
'hero') !==
false) {
59 private function analyzeTemplateSlots(
string $template): array {
69 $path = $this->contentTemplateDir() .
$template .
'.htm';
70 if (!is_file($path)) {
71 return array(
'hero_text' =>
true,
'header' =>
true,
'footer' =>
true,
'cols' => 1,
'gallery' =>
false);
73 $html = (string) file_get_contents($path);
75 'hero_text' => strpos(
$html,
'{cms:hero_text}') !==
false,
76 'header' => strpos(
$html,
'{cms:header}') !==
false,
77 'footer' => strpos(
$html,
'{cms:footer}') !==
false,
78 'gallery' => strpos(
$html,
'{cms:gallery}') !==
false,
81 if (strpos(
$html,
'{cms:col3}') !==
false) {
83 } elseif (strpos(
$html,
'{cms:col2}') !==
false) {
89 private function buildContentTemplateOptions(
string $selected,
bool $heroEnabled):
string {
90 $selected = $this->sanitizeContentTemplate($selected, $heroEnabled);
93 $html .=
'<option value="parent"' . ($selected ===
'parent' ?
' selected' :
'') .
'>parent — vom Ordner</option>';
95 foreach ($this->listContentTemplates() as $name) {
96 if (!$heroEnabled && strpos($name,
'hero') !==
false) {
99 if ($heroEnabled && strpos($name,
'hero') ===
false && $name !==
'c-content') {
102 $sel = ($name === $selected) ?
' selected' :
'';
103 $html .=
'<option value="' . $this->esc($name) .
'"' . $sel .
'>' . $this->esc($name) .
'</option>';
108 private function contentTemplateForCreate(
bool $heroEnabled,
string $selected =
''):
string {
109 return $this->sanitizeContentTemplate($selected, $heroEnabled);
112 private function contentMarkerHr(
string $name):
string {
114 'hero' =>
'Hero-Text',
115 'header' =>
'Header',
116 'footer' =>
'Footer',
118 $name = strtolower(trim($name));
119 $label = $labels[$name] ?? $name;
120 return '<hr class="dbx-cms-marker dbx-cms-marker-' . $name
121 .
'" contenteditable="false" data-dbx-marker="dbx:' . $name
122 .
'" data-label="' . $label .
'">';
125 private function contentMarkersMeta(array
$slots): array {
127 if (!empty(
$slots[
'hero_text'])) {
128 $markers[
'hero'] = $this->contentMarkerHr(
'hero');
130 if (!empty(
$slots[
'header'])) {
131 $markers[
'header'] = $this->contentMarkerHr(
'header');
133 if (!empty(
$slots[
'footer'])) {
134 $markers[
'footer'] = $this->contentMarkerHr(
'footer');
139 private function contentExampleHtml(array
$slots,
string $heroTextHint =
''):
string {
141 if (!empty(
$slots[
'hero_text'])) {
142 $lead = $heroTextHint !==
'' ? $heroTextHint :
'Kurzer Hero-Text';
143 $parts[] =
'<p class="lead">' . $lead .
'</p>';
144 $parts[] = $this->contentMarkerHr(
'hero');
146 if (!empty(
$slots[
'header'])) {
147 $parts[] = $this->contentMarkerHr(
'header');
149 $parts[] =
'<h2>Ueberschrift</h2><p>Haupttext...</p>';
150 if (!empty(
$slots[
'footer'])) {
151 $parts[] = $this->contentMarkerHr(
'footer');
152 $parts[] =
'<p><small>Optionale Fusszeile</small></p>';
154 return implode(
'', $parts);
157 private function contentMarkersGuide(
string $template,
bool $withHeroImage,
string $heroTextBrief =
''):
string {
159 $markers = $this->contentMarkersMeta(
$slots);
160 $example = $this->contentExampleHtml(
$slots, $heroTextBrief);
163 '## Content-Template und Bereichs-Marker',
167 'Der Inhalt in `content` wird mit **`<hr>`-Markern** getrennt (Reihenfolge von oben nach unten):',
171 if (!empty(
$slots[
'hero_text'])) {
172 $lines[] =
'1. Text **vor** Hero-Marker → Slot `{cms:hero_text}` (Text im Hero-Bereich neben/unter dem Bild)';
173 $lines[] =
' Standard: maximal ' . self::HERO_TEXT_MAX_LINES .
' Zeilen Hero-Text, wenn nicht anders angegeben.';
174 $lines[] =
'2. `<hr data-dbx-marker="dbx:hero">`';
175 if ($heroTextBrief !==
'') {
176 $lines[] =
' Hero-Text laut Auftrag: *' . $heroTextBrief .
'*';
179 if (!empty(
$slots[
'header'])) {
180 $lines[] =
'- Text zwischen Hero- und Header-Marker → `{cms:header}` (eigener Block zwischen Hero und Body)';
181 $lines[] =
'- `<hr data-dbx-marker="dbx:header">`';
183 $lines[] =
'- Text bis Footer-Marker → Body (`{cms:col1}`' . ((int)(
$slots[
'cols'] ?? 1) > 1 ?
' / Spalten' :
'') .
')';
184 if (!empty(
$slots[
'footer'])) {
185 $lines[] =
'- `<hr data-dbx-marker="dbx:footer">`';
186 $lines[] =
'- Text danach → `{cms:footer}`';
189 $lines[] =
'Fehlende Marker: der jeweilige Bereich entfaellt, der Text gehoert zum Body.';
190 $lines[] =
'**Spalten-Marker (`col2`, `col3a`, `col3b`) nicht setzen** — werden manuell im CMS gesetzt.';
192 foreach ($markers as $name => $hr) {
193 $lines[] =
'**' . ucfirst($name) .
'-Marker:**';
194 $lines[] =
'```html';
199 $lines[] =
'**Beispiel `content` fuer dieses Template:**';
200 $lines[] =
'```html';
203 if ($withHeroImage) {
205 $lines[] =
'Hero-**Bild** kommt ueber dbxKi-Medienschritte — nicht ins HTML. Neue Hero-Bilder liegen verbindlich in `img/hero`.';
207 return implode(
"\n", $lines);
210 private function contentMarkersGuideShort(
string $template):
string {
212 $bits = array(
'Template `' .
$template .
'`');
213 if (!empty(
$slots[
'hero_text'])) {
214 $bits[] =
'Hero-/Header-/Footer-Marker per `<hr>`';
215 } elseif (!empty(
$slots[
'header']) || !empty(
$slots[
'footer'])) {
216 $bits[] =
'Header-/Footer-Marker per `<hr>`';
218 $bits[] =
'keine Spalten-Marker';
219 $bits[] =
'Bootstrap-5-Content-Komponenten nur wenn im Auftrag ausgewaehlt, nur Bootstrap-Klassen, kein eigenes CSS/JS';
220 return implode(
'; ', $bits) .
'.';
223 private function allowedBootstrapComponents(): array {
226 'label' =>
'Hinweis',
227 'classes' =>
'alert alert-info / alert-warning / alert-success',
228 'use' =>
'Kurze Hinweis-, Info- oder Erfolgsbox.',
232 'classes' =>
'card, card-body, row, row-cols-*, g-*',
233 'use' =>
'Teaser, Leistungsboxen oder Paket-/Feature-Kacheln.',
235 'list_group' => array(
236 'label' =>
'Listenbox',
237 'classes' =>
'list-group, list-group-item',
238 'use' =>
'Kompakte Nutzen-, Schritt- oder Funktionslisten.',
242 'classes' =>
'badge text-bg-*',
243 'use' =>
'Status, Kategorien, kleine Hervorhebungen.',
246 'label' =>
'Buttons',
247 'classes' =>
'btn btn-primary / btn-outline-primary',
248 'use' =>
'CTA-Links ohne eigenes JavaScript.',
251 'label' =>
'Tabelle',
252 'classes' =>
'table table-striped table-hover',
253 'use' =>
'Vergleichs- oder Preis-/Datenuebersichten.',
255 'accordion' => array(
256 'label' =>
'Akkordeon',
257 'classes' =>
'accordion, accordion-item, accordion-button',
258 'use' =>
'FAQ oder aufklappbare Detailbereiche.',
262 'classes' =>
'nav nav-tabs, tab-content, tab-pane',
263 'use' =>
'Alternative Sichten auf denselben Inhalt.',
268 private function selectedBootstrapComponentsFromRequest(): array {
269 $raw =
dbx()->get_request_var(
'bootstrap_components', array(),
'*');
270 if (!is_array($raw)) {
271 $raw = $raw ===
'' ? array() : explode(
',', (
string) $raw);
273 $allowed = $this->allowedBootstrapComponents();
275 foreach ($raw as $key) {
276 $key = strtolower(trim((
string) $key));
277 if (isset(
$allowed[$key]) && !in_array($key,
$out,
true)) {
284 private function buildBootstrapComponentChoices(array $selected):
string {
286 foreach ($this->allowedBootstrapComponents() as $key => $meta) {
287 $checked = in_array($key, $selected,
true) ?
' checked' :
'';
288 $html .=
'<label><input type="checkbox" name="bootstrap_components[]" value="' . $this->esc($key) .
'"' .
$checked .
'>'
289 .
'<span><strong>' . $this->esc($meta[
'label'] ?? $key) .
'</strong><small>' . $this->esc($meta[
'use'] ??
'') .
'</small></span></label>';
294 private function bootstrapComponentsMeta(array $selected): array {
295 $allowed = $this->allowedBootstrapComponents();
297 foreach ($selected as $key) {
305 private function bootstrapComponentsGuide(array $selected):
string {
306 $meta = $this->bootstrapComponentsMeta($selected);
308 return "Keine Bootstrap-Komponenten im Content verwenden. Erlaubt sind nur normales Jodit-HTML wie h2, h3, p, ul/ol, Links und einfache Textstruktur.";
311 'Die KI darf im Content nur diese ausgewaehlten Bootstrap-5-Komponenten verwenden:',
314 foreach ($meta as $key => $row) {
315 $lines[] =
'- `' . $key .
'` (' . ($row[
'label'] ?? $key) .
'): ' . ($row[
'use'] ??
'') .
' Klassen: `' . ($row[
'classes'] ??
'') .
'`.';
318 $lines[] =
'Nicht ausgewaehlte Bootstrap-Komponenten sind verboten. Kein eigenes CSS, kein eigenes JavaScript, keine Inline-Styles. HTML muss in Jodit bearbeitbar bleiben.';
319 return implode(
"\n", $lines);
322 private function heroImageSpecText():
string {
323 return 'JPG, Standard ' . self::HERO_DEFAULT_IMAGE_WIDTH .
'×' . self::HERO_DEFAULT_IMAGE_HEIGHT .
' px'
324 .
' (nur bei ausdruecklicher Vorgabe abweichend, maximal ' . self::HERO_MAX_WIDTH .
'×' . self::HERO_MAX_HEIGHT .
' px),'
325 .
' CMS-Hero-Hoehe Standard ' . self::HERO_DEFAULT_HEIGHT;
328 private function heroImageBriefingMeta(): array {
331 'max_width' => self::HERO_MAX_WIDTH,
332 'max_height' => self::HERO_MAX_HEIGHT,
333 'default_width' => self::HERO_DEFAULT_IMAGE_WIDTH,
334 'default_image_height' => self::HERO_DEFAULT_IMAGE_HEIGHT,
335 'default_dimensions' => self::HERO_DEFAULT_IMAGE_WIDTH .
'x' . self::HERO_DEFAULT_IMAGE_HEIGHT,
336 'recommended' => self::HERO_OPTIMAL_WIDTH .
'x' . self::HERO_OPTIMAL_HEIGHT,
337 'default_height' => self::HERO_DEFAULT_HEIGHT,
341 private function ensureContentBootstrap():
void {
342 if (!class_exists(dbxContentLng::class)) {
343 require_once dirname(__DIR__, 2) .
'/dbxContent/include/dbxContent_bootstrap_sync.php';
348 return dbx()->get_include_obj(
'dbxKiCmsService',
'dbxKi');
352 return dbx()->get_include_obj(
'dbxKiHelp',
'dbxKi');
356 return dbx()->get_include_obj(
'dbxKiBundleService',
'dbxKi');
359 private function tpl() {
360 return dbx()->get_system_obj(
'dbxTPL');
363 private function esc(
$value):
string {
364 return htmlspecialchars((
string)
$value, ENT_QUOTES,
'UTF-8');
367 private function moduleUrl(
string $run1, array $params = array()):
string {
368 $url =
'?dbx_modul=dbxKi&dbx_run1=' . rawurlencode($run1);
369 foreach ($params as $key =>
$value) {
373 $url .=
'&' . rawurlencode((
string) $key) .
'=' . rawurlencode((
string)
$value);
378 private function contentAdminUrl(
string $run1, array $params = array()):
string {
379 $url =
'?dbx_modul=dbxContent_admin&dbx_run1=' . rawurlencode($run1);
380 foreach ($params as $key =>
$value) {
384 $url .=
'&' . rawurlencode((
string) $key) .
'=' . rawurlencode((
string)
$value);
389 private function withContentLng(
string $lng, callable $fn) {
391 $prev = (string)
dbx()->get_system_var(
'dbx_lng',
'');
393 dbx()->set_system_var(
'dbx_lng',
$lng);
398 dbx()->set_system_var(
'dbx_lng', $prev);
402 private function withModuleBar(array $data,
string $screen,
string $actionsHtml =
''): array {
403 return array_merge($data, $this->help()->moduleBarTemplateData($screen, $actionsHtml));
424 private function briefingForm(
string $fid,
string $template,
string $action, array $replacements = array()) {
425 $form =
dbx()->get_system_obj(
'dbxForm');
427 $form->_action = $action;
428 $form->_msg_info =
'';
430 foreach ($replacements as $key =>
$value) {
440 private function briefingFormId(
string $recipe):
string {
441 return 'ki-briefing-' . str_replace(
'_',
'-', strtolower(trim($recipe)));
444 private function barBackHub():
string {
445 return '<a class="btn btn-outline-secondary btn-sm" href="' . $this->esc($this->moduleUrl(
'briefing')) .
'" title="Zurueck zum Hub"><i class="bi bi-arrow-left"></i></a>';
448 private function briefingWorkflowData(
string $returnRun1): array {
450 'workflow_hint' => $this->tpl()->get_tpl(
'dbxKi|ki-briefing-workflow-hint', array()),
451 'import_panel' => $this->bundle()->renderImportPanel($returnRun1),
457 'page_create' => array(
458 'title' =>
'Neue Content-Seite anlegen',
459 'icon' =>
'bi-file-earmark-plus',
460 'subtitle' =>
'Ordner, Titel, Text, Hero — KI-Auftrag erzeugen',
461 'recipe' =>
'page.create.v1',
462 'form_url' => $this->moduleUrl(
'briefing_page_create'),
463 'export_action' =>
'briefing_export',
465 'page_update' => array(
466 'title' =>
'Bestehende Seite aendern',
467 'icon' =>
'bi-pencil-square',
468 'subtitle' =>
'Inhalt aktualisieren, Stil anpassen, Hero tauschen',
469 'recipe' =>
'page.update.v1',
470 'form_url' => $this->moduleUrl(
'briefing_page_update'),
471 'export_action' =>
'briefing_export',
473 'page_translate' => array(
474 'title' =>
'Seite uebersetzen',
475 'icon' =>
'bi-translate',
476 'subtitle' =>
'Quellseite in andere Sprache uebertragen',
477 'recipe' =>
'translation.v1',
478 'form_url' => $this->moduleUrl(
'briefing_page_translate'),
479 'export_action' =>
'briefing_export',
481 'module_update' => array(
482 'title' =>
'Bestehendes Modul bearbeiten',
483 'icon' =>
'bi-box-seam',
484 'subtitle' =>
'Komplettes Modul als Kontext, harte dbxapp-Regeln, Antwort-ZIP',
485 'recipe' =>
'module.update.v1',
486 'form_url' => $this->moduleUrl(
'briefing_module'),
487 'export_action' =>
'briefing_module_export',
490 'title' =>
'Design entwickeln',
491 'icon' =>
'bi-palette',
492 'subtitle' =>
'Aufteilung, Menue, Footer und Branding mit komplettem Designkontext',
493 'recipe' =>
'design.update.v1',
494 'form_url' => $this->moduleUrl(
'briefing_design'),
495 'export_action' =>
'briefing_design_export',
503 return $this->moduleUrl(
'briefing_page_update');
504 case 'page_translate':
505 return $this->moduleUrl(
'briefing_page_translate');
507 return $this->moduleUrl(
'briefing_page_create');
512 $this->ensureContentBootstrap();
514 foreach ($this->
recipes() as $key => $meta) {
515 $cards .=
'<div class="col-md-6">'
516 .
'<div class="card h-100">'
517 .
'<div class="card-body d-flex flex-column">'
518 .
'<h2 class="h5"><i class="bi ' . $this->
esc($meta[
'icon'] ??
'bi-grid') .
' me-2"></i>'
519 . $this->
esc($meta[
'title'] ?? $key) .
'</h2>'
520 .
'<p class="small text-muted flex-grow-1">' . $this->
esc($meta[
'subtitle'] ??
'') .
'</p>'
521 .
'<a class="btn btn-primary" href="' . $this->
esc($meta[
'form_url'] ??
'#') .
'">Formular oeffnen</a>'
522 .
'</div></div></div>';
525 return $this->tpl()->get_tpl(
'dbxKi|ki-briefing-hub', $this->withModuleBar(array_merge(array(
526 'recipe_cards' => $cards,
527 'import_url' => $this->
esc($this->moduleUrl(
'bundle')),
528 'styles_url' => $this->
esc($this->moduleUrl(
'briefing_styles')),
529 'bundle_version' => $this->
esc(self::BRIEFING_VERSION),
530 ), $this->briefingWorkflowData(
'briefing')),
'briefing',
531 '<a class="btn btn-outline-secondary btn-sm" href="' . $this->
esc($this->moduleUrl(
'briefing_styles')) .
'" title="Schreibstile"><i class="bi bi-type"></i></a>'));
535 $this->ensureContentBootstrap();
536 $lng = strtolower(trim((
string)
dbx()->get_request_var(
'lng',
'',
'*')));
538 $lng = strtolower(trim((
string)
dbx()->get_request_var(
'dbx_lng', dbxContentLng::current(),
'*')));
541 $styleOptions = $this->buildStyleOptions((
string)
dbx()->get_request_var(
'writing_style',
'sachlich',
'*'));
542 $folderId = (int)
dbx()->get_request_var(
'folder_id', 0,
'int');
543 $sorterAfterPageId = (int)
dbx()->get_request_var(
'sorter_after_page_id', 0,
'int');
544 if ($sorterAfterPageId > 0) {
546 $afterPage = $this->loadPage(
$lng, $sorterAfterPageId);
547 $folderId = (int) ($afterPage[
'folder'] ?? $folderId);
548 }
catch (\Throwable $e) {
549 $sorterAfterPageId = 0;
553 $heroEnabled =
dbx()->get_request_var(
'hero_enabled',
'1',
'*') !==
'0';
554 $selectedTemplate = (string)
dbx()->get_request_var(
'content_template', self::CONTENT_TEMPLATE_DEFAULT,
'*');
555 $selectedBootstrapComponents = $this->selectedBootstrapComponentsFromRequest();
556 $placementTitle = $folderId > 0 ? $this->createPlacementTitle(
$lng, $folderId, $sorterAfterPageId) :
'Zielposition waehlen';
558 $data = $this->withModuleBar(array_merge(array(
559 'hub_url' => $this->
esc($this->moduleUrl(
'briefing')),
560 'export_url' => $this->
esc($this->moduleUrl(
'briefing_export')),
561 'tree_url' => $this->
esc($this->contentAdminUrl(
'cms_tree')),
564 'selected_folder_id' => (
string) $folderId,
565 'selected_sorter_after_page_id' => (
string) $sorterAfterPageId,
566 'selected_placement_title' => $this->
esc($placementTitle),
567 'current_context' => $this->renderCreatePlacementHtml(
$lng, $folderId, $sorterAfterPageId),
568 'style_options' => $styleOptions,
569 'template_options' => $this->buildContentTemplateOptions($selectedTemplate, $heroEnabled),
570 'bootstrap_component_choices' => $this->buildBootstrapComponentChoices($selectedBootstrapComponents),
571 'title' => $this->
esc((
string)
dbx()->get_request_var(
'title',
'',
'*')),
572 'permalink' => $this->
esc((
string)
dbx()->get_request_var(
'permalink',
'',
'*')),
573 'description' => $this->
esc((
string)
dbx()->get_request_var(
'description',
'',
'*')),
574 'keywords' => $this->
esc((
string)
dbx()->get_request_var(
'keywords',
'',
'*')),
575 'content_brief' => $this->
esc((
string)
dbx()->get_request_var(
'content_brief',
'',
'*')),
576 'hero_brief' => $this->
esc((
string)
dbx()->get_request_var(
'hero_brief',
'',
'*')),
577 'hero_text_brief' => $this->
esc((
string)
dbx()->get_request_var(
'hero_text_brief',
'',
'*')),
578 'custom_notes' => $this->
esc((
string)
dbx()->get_request_var(
'custom_notes',
'',
'*')),
579 'hero_checked' => $heroEnabled ?
'checked' :
'',
580 'activ_checked' =>
dbx()->get_request_var(
'activ',
'1',
'*') !==
'0' ?
'checked' :
'',
581 ), $this->briefingWorkflowData(
'briefing_page_create')),
'briefing_page_create', $this->barBackHub());
583 return $this->briefingForm(
584 $this->briefingFormId(
'page_create'),
585 'ki-briefing-page-create',
586 $this->moduleUrl(
'briefing_export'),
592 $this->ensureContentBootstrap();
593 $lng = dbxContentLng::current();
594 $pageId = (int)
dbx()->get_request_var(
'page_id', 0,
'int');
596 $styleOptions = $this->buildStyleOptions((
string)
dbx()->get_request_var(
'writing_style',
'sachlich',
'*'));
597 $selectedBootstrapComponents = $this->selectedBootstrapComponentsFromRequest();
600 $rendered =
'<div class="dbx-cms-empty">Seite links im Content Tree waehlen.</div>';
601 $contextHtml =
'<div class="dbx-ki-context-empty">Noch keine Seite gewaehlt.</div>';
605 $pageTitle = (string) (
$page[
'title'] ?? (
'Seite #' .
$pageId));
606 $rendered =
'[modul=dbxContent]dbx_run1=show&cid=' .
$pageId .
'[/modul]';
607 $contextHtml = $this->renderPageContextHtml(
$lng,
$page);
608 }
catch (\Throwable $e) {
613 $data = $this->withModuleBar(array_merge(array(
614 'hub_url' => $this->
esc($this->moduleUrl(
'briefing')),
615 'export_url' => $this->
esc($this->moduleUrl(
'briefing_export')),
616 'tree_url' => $this->
esc($this->contentAdminUrl(
'cms_tree')),
617 'preview_url' => $this->
esc($this->moduleUrl(
'briefing_page_update_preview')),
620 'selected_page_id' => (
string)
$pageId,
621 'selected_page_title' => $this->
esc($pageTitle !==
'' ? $pageTitle :
'Keine Seite gewaehlt'),
622 'current_rendered' => $rendered,
623 'current_context' => $contextHtml,
624 'style_options' => $styleOptions,
625 'bootstrap_component_choices' => $this->buildBootstrapComponentChoices($selectedBootstrapComponents),
626 'change_brief' => $this->
esc((
string)
dbx()->get_request_var(
'change_brief',
'',
'*')),
627 'custom_notes' => $this->
esc((
string)
dbx()->get_request_var(
'custom_notes',
'',
'*')),
628 'field_content_checked' =>
dbx()->get_request_var(
'change_content',
'1',
'*') !==
'0' ?
'checked' :
'',
629 'field_title_checked' =>
dbx()->get_request_var(
'change_title',
'',
'*') ===
'1' ?
'checked' :
'',
630 'field_description_checked' =>
dbx()->get_request_var(
'change_description',
'',
'*') ===
'1' ?
'checked' :
'',
631 'field_hero_checked' =>
dbx()->get_request_var(
'change_hero',
'',
'*') ===
'1' ?
'checked' :
'',
632 'hero_brief' => $this->
esc((
string)
dbx()->get_request_var(
'hero_brief',
'',
'*')),
633 'embedded_policy_preserve_checked' =>
dbx()->get_request_var(
'embedded_policy',
'preserve',
'*') ===
'preserve' ?
'checked' :
'',
634 'embedded_policy_reorder_checked' =>
dbx()->get_request_var(
'embedded_policy',
'preserve',
'*') ===
'reorder' ?
'checked' :
'',
635 'embedded_policy_remove_checked' =>
dbx()->get_request_var(
'embedded_policy',
'preserve',
'*') ===
'remove' ?
'checked' :
'',
636 'embedded_change_notes' => $this->
esc((
string)
dbx()->get_request_var(
'embedded_change_notes',
'',
'*')),
637 ), $this->briefingWorkflowData(
'briefing_page_update')),
'briefing_page_update', $this->barBackHub());
639 return $this->briefingForm(
640 $this->briefingFormId(
'page_update'),
641 'ki-briefing-page-update',
642 $this->moduleUrl(
'briefing_export'),
648 $this->ensureContentBootstrap();
650 $lng = strtolower(trim((
string)
dbx()->get_request_var(
'dbx_lng', dbxContentLng::current(),
'*')));
651 $pageId = (int)
dbx()->get_request_var(
'id', 0,
'int');
653 dbx()->json_response(array(
'ok' => 0,
'error' =>
'Keine Seite gewaehlt.'),
true);
658 $renderer =
dbx()->get_include_obj(
'dbxContentRenderer',
'dbxContent');
662 dbx()->json_response(array(
665 'title' => (
string) (
$page[
'title'] ??
''),
667 'context_html' => $this->renderPageContextHtml(
$lng,
$page),
669 }
catch (\Throwable $e) {
670 dbx()->json_response(array(
'ok' => 0,
'error' => $e->getMessage()),
true);
675 $this->ensureContentBootstrap();
676 $sourceLng = strtolower(trim((
string)
dbx()->get_request_var(
'source_lng',
'',
'*')));
677 if ($sourceLng ===
'') {
678 $sourceLng = strtolower(trim((
string)
dbx()->get_request_var(
'dbx_lng', dbxContentLng::current(),
'*')));
680 $sourceId = (int)
dbx()->get_request_var(
'source_id', 0,
'int');
681 $targetLngs = $this->selectedTargetLngsFromRequest($sourceLng,
true);
682 $currentRendered =
'<div class="dbx-cms-empty">Seite links im Content Tree waehlen.</div>';
683 $currentContext =
'<p class="dbx-ki-context-empty">Noch keine Seite gewaehlt.</p>';
684 $selectedTitle =
'Keine Seite gewaehlt';
687 $source = $this->loadPage($sourceLng, $sourceId);
688 $selectedTitle = trim((
string) (
$source[
'title'] ??
'')) !==
'' ? (string)
$source[
'title'] : (
'Seite #' . $sourceId);
689 $renderer =
dbx()->get_include_obj(
'dbxContentRenderer',
'dbxContent');
690 $currentRendered = $this->withContentLng($sourceLng,
function () use (
$renderer, $sourceId) {
693 $currentContext = $this->renderPageContextHtml($sourceLng,
$source);
694 }
catch (\Throwable $e) {
695 $currentRendered =
'<div class="dbx-cms-empty">Quellseite konnte nicht geladen werden.</div>';
699 $data = $this->withModuleBar(array_merge(array(
700 'hub_url' => $this->
esc($this->moduleUrl(
'briefing')),
701 'export_url' => $this->
esc($this->moduleUrl(
'briefing_export')),
702 'tree_url' => $this->
esc($this->contentAdminUrl(
'cms_tree')),
703 'preview_url' => $this->
esc($this->moduleUrl(
'briefing_page_update_preview')),
704 'source_lng' => $this->
esc($sourceLng),
705 'source_lng_options' => $this->buildLngOptions($sourceLng),
706 'target_lng_checkboxes' => $this->buildTargetLngCheckboxes($sourceLng, $targetLngs),
707 'selected_page_id' => (
string) $sourceId,
708 'selected_page_title' => $this->
esc($selectedTitle),
709 'style_options' => $this->buildStyleOptions((
string)
dbx()->get_request_var(
'writing_style',
'sachlich',
'*')),
710 'translation_notes' => $this->
esc((
string)
dbx()->get_request_var(
'translation_notes',
'',
'*')),
711 'current_rendered' => $currentRendered,
712 'current_context' => $currentContext,
713 'copy_media_checked' =>
dbx()->get_request_var(
'copy_media',
'1',
'*') !==
'0' ?
'checked' :
'',
714 ), $this->briefingWorkflowData(
'briefing_page_translate')),
'briefing_page_translate', $this->barBackHub());
716 return $this->briefingForm(
717 $this->briefingFormId(
'page_translate'),
718 'ki-briefing-page-translation',
719 $this->moduleUrl(
'briefing_export'),
728 .
'<td><input class="form-control form-control-sm" name="style_key[]" value="' . $this->
esc($key) .
'"></td>'
729 .
'<td><input class="form-control form-control-sm" name="style_label[]" value="' . $this->
esc($meta[
'label'] ??
'') .
'"></td>'
730 .
'<td><textarea class="form-control form-control-sm" name="style_prompt[]" rows="2">' . $this->
esc($meta[
'prompt'] ??
'') .
'</textarea></td>'
734 .
'<td><input class="form-control form-control-sm" name="style_key[]" placeholder="neuer_stil"></td>'
735 .
'<td><input class="form-control form-control-sm" name="style_label[]" placeholder="Bezeichnung"></td>'
736 .
'<td><textarea class="form-control form-control-sm" name="style_prompt[]" rows="2" placeholder="KI-Anweisung"></textarea></td>'
739 $data = $this->withModuleBar(array(
740 'hub_url' => $this->
esc($this->moduleUrl(
'briefing')),
741 'save_url' => $this->
esc($this->moduleUrl(
'briefing_styles_save')),
742 'style_rows' =>
$rows,
743 ),
'briefing_styles', $this->barBackHub());
745 return $this->briefingForm(
746 'ki-briefing-styles',
747 'ki-briefing-styles',
748 $this->moduleUrl(
'briefing_styles_save'),
755 $form = $this->briefingForm(
756 'ki-briefing-styles',
757 'ki-briefing-styles',
758 $this->moduleUrl(
'briefing_styles_save')
760 if (!
$form->submit()) {
761 throw new \RuntimeException(
'Ungueltiger oder abgelaufener Formular-Token.');
763 if (
dbx()->get_request_var(
'styles_action',
'save',
'parameter') ===
'reset') {
765 dbx()->sys_msg(
'info',
'dbxKi',
'styles',
'Schreibstile',
'Standard wiederhergestellt');
769 (array)
dbx()->get_request_var(
'style_key', array(),
'*'),
770 (array)
dbx()->get_request_var(
'style_label', array(),
'*'),
771 (array)
dbx()->get_request_var(
'style_prompt', array(),
'*')
774 dbx()->sys_msg(
'info',
'dbxKi',
'styles',
'Schreibstile gespeichert', count($styles) .
' Stile');
775 }
catch (\Throwable $e) {
776 dbx()->sys_msg(
'error',
'dbxKi',
'styles',
'Speichern fehlgeschlagen', $e->getMessage());
778 return $this->renderStylesAdmin();
784 dbx()->
sys_msg(
'warning',
'dbxKi',
'styles',
'Schreibstile',
785 'Standardwerte bitte ueber das geschuetzte Formular zuruecksetzen.');
790 $this->ensureContentBootstrap();
791 if (!class_exists(
'ZipArchive')) {
792 dbx()->json_response(array(
'ok' => 0,
'error' =>
'ZipArchive nicht verfuegbar.'),
true);
795 $recipe = strtolower(trim((
string)
dbx()->get_request_var(
'recipe',
'',
'*')));
796 if (!in_array($recipe, array(
'page_create',
'page_update',
'page_translate'),
true)) {
797 dbx()->json_response(array(
'ok' => 0,
'error' =>
'Unbekanntes Rezept: ' . $recipe),
true);
801 $form = $this->briefingForm(
802 $this->briefingFormId($recipe),
803 'ki-briefing-' . str_replace(
'_',
'-', $recipe),
804 $this->moduleUrl(
'briefing_export')
806 if (!
$form->submit()) {
807 dbx()->json_response(array(
'ok' => 0,
'error' =>
'Ungueltiger oder abgelaufener Formular-Token.'),
true);
811 if ($recipe ===
'page_create') {
812 $package = $this->buildPageCreatePackage($this->collectPageCreateInput());
813 } elseif ($recipe ===
'page_update') {
814 $package = $this->buildPageUpdatePackage($this->collectPageUpdateInput());
815 } elseif ($recipe ===
'page_translate') {
816 $package = $this->buildPageTranslatePackage($this->collectPageTranslateInput());
822 private function collectPageCreateInput(): array {
823 $lng = strtolower(trim((string)
dbx()->get_request_var(
'lng', dbxContentLng::current(),
'*')));
825 'recipe' =>
'page_create',
827 'folder_id' => max(0, (
int)
dbx()->get_request_var(
'folder_id', 0,
'int')),
828 'sorter_after_page_id' => max(0, (
int)
dbx()->get_request_var(
'sorter_after_page_id', 0,
'int')),
829 'title' => trim((
string)
dbx()->get_request_var(
'title',
'',
'*')),
830 'permalink' => trim((
string)
dbx()->get_request_var(
'permalink',
'',
'*')),
831 'description' => trim((
string)
dbx()->get_request_var(
'description',
'',
'*')),
832 'keywords' => trim((
string)
dbx()->get_request_var(
'keywords',
'',
'*')),
833 'writing_style' => trim((
string)
dbx()->get_request_var(
'writing_style',
'sachlich',
'*')),
834 'content_brief' => trim((
string)
dbx()->get_request_var(
'content_brief',
'',
'*')),
835 'hero_enabled' =>
dbx()->get_request_var(
'hero_enabled',
'0',
'*') ===
'1',
836 'hero_brief' => trim((
string)
dbx()->get_request_var(
'hero_brief',
'',
'*')),
837 'hero_text_brief' => trim((
string)
dbx()->get_request_var(
'hero_text_brief',
'',
'*')),
838 'content_template' => trim((
string)
dbx()->get_request_var(
'content_template',
'',
'*')),
839 'bootstrap_components' => $this->selectedBootstrapComponentsFromRequest(),
840 'activ' =>
dbx()->get_request_var(
'activ',
'0',
'*') ===
'1',
841 'custom_notes' => trim((
string)
dbx()->get_request_var(
'custom_notes',
'',
'*')),
845 private function collectPageUpdateInput(): array {
846 $lng = strtolower(trim((string)
dbx()->get_request_var(
'lng', dbxContentLng::current(),
'*')));
847 $changeFields = array();
848 if (
dbx()->get_request_var(
'change_content',
'0',
'*') ===
'1') {
849 $changeFields[] =
'content';
851 if (
dbx()->get_request_var(
'change_title',
'0',
'*') ===
'1') {
852 $changeFields[] =
'title';
854 if (
dbx()->get_request_var(
'change_description',
'0',
'*') ===
'1') {
855 $changeFields[] =
'description';
857 if (
dbx()->get_request_var(
'change_hero',
'0',
'*') ===
'1') {
858 $changeFields[] =
'hero';
860 if (!$changeFields) {
861 $changeFields = array(
'content');
865 'recipe' =>
'page_update',
867 'page_id' => max(0, (
int)
dbx()->get_request_var(
'page_id', 0,
'int')),
868 'writing_style' => trim((
string)
dbx()->get_request_var(
'writing_style',
'sachlich',
'*')),
869 'change_brief' => trim((
string)
dbx()->get_request_var(
'change_brief',
'',
'*')),
870 'change_fields' => $changeFields,
871 'hero_brief' => trim((
string)
dbx()->get_request_var(
'hero_brief',
'',
'*')),
872 'custom_notes' => trim((
string)
dbx()->get_request_var(
'custom_notes',
'',
'*')),
873 'embedded_policy' => $this->sanitizeEmbeddedPolicy((
string)
dbx()->get_request_var(
'embedded_policy',
'preserve',
'*')),
874 'embedded_change_notes' => trim((
string)
dbx()->get_request_var(
'embedded_change_notes',
'',
'*')),
875 'bootstrap_components' => $this->selectedBootstrapComponentsFromRequest(),
879 private function collectPageTranslateInput(): array {
880 $sourceLng = strtolower(trim((string)
dbx()->get_request_var(
'source_lng', dbxContentLng::current(),
'*')));
881 $targetLngs = $this->selectedTargetLngsFromRequest($sourceLng,
false);
883 'recipe' =>
'page_translate',
884 'source_lng' => $sourceLng,
885 'target_lng' => (
string) ($targetLngs[0] ??
''),
886 'target_lngs' => $targetLngs,
887 'source_id' => max(0, (
int)
dbx()->get_request_var(
'source_id', 0,
'int')),
888 'writing_style' => trim((
string)
dbx()->get_request_var(
'writing_style',
'sachlich',
'*')),
889 'translation_notes' => trim((
string)
dbx()->get_request_var(
'translation_notes',
'',
'*')),
890 'copy_media' =>
dbx()->get_request_var(
'copy_media',
'1',
'*') !==
'0',
894 private function buildPageCreatePackage(array $in): array {
895 if ($in[
'folder_id'] <= 0) {
896 throw new \InvalidArgumentException(
'Bitte einen Zielordner waehlen.');
898 if ($in[
'title'] ===
'') {
899 throw new \InvalidArgumentException(
'Titel ist erforderlich.');
901 if ($in[
'content_brief'] ===
'') {
902 throw new \InvalidArgumentException(
'Beschreiben Sie, worum es im Text gehen soll.');
905 $sorterAfterPage = array();
906 $sorterAfterLabel =
'';
908 if ($in[
'sorter_after_page_id'] > 0) {
910 $sorterAfterPage = $this->loadPage($in[
'lng'], $in[
'sorter_after_page_id']);
911 $in[
'folder_id'] = (int) ($sorterAfterPage[
'folder'] ?? $in[
'folder_id']);
912 $sorterAfterLabel =
'#' . (int) $in[
'sorter_after_page_id'] .
' ' . (
string) ($sorterAfterPage[
'title'] ??
'');
913 $sorterValue = $this->sorterAfterPage($in[
'lng'], $in[
'sorter_after_page_id']);
914 }
catch (\Throwable $e) {
915 $in[
'sorter_after_page_id'] = 0;
918 $in[
'sorter'] = $sorterValue;
919 $folderLabel = $this->folderLabel($in[
'lng'], $in[
'folder_id']);
920 $styles = $this->writingStyles();
921 $styleKey = $in[
'writing_style'];
922 if (!isset($styles[$styleKey])) {
923 $styleKey =
'sachlich';
925 $stylePrompt = (string) ($styles[$styleKey][
'prompt'] ??
'');
927 $permalink = $in[
'permalink'];
928 if ($permalink ===
'') {
929 $permalink =
'(automatisch aus Titel)';
932 $contentTemplate = $this->contentTemplateForCreate($in[
'hero_enabled'], $in[
'content_template'] ??
'');
933 $templateSlots = $this->analyzeTemplateSlots($contentTemplate);
936 'briefing_version' => self::BRIEFING_VERSION,
937 'recipe' =>
'page.create.v1',
938 'task' =>
'page_create',
939 'created_at' => date(
'c'),
941 'folder_id' => $in[
'folder_id'],
942 'folder_label' => $folderLabel,
943 'sorter_after_page_id' => $in[
'sorter_after_page_id'],
944 'sorter_after_label' => $sorterAfterLabel,
945 'sorter' => $sorterValue,
946 'title' => $in[
'title'],
947 'permalink' => $in[
'permalink'],
948 'description' => $in[
'description'],
949 'keywords' => $in[
'keywords'],
950 'activ' => $in[
'activ'],
951 'writing_style' => $styleKey,
952 'content' => array(
'brief' => $in[
'content_brief']),
954 'enabled' => $in[
'hero_enabled'],
955 'brief' => $in[
'hero_brief'],
956 'image' => $this->heroImageBriefingMeta(),
958 'default' => self::HERO_DEFAULT_HEIGHT,
959 'rule' =>
'In page.create hero_height=' . self::HERO_DEFAULT_HEIGHT .
' setzen, wenn nichts anderes angegeben ist.',
962 'hero_text' => array(
963 'brief' => $in[
'hero_text_brief'] ??
'',
964 'max_lines' => self::HERO_TEXT_MAX_LINES,
965 'rule' =>
'Wenn nicht anders angegeben, maximal ' . self::HERO_TEXT_MAX_LINES .
' Zeilen.',
967 'content_template' => $contentTemplate,
968 'template_slots' => $templateSlots,
969 'content_markers' => $this->contentMarkersMeta($templateSlots),
970 'bootstrap_components' => $this->bootstrapComponentsMeta($in[
'bootstrap_components'] ?? array()),
971 'custom_notes' => $in[
'custom_notes'],
974 $jobVorlage = $this->jobVorlagePageCreate($in);
976 'bundle_version' => self::BRIEFING_VERSION,
977 'title' => $in[
'title'],
978 'recipe' =>
'page.create.v1',
980 'intent' =>
'create',
982 'auto_execute' =>
true,
987 'target_folder_id' => $in[
'folder_id'],
988 'target_folder_label' => $folderLabel,
989 'sorter_after_page_id' => $in[
'sorter_after_page_id'],
990 'sorter_after' => $this->slimPageForUpdate($sorterAfterPage),
991 'sorter' => $sorterValue,
994 $auftrag = $this->renderTemplateFile(
'ki-page-create-auftrag.md', array(
995 'writing_style_prompt' => $stylePrompt,
996 'content_brief' => $in[
'content_brief'],
997 'custom_notes' => $in[
'custom_notes'] !==
'' ? $in[
'custom_notes'] :
'(keine)',
998 'zip_structure' => $this->zipStructureCreate($in[
'hero_enabled']),
999 'assets_rules' => $this->assetsRulesCreate($in[
'hero_enabled'], $in[
'hero_brief']),
1000 'lng' => $in[
'lng'],
1001 'folder_id' => (
string) $in[
'folder_id'],
1002 'folder_label' => $folderLabel,
1003 'sorter_after_label' => $sorterAfterLabel !==
'' ? $sorterAfterLabel :
'(am Ende des Ordners)',
1004 'sorter' => $sorterValue !==
'' ? $sorterValue :
'(automatisch am Ende)',
1005 'title' => $in[
'title'],
1006 'permalink' => $permalink,
1007 'description' => $in[
'description'] !==
'' ? $in[
'description'] :
'(optional)',
1008 'keywords' => $in[
'keywords'] !==
'' ? $in[
'keywords'] :
'(optional)',
1009 'activ' => $in[
'activ'] ?
'1 (aktiv)' :
'0 (inaktiv)',
1010 'content_template' => $contentTemplate,
1011 'hero_text_brief' => ($in[
'hero_text_brief'] ??
'') !==
'' ? $in[
'hero_text_brief'] :
'(optional — Kurztext im Hero-Bereich)',
1012 'content_markers_guide' => $this->contentMarkersGuide($contentTemplate, $in[
'hero_enabled'], $in[
'hero_text_brief'] ??
''),
1013 'bootstrap_components_guide' => $this->bootstrapComponentsGuide($in[
'bootstrap_components'] ?? array()),
1016 $files = $this->packBriefingFiles(array(
1017 'recipe' =>
'page.create.v1',
1018 'task_label' =>
'Neue CMS-Seite: ' . $in[
'title'],
1019 'hero_assets' => $in[
'hero_enabled'],
1020 'content_template' => $contentTemplate,
1021 'context_hint' =>
'Nein — Ordner und Sortierung stehen in briefing.json',
1023 'briefing' => $briefing,
1024 'job_vorlage' => $jobVorlage,
1025 'context' => $context,
1026 'auftrag' => $auftrag,
1028 if ($in[
'hero_enabled']) {
1029 $files[
'assets/README.txt'] = $this->assetsReadmeHero($in[
'hero_brief']);
1033 'filename' =>
'dbxki-auftrag-neue-seite-' . preg_replace(
'/[^a-z0-9_-]+/i',
'-', $in[
'title']) .
'.zip',
1038 private function buildPageUpdatePackage(array $in): array {
1039 if ($in[
'page_id'] <= 0) {
1040 throw new \InvalidArgumentException(
'Bitte eine Seite waehlen.');
1042 if ($in[
'change_brief'] ===
'') {
1043 throw new \InvalidArgumentException(
'Beschreiben Sie die gewuenschte Aenderung.');
1046 $page = $this->loadPage($in[
'lng'], $in[
'page_id']);
1047 $styles = $this->writingStyles();
1048 $styleKey = $in[
'writing_style'];
1049 if (!isset($styles[$styleKey])) {
1050 $styleKey =
'sachlich';
1052 $pageTemplate = trim((
string) (
$page[
'template'] ??
'parent'));
1053 if ($pageTemplate ===
'' || $pageTemplate ===
'parent') {
1054 $pageTemplate =
'c-body1-footer';
1056 $pageContext = $this->pageContextForKi($in[
'lng'],
$page);
1057 $embeddedPolicy = $this->embeddedPolicyText($in[
'embedded_policy'], $in[
'embedded_change_notes']);
1060 'briefing_version' => self::BRIEFING_VERSION,
1061 'recipe' =>
'page.update.v1',
1062 'task' =>
'page_update',
1063 'lng' => $in[
'lng'],
1064 'page_id' => $in[
'page_id'],
1065 'page_title' => (
string) (
$page[
'title'] ??
''),
1066 'permalink' => (
string) (
$page[
'permalink'] ??
''),
1067 'writing_style' => $styleKey,
1068 'change_brief' => $in[
'change_brief'],
1069 'change_fields' => $in[
'change_fields'],
1070 'content_template' => $pageTemplate,
1071 'bootstrap_components' => $this->bootstrapComponentsMeta($in[
'bootstrap_components'] ?? array()),
1072 'embedded_content_policy' => array(
1073 'mode' => $in[
'embedded_policy'],
1074 'notes' => $in[
'embedded_change_notes'],
1075 'default' =>
'preserve existing embedded media and module calls exactly',
1078 'brief' => $in[
'hero_brief'],
1079 'image' => $this->heroImageBriefingMeta(),
1081 'custom_notes' => $in[
'custom_notes'],
1084 $jobVorlage = $this->jobVorlagePageUpdate($in,
$page);
1086 'bundle_version' => self::BRIEFING_VERSION,
1087 'title' =>
'Update: ' . (
$page[
'title'] ??
''),
1088 'recipe' =>
'page.update.v1',
1089 'lng' => $in[
'lng'],
1090 'intent' =>
'update',
1092 'auto_execute' =>
true,
1096 'lng' => $in[
'lng'],
1097 'current_page' => $this->slimPageForUpdate(
$page),
1098 'current_page_context' => $pageContext,
1101 $excerpt = $this->truncate((
string) (
$page[
'content'] ??
''), 4000);
1103 $heroChange = in_array(
'hero', $in[
'change_fields'],
true);
1105 $auftrag = $this->renderTemplateFile(
'ki-page-update-auftrag.md', array(
1106 'writing_style_prompt' => (
string) ($styles[$styleKey][
'prompt'] ??
''),
1107 'change_brief' => $in[
'change_brief'],
1108 'custom_notes' => $in[
'custom_notes'] !==
'' ? $in[
'custom_notes'] :
'(keine)',
1109 'embedded_policy' => $embeddedPolicy,
1110 'embedded_summary' => $this->embeddedSummaryForPrompt($pageContext),
1111 'zip_structure' => $this->zipStructureUpdate($heroChange),
1112 'assets_rules' => $this->assetsRulesUpdate($heroChange, $in[
'hero_brief']),
1113 'page_id' => (
string) $in[
'page_id'],
1114 'page_title' => (
string) (
$page[
'title'] ??
''),
1115 'lng' => $in[
'lng'],
1116 'permalink' => (
string) (
$page[
'permalink'] ??
''),
1117 'current_content_excerpt' => $excerpt,
1118 'content_markers_guide' => in_array(
'content', $in[
'change_fields'],
true)
1119 ? $this->contentMarkersGuide($pageTemplate, $heroChange)
1120 :
'(Inhalt wird nicht geaendert.)',
1121 'bootstrap_components_guide' => in_array(
'content', $in[
'change_fields'],
true)
1122 ? $this->bootstrapComponentsGuide($in[
'bootstrap_components'] ?? array())
1123 :
'(Inhalt wird nicht geaendert.)',
1127 'filename' =>
'dbxki-auftrag-update-seite-' . (
int) $in[
'page_id'] .
'.zip',
1128 'files' => array_merge($this->packBriefingFiles(array(
1129 'recipe' =>
'page.update.v1',
1130 'task_label' =>
'Seite #' . $in[
'page_id'] .
' aendern',
1131 'hero_assets' => $heroChange,
1132 'content_template' => $pageTemplate,
1133 'context_hint' =>
'Ja — bisheriger Seiteninhalt',
1135 'briefing' => $briefing,
1136 'job_vorlage' => $jobVorlage,
1137 'context' => $context,
1138 'auftrag' => $auftrag,
1139 )), $heroChange ? array(
'assets/README.txt' => $this->assetsReadmeHero($in[
'hero_brief'])) : array()),
1143 private function buildPageTranslatePackage(array $in): array {
1144 if ($in[
'source_id'] <= 0) {
1145 throw new \InvalidArgumentException(
'Bitte eine Quellseite waehlen.');
1147 $targets = $this->normalizeTargetLngs((array) ($in[
'target_lngs'] ?? array()),
true);
1149 throw new \InvalidArgumentException(
'Bitte mindestens eine Ziel- oder Korrektursprache waehlen.');
1152 $in[
'target_lng'] = (string) (
$targets[0] ??
'');
1154 $source = $this->loadPage($in[
'source_lng'], $in[
'source_id']);
1155 $sourceContext = $this->pageContextForKi($in[
'source_lng'],
$source);
1156 $styles = $this->writingStyles();
1157 $styleKey = $in[
'writing_style'];
1158 if (!isset($styles[$styleKey])) {
1159 $styleKey =
'sachlich';
1161 $targetLabels = $this->targetInstructionLabels($in[
'source_lng'],
$targets);
1162 $hasCorrections = in_array($in[
'source_lng'],
$targets,
true);
1163 $realTargets = array_values(array_filter(
$targets,
function (
$lng) use ($in) {
1164 return $lng !== $in[
'source_lng'];
1168 'briefing_version' => self::BRIEFING_VERSION,
1169 'recipe' =>
'translation.v1',
1170 'task' =>
'page_translate',
1171 'source_lng' => $in[
'source_lng'],
1172 'target_lng' => $in[
'target_lng'],
1174 'targets' => $targetLabels,
1175 'correction_mode_lngs' => $hasCorrections ? array($in[
'source_lng']) : array(),
1176 'source_id' => $in[
'source_id'],
1177 'source_title' => (
string) (
$source[
'title'] ??
''),
1178 'source_permalink' => (
string) (
$source[
'permalink'] ??
''),
1179 'writing_style' => $styleKey,
1180 'translation_notes' => $in[
'translation_notes'],
1181 'copy_media' => $in[
'copy_media'],
1184 $jobVorlage = $this->jobVorlagePageTranslate($in);
1186 'bundle_version' => self::BRIEFING_VERSION,
1187 'title' =>
'Uebersetzung: ' . (
$source[
'title'] ??
''),
1188 'recipe' =>
'translation.v1',
1189 'source_lng' => $in[
'source_lng'],
1190 'target_lng' => $in[
'target_lng'],
1192 'intent' => $realTargets && $hasCorrections ?
'translate_and_correct' : ($hasCorrections ?
'correct' :
'translate'),
1196 'source_lng' => $in[
'source_lng'],
1197 'target_lng' => $in[
'target_lng'],
1199 'targets' => $targetLabels,
1200 'source' => $this->slimPageForTranslation(
$source),
1201 'source_page_context' => $sourceContext,
1204 $auftrag = $this->renderTemplateFile(
'ki-page-translation-auftrag.md', array(
1205 'writing_style_prompt' => (
string) ($styles[$styleKey][
'prompt'] ??
''),
1206 'translation_notes' => $in[
'translation_notes'] !==
'' ? $in[
'translation_notes'] :
'(keine)',
1207 'source_lng' => $in[
'source_lng'],
1208 'target_lng' => $in[
'target_lng'],
1209 'target_lngs' => implode(
', ', array_map(
'strtoupper',
$targets)),
1210 'target_instructions' => $this->targetInstructionsForPrompt($in[
'source_lng'],
$targets),
1211 'source_id' => (
string) $in[
'source_id'],
1212 'source_title' => (
string) (
$source[
'title'] ??
''),
1213 'source_permalink' => (
string) (
$source[
'permalink'] ??
''),
1214 'render_reference' => (
string) ($sourceContext[
'render_reference'] ??
''),
1215 'embedded_summary' => $this->embeddedSummaryForPrompt($sourceContext),
1219 'filename' =>
'dbxki-auftrag-uebersetzung-' . (
int) $in[
'source_id'] .
'-' . implode(
'-',
$targets) .
'.zip',
1220 'files' => $this->packBriefingFiles(array(
1221 'recipe' =>
'translation.v1',
1222 'task_label' =>
'Uebersetzung/Korrektur ' . strtoupper($in[
'source_lng']) .
' -> ' . implode(
', ', array_map(
'strtoupper',
$targets)),
1223 'hero_assets' =>
false,
1224 'context_hint' =>
'Ja — Quelltext in source.content',
1225 'content_template' => (
string) ($sourceContext[
'template'] ??
''),
1227 'briefing' => $briefing,
1228 'job_vorlage' => $jobVorlage,
1229 'context' => $context,
1230 'auftrag' => $auftrag,
1235 private function jobVorlagePageCreate(array $in): array {
1237 if ($in[
'hero_enabled']) {
1240 'action' =>
'media.create_base64',
1242 'file_name' =>
'hero.jpg',
1243 'asset_ref' =>
'hero.jpg',
1244 'media_folder' =>
'img/hero',
1245 'title' => $in[
'title'],
1246 'alt' =>
'___KI_FUELLEN___',
1251 $pageParams = array(
1252 'lng' => $in[
'lng'],
1253 'folder_id' => $in[
'folder_id'],
1254 'title' => $in[
'title'],
1255 'template' => $this->contentTemplateForCreate($in[
'hero_enabled'], $in[
'content_template'] ??
''),
1256 'activ' => $in[
'activ'] ? 1 : 0,
1257 'content' =>
'___KI_FUELLEN___',
1259 if ($in[
'hero_enabled']) {
1260 $pageParams[
'hero_height'] = self::HERO_DEFAULT_HEIGHT;
1262 if (!empty($in[
'sorter'])) {
1263 $pageParams[
'sorter'] = (string) $in[
'sorter'];
1265 if ($in[
'permalink'] !==
'') {
1266 $pageParams[
'permalink'] = $in[
'permalink'];
1268 if ($in[
'description'] !==
'') {
1269 $pageParams[
'description'] = $in[
'description'];
1271 if ($in[
'keywords'] !==
'') {
1272 $pageParams[
'keywords'] = $in[
'keywords'];
1277 'action' =>
'page.create',
1278 'params' => $pageParams,
1281 if ($in[
'hero_enabled']) {
1283 'id' =>
'hero_assign',
1284 'action' =>
'media.assign',
1286 'media_id' =>
'$ref:hero.media_id',
1287 'content_id' =>
'$ref:page.page_id',
1289 'lng' => $in[
'lng'],
1294 return array(
'steps' =>
$steps);
1297 private function jobVorlagePageUpdate(array $in, array
$page): array {
1299 if (in_array(
'content', $in[
'change_fields'],
true)) {
1300 $patch[
'content'] =
'___KI_FUELLEN___';
1302 if (in_array(
'title', $in[
'change_fields'],
true)) {
1303 $patch[
'title'] =
'___KI_FUELLEN___';
1305 if (in_array(
'description', $in[
'change_fields'],
true)) {
1306 $patch[
'description'] =
'___KI_FUELLEN___';
1308 if (in_array(
'hero', $in[
'change_fields'],
true)) {
1309 $currentHeroHeight = trim((
string) (
$page[
'hero_height'] ??
''));
1310 if ($currentHeroHeight ===
'' || $currentHeroHeight ===
'parent') {
1311 $patch[
'hero_height'] = self::HERO_DEFAULT_HEIGHT;
1316 if (in_array(
'hero', $in[
'change_fields'],
true)) {
1319 'action' =>
'media.create_base64',
1321 'file_name' =>
'hero.jpg',
1322 'asset_ref' =>
'hero.jpg',
1323 'media_folder' =>
'img/hero',
1324 'title' => (
string) (
$page[
'title'] ??
'Hero'),
1325 'alt' =>
'___KI_FUELLEN___',
1329 'id' =>
'hero_assign',
1330 'action' =>
'media.assign',
1332 'media_id' =>
'$ref:hero.media_id',
1333 'content_id' => (
int) $in[
'page_id'],
1335 'lng' => $in[
'lng'],
1342 'action' =>
'page.update',
1344 'lng' => $in[
'lng'],
1345 'id' => (
int) $in[
'page_id'],
1350 return array(
'steps' =>
$steps);
1353 private function jobVorlagePageTranslate(array $in): array {
1355 foreach ((array) ($in[
'target_lngs'] ?? array($in[
'target_lng'])) as $targetLng) {
1356 $targetLng = strtolower(trim((
string) $targetLng));
1357 if ($targetLng ===
'') {
1360 if ($targetLng === $in[
'source_lng']) {
1362 'id' =>
'proofread_' . $targetLng,
1363 'action' =>
'page.update',
1365 'lng' => $in[
'source_lng'],
1366 'id' => (
int) $in[
'source_id'],
1368 'title' =>
'___KI_FUELLEN___',
1369 'description' =>
'___KI_FUELLEN___',
1370 'keywords' =>
'___KI_FUELLEN___',
1371 'content' =>
'___KI_FUELLEN___',
1378 'id' =>
'translation_' . $targetLng,
1379 'action' =>
'translation.apply',
1381 'source_lng' => $in[
'source_lng'],
1382 'target_lng' => $targetLng,
1383 'source_id' => (
int) $in[
'source_id'],
1384 'copy_media' => $in[
'copy_media'] ? 1 : 0,
1385 'translation' => array(
1386 'title' =>
'___KI_FUELLEN___',
1387 'description' =>
'___KI_FUELLEN___',
1388 'keywords' =>
'___KI_FUELLEN___',
1389 'content' =>
'___KI_FUELLEN___',
1399 private function packBriefingFiles(array $in): array {
1400 $recipe = (string) ($in[
'recipe'] ??
'');
1401 $heroAssets = !empty($in[
'hero_assets']);
1403 '00-START.md' => $this->buildStartMd(
1405 (
string) ($in[
'task_label'] ??
''),
1407 (
string) ($in[
'context_hint'] ??
''),
1408 (
string) ($in[
'content_template'] ??
'')
1410 'manifest.json' => $in[
'manifest'],
1411 'briefing.json' => $in[
'briefing'],
1412 'job.vorlage.json' => $in[
'job_vorlage'],
1413 'context.json' => $in[
'context'],
1414 'KI-AUFTRAG.md' => $in[
'auftrag'],
1415 'bundle.rules.json' => $this->bundleRulesForRecipe($recipe, $heroAssets, (
string) ($in[
'content_template'] ??
'')),
1419 private function buildStartMd(
string $recipe,
string $taskLabel,
bool $heroAssets,
string $contextHint,
string $contentTemplate =
''): string {
1420 if ($contentTemplate ===
'') {
1421 $contentTemplate = $heroAssets ? self::CONTENT_TEMPLATE_DEFAULT :
'parent';
1423 $zipExtra = $heroAssets
1424 ?
"- `assets/hero.jpg` (" . $this->heroImageSpecText() .
")\n"
1426 $assetsShort = $heroAssets
1427 ?
'**Ja:** genau `assets/hero.jpg` — ' . $this->heroImageSpecText() .
'. `asset_ref` = `hero.jpg` (nicht aendern).'
1428 :
'**Nein.** Keinen `assets/` Ordner anlegen.';
1429 return $this->renderTemplateFile(
'ki-start.md', array(
1430 'task_label' => $taskLabel,
1431 'recipe' => $recipe,
1432 'zip_extra' => $zipExtra,
1433 'assets_short' => $assetsShort,
1434 'context_hint' => $contextHint,
1435 'content_layout_short' => $this->contentMarkersGuideShort($contentTemplate),
1439 private function bundleRulesForRecipe(
string $recipe,
bool $withHero =
false,
string $contentTemplate =
''): array {
1442 case 'page.create.v1':
1444 ? array(
'media.create_base64',
'page.create',
'media.assign')
1445 : array(
'page.create');
1447 case 'page.update.v1':
1449 ? array(
'page.hero_replace_image',
'page.hero_create_image',
'media.create_base64',
'page.update',
'media.assign')
1450 : array(
'page.update');
1452 case 'translation.v1':
1453 $actions = array(
'translation.apply',
'page.update');
1457 'recipe' => $recipe,
1459 'refs' =>
'$ref:{step_id}.{field}',
1460 'asset_ref' =>
'Dateiname relativ zu assets/, z.B. hero.jpg',
1461 'forbidden' => array(
'*.delete',
'data_base64'),
1462 'content' => $this->contentRulesForBriefing($contentTemplate, $withHero),
1466 private function contentRulesForBriefing(
string $contentTemplate,
bool $withHero): array {
1467 if ($contentTemplate ===
'') {
1468 $contentTemplate = $withHero ? self::CONTENT_TEMPLATE_DEFAULT :
'parent';
1470 $slots = $this->analyzeTemplateSlots($contentTemplate);
1471 $markers = $this->contentMarkersMeta(
$slots);
1473 'format' =>
'HTML in page.create/page.update content oder translation.content',
1474 'marker_type' =>
'hr',
1475 'markers' => $markers,
1476 'marker_order' => array(
'hero',
'header',
'footer'),
1477 'marker_attributes' =>
'class="dbx-cms-marker", data-dbx-marker="dbx:hero|header|footer", data-label',
1478 'sections' => array(
1479 'before_hero' =>
'Hero-Text ({cms:hero_text})',
1480 'between_hero_and_header' =>
'Header ({cms:header}) — eigener Abschnitt nach Hero, vor Body',
1481 'between_header_and_footer' =>
'Body ({cms:col1})',
1482 'after_footer' =>
'Footer ({cms:footer})',
1484 'missing_marker' =>
'Bereich entfaellt, Text gehoert zum Body',
1485 'no_column_markers' =>
'Spalten-Marker (col2/col3a/col3b) nicht von der KI setzen',
1486 'hero_image' =>
'Hero-Bild nicht im HTML. Bestehendes Hero aendern: page.hero_replace_image. Neues Hero setzen: page.hero_create_image oder media.create_base64 + media.assign mit media_folder=img/hero.',
1487 'fake_hero_forbidden' =>
'Ein Seitenkopf aus Inline-Bild plus position-relative/position-absolute Textebene ist verboten. Das Bild gehoert in slot=hero/hero_image_id; der Text vor den dbx:hero-Marker.',
1488 'hero_height_default' => self::HERO_DEFAULT_HEIGHT .
' in page.create/page.update setzen, wenn ein Hero-Bild neu angelegt wird und nichts anderes angegeben ist',
1489 'hero_text_default' =>
'Hero-Text maximal ' . self::HERO_TEXT_MAX_LINES .
' Zeilen, wenn nicht anders angegeben',
1490 'inline_images' =>
'CMS-Medien nie als files/media/... in img src setzen. Nach media.create_* inline_src oder inline_img verwenden: index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid={id} plus data-cms-media-id.',
1491 'package_pages' =>
'Paket-Detailseiten (dbxapp-paket-*) per page.update mit patch.package_product_image=true auf home-package-* Produktbild umstellen. page.get liefert package_hint.',
1493 $rules[
'template'] = $contentTemplate;
1498 private function sanitizeEmbeddedPolicy(
string $policy): string {
1499 $policy = strtolower(trim($policy));
1500 return in_array($policy, array(
'preserve',
'reorder',
'remove'),
true) ? $policy :
'preserve';
1503 private function embeddedPolicyText(
string $policy,
string $notes): string {
1504 $policy = $this->sanitizeEmbeddedPolicy($policy);
1505 $notes = trim($notes);
1507 'Standard: Bestehende eingebettete Medien, Videos und `[modul=...]...[/modul]`-Aufrufe exakt beibehalten.',
1508 'Keine bestehenden Medienpfade manuell umschreiben; dbxKi/CMS-Befehle loesen Speicherpfade automatisch.',
1510 if ($policy ===
'reorder') {
1511 $lines[] =
'Erlaubt: vorhandene Medien/Module neu anordnen, aber nur soweit es zur gewuenschten Aenderung passt.';
1512 } elseif ($policy ===
'remove') {
1513 $lines[] =
'Erlaubt: vorhandene Medien/Module entfernen, aber nur wenn unten konkret beschrieben ist, was weg soll.';
1515 $lines[] =
'Nicht erlaubt: Medien/Module entfernen, ersetzen oder neu anordnen.';
1517 if ($notes !==
'') {
1518 $lines[] =
'Konkrete Anweisung zu Medien/Modulen: ' . $notes;
1520 return implode(
"\n", $lines);
1523 private function moduleCallsFromContent(
string $content): array {
1525 if (preg_match_all(
'/\[modul=([A-Za-z0-9_]+)\]([\s\S]*?)\[\/modul\]/i',
$content, $m, PREG_SET_ORDER)) {
1526 foreach ($m as $idx => $match) {
1528 'index' => $idx + 1,
1529 'modul' => (
string) ($match[1] ??
''),
1530 'params' => trim((
string) ($match[2] ??
'')),
1531 'marker' => (
string) ($match[0] ??
''),
1538 private function inlineMediaIdsFromContent(
string $content): array {
1540 if (preg_match_all(
'/data-cms-media-id=["\']?([0-9]+)/i',
$content, $m)) {
1541 foreach ($m[1] as $id) {
1542 $ids[(int) $id] = (
int) $id;
1545 if (preg_match_all(
'/(?:dbx_mid|media_id)=([0-9]+)/i',
$content, $m)) {
1546 foreach ($m[1] as $id) {
1547 $ids[(int) $id] = (
int) $id;
1550 return array_values(array_filter($ids));
1553 private function mediaContextForPage(
int $pageId,
string $content): array {
1554 $db =
dbx()->get_system_obj(
'dbxDB');
1555 $mediaIds = $this->inlineMediaIdsFromContent(
$content);
1556 $usageRows =
$db->select(
'dbxMediaUsage', dbxContentMediaUsageScope::withLanguage(
'content_id = ' . (
int)
$pageId .
' AND active = 1'),
'*',
'slot,sorter,id',
'ASC',
'', 0, 0, 0);
1557 if (!is_array($usageRows)) {
1558 $usageRows = array();
1560 foreach ($usageRows as $usage) {
1561 $id = (int) ($usage[
'media_id'] ?? 0);
1563 $mediaIds[$id] = $id;
1568 foreach (array_values(array_unique($mediaIds)) as $id) {
1569 $media =
$db->select1(
'dbxMedia', (
int) $id);
1571 $rows[] = array(
'id' => (
int) $id,
'missing' => 1);
1574 $usage = array_values(array_filter($usageRows,
function ($row) use ($id) {
1575 return (
int) ($row[
'media_id'] ?? 0) === (
int) $id;
1579 'title' => (
string) (
$media[
'title'] ??
$media[
'file_name'] ??
''),
1580 'file_name' => (
string) (
$media[
'file_name'] ??
''),
1581 'media_type' => (
string) (
$media[
'media_type'] ??
''),
1582 'mime' => (
string) (
$media[
'mime'] ??
''),
1583 'slots' => array_values(array_unique(array_map(
function ($row) {
1584 return (
string) ($row[
'slot'] ??
'');
1586 'inline_reference' => in_array((
int) $id, $this->inlineMediaIdsFromContent(
$content),
true) ? 1 : 0,
1592 private function renderedTextForPage(
string $lng,
int $pageId): string {
1594 $renderer =
dbx()->get_include_obj(
'dbxContentRenderer',
'dbxContent');
1598 return $this->truncate(strip_tags((
string)
$html), 12000);
1599 }
catch (\Throwable $e) {
1604 private function pageContextForKi(
string $lng, array
$page): array {
1608 'render_reference' =>
'[modul=dbxContent]dbx_run1=show&cid=' .
$pageId .
'[/modul]',
1609 'folder_label' => $this->folderLabel(
$lng, (
int) (
$page[
'folder'] ?? 0)),
1610 'template' => (
string) (
$page[
'template'] ??
''),
1611 'rendered_text_excerpt' => $this->renderedTextForPage(
$lng,
$pageId),
1613 'module_calls' => $this->moduleCallsFromContent(
$content),
1615 'default' =>
'Preserve embedded media and module calls exactly unless the briefing explicitly allows reorder/remove.',
1616 'media_paths' =>
'Do not rewrite existing media paths manually. Keep existing HTML/media references or use dbxKi media steps for new hero assets.',
1621 private function embeddedSummaryForPrompt(array $context): string {
1622 $media = is_array($context[
'embedded_media'] ?? null) ? $context[
'embedded_media'] : array();
1623 $modules = is_array($context[
'module_calls'] ??
null) ? $context[
'module_calls'] : array();
1625 $lines[] =
'Eingebettete Medien: ' . count(
$media);
1626 foreach (
$media as $row) {
1627 $label =
'#' . (int) ($row[
'id'] ?? 0);
1628 $title = trim((
string) ($row[
'title'] ?? $row[
'file_name'] ??
''));
1629 $slots = implode(
',', array_filter((array) ($row[
'slots'] ?? array())));
1630 $lines[] =
'- ' . $label . ($title !==
'' ?
' ' . $title :
'') . (
$slots !==
'' ?
' [' .
$slots .
']' :
'');
1632 $lines[] =
'Modul-Aufrufe: ' . count(
$modules);
1634 $lines[] =
'- [modul=' . (string) (
$call[
'modul'] ??
'') .
']' . (string) (
$call[
'params'] ??
'') .
'[/modul]';
1636 return implode(
"\n", $lines);
1639 private function renderPageContextHtml(
string $lng, array
$page): string {
1640 $context = $this->pageContextForKi(
$lng,
$page);
1641 $media = is_array($context[
'embedded_media'] ??
null) ? $context[
'embedded_media'] : array();
1642 $modules = is_array($context[
'module_calls'] ??
null) ? $context[
'module_calls'] : array();
1644 foreach (
$media as $row) {
1645 $title = trim((
string) ($row[
'title'] ?? $row[
'file_name'] ??
''));
1646 $slots = implode(
', ', array_filter((array) ($row[
'slots'] ?? array())));
1647 $mediaHtml .=
'<li><code>#' . (int) ($row[
'id'] ?? 0) .
'</code> '
1648 . $this->
esc($title !==
'' ? $title :
'Medium')
1649 . (
$slots !==
'' ?
' <span class="text-muted">(' . $this->
esc(
$slots) .
')</span>' :
'')
1652 if ($mediaHtml ===
'') {
1653 $mediaHtml =
'<li class="text-muted">Keine eingebetteten Medien erkannt.</li>';
1658 $moduleHtml .=
'<li><code>[modul=' . $this->
esc($call[
'modul'] ??
'') .
']</code> '
1659 .
'<span class="text-muted">' . $this->
esc($call[
'params'] ??
'') .
'</span></li>';
1661 if ($moduleHtml ===
'') {
1662 $moduleHtml =
'<li class="text-muted">Keine Modul-Aufrufe erkannt.</li>';
1665 return $this->tpl()->get_tpl(
'dbxKi|ki-briefing-page-update-context', array(
1666 'page_id' => (
string) (
int) (
$page[
'id'] ?? 0),
1667 'page_title' => $this->
esc((
string) ($page[
'title'] ??
'')),
1668 'folder_label' => $this->
esc((
string) ($context[
'folder_label'] ??
'')),
1669 'template' => $this->
esc((
string) ($context[
'template'] ??
'')),
1670 'permalink' => $this->
esc((
string) ($page[
'permalink'] ??
'')),
1671 'media_count' => (
string) count(
$media),
1672 'module_count' => (
string) count(
$modules),
1673 'media_items' => $mediaHtml,
1674 'module_items' => $moduleHtml,
1678 private function createPlacementTitle(
string $lng,
int $folderId,
int $afterPageId): string {
1679 $folder = $folderId > 0 ? $this->folderLabel(
$lng, $folderId) :
'';
1680 if ($afterPageId > 0) {
1682 $page = $this->loadPage(
$lng, $afterPageId);
1683 return 'Unter #' . $afterPageId .
' ' . (string) (
$page[
'title'] ??
'') .
' in ' . $folder;
1684 }
catch (\Throwable $e) {
1685 return $folder !==
'' ? $folder :
'Zielposition waehlen';
1688 return $folder !==
'' ? $folder .
' / am Ende' :
'Zielposition waehlen';
1691 private function renderCreatePlacementHtml(
string $lng,
int $folderId,
int $afterPageId): string {
1692 $folderLabel = $folderId > 0 ? $this->folderLabel(
$lng, $folderId) :
'';
1695 if ($afterPageId > 0) {
1697 $page = $this->loadPage(
$lng, $afterPageId);
1698 $pageTitle = (string) (
$page[
'title'] ??
'');
1699 $folderId = (int) (
$page[
'folder'] ?? $folderId);
1700 $folderLabel = $this->folderLabel(
$lng, $folderId);
1701 $sorter = $this->sorterAfterPage(
$lng, $afterPageId);
1702 }
catch (\Throwable $e) {
1706 return $this->tpl()->get_tpl(
'dbxKi|ki-briefing-page-create-placement', array(
1707 'folder_id' => $folderId > 0 ? (
string) $folderId :
'-',
1708 'folder_label' => $this->
esc($folderLabel !==
'' ? $folderLabel :
'Noch kein Zielordner gewaehlt'),
1709 'after_page_id' => $afterPageId > 0 ? (
string) $afterPageId :
'-',
1710 'after_page_title' => $this->
esc($pageTitle !==
'' ? $pageTitle :
'Keine Seite als Sortieranker'),
1711 'sorter' => $this->
esc($sorter !==
'' ? $sorter :
'automatisch am Ende'),
1715 private function slimPageForUpdate(array
$page): array {
1717 'id' => (int) (
$page[
'id'] ?? 0),
1718 'title' => (string) (
$page[
'title'] ??
''),
1719 'permalink' => (string) (
$page[
'permalink'] ??
''),
1720 'description' => (string) (
$page[
'description'] ??
''),
1721 'keywords' => (string) (
$page[
'keywords'] ??
''),
1722 'content' => (string) (
$page[
'content'] ??
''),
1726 private function slimPageForTranslation(array
$page): array {
1728 'id' => (int) (
$page[
'id'] ?? 0),
1729 'title' => (string) (
$page[
'title'] ??
''),
1730 'permalink' => (string) (
$page[
'permalink'] ??
''),
1731 'description' => (string) (
$page[
'description'] ??
''),
1732 'keywords' => (string) (
$page[
'keywords'] ??
''),
1733 'content' => (string) (
$page[
'content'] ??
''),
1737 private function sendZipDownload(array
$package): void {
1738 $tmp = tempnam(sys_get_temp_dir(),
'dbxki_brief');
1739 $zip = new \ZipArchive();
1740 if ($zip->open(
$tmp, \ZipArchive::OVERWRITE) !==
true) {
1742 throw new \RuntimeException(
'ZIP konnte nicht erstellt werden.');
1747 $content = json_encode(
$content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
1749 $zip->addFromString($path, (
string)
$content);
1753 $name = (string) (
$package[
'filename'] ??
'dbxki-auftrag.zip');
1754 header(
'Content-Type: application/zip');
1755 header(
'Content-Disposition: attachment; filename="' . str_replace(
'"',
'', $name) .
'"');
1756 header(
'Content-Length: ' . filesize(
$tmp));
1762 private function writingStyles(): array {
1763 return dbxKiWritingStyles::all();
1766 private function zipStructureCreate(
bool $heroEnabled): string {
1768 return "antwort.zip\n"
1769 .
"├── manifest.json\n"
1775 return "antwort.zip\n"
1776 .
"├── manifest.json\n"
1781 private function zipStructureUpdate(
bool $heroChange): string {
1783 return "antwort.zip\n"
1784 .
"├── manifest.json\n"
1790 return "antwort.zip\n"
1791 .
"├── manifest.json\n"
1796 private function assetsRulesCreate(
bool $heroEnabled,
string $heroBrief): string {
1798 $brief = $heroBrief !==
'' ? $heroBrief :
'passend zum Seitenthema';
1799 return "**Hero-Bild ist vorgesehen** (`briefing.hero.enabled = true`):\n\n"
1800 .
"1. Lege **genau eine** Datei an: `assets/hero.jpg` (" . $this->heroImageSpecText() .
").\n"
1801 .
"2. Motiv: " . $brief .
"\n"
1802 .
"3. In `job.json` den Step `hero` **nicht** aendern — `asset_ref` bleibt `hero.jpg`.\n"
1803 .
"4. Hero-Medienordner bleibt `img/hero`.\n"
1804 .
"5. **Kein** `data_base64` eintragen. **Keine** weiteren Dateien in `assets/`.\n"
1805 .
"6. Alt-Text (`alt` im hero-Step) ausfuellen.";
1807 return "**Kein Hero-Bild** (`briefing.hero.enabled = false`):\n\n"
1808 .
"1. **Keinen** `assets/` Ordner in der Antwort-ZIP anlegen.\n"
1809 .
"2. Keine Medien-Steps — `job.vorlage.json` enthaelt nur `page.create`.\n"
1810 .
"3. Nicht ueber Bilder nachdenken; direkt Text/HTML in `content` schreiben.";
1813 private function assetsRulesUpdate(
bool $heroChange,
string $heroBrief): string {
1815 $brief = $heroBrief !==
'' ? $heroBrief :
'passend zur Seite';
1816 return "**Neues Hero-Bild** (`hero` in change_fields):\n\n"
1817 .
"1. Lege `assets/hero.jpg` an (" . $this->heroImageSpecText() .
"). Motiv: " . $brief .
"\n"
1818 .
"2. Fuer ein wirklich neues Hero-Bild: neue Medienverknuepfung in `img/hero` setzen.\n"
1819 .
"3. Fuer eine reine Aenderung des bestehenden Hero-Bildes: nur die bestehende Hero-Datei ersetzen, keine neue Verknuepfung.\n"
1820 .
"4. `asset_ref` bleibt `hero.jpg`. Kein `data_base64`. Keine weiteren Assets.";
1822 return "**Kein Hero-Wechsel** (kein `hero` in change_fields):\n\n"
1823 .
"1. **Keinen** `assets/` Ordner anlegen.\n"
1824 .
"2. Nur `page.update` in `job.json` — keine Medien-Steps.";
1827 private function assetsReadmeHero(
string $heroBrief): string {
1828 return
"KI: Lege hier die Datei hero.jpg ab.\n\n"
1829 .
"Pfad in der Antwort-ZIP: assets/hero.jpg\n"
1830 .
"Groesse: " . $this->heroImageSpecText() .
"\n"
1831 .
"In job.json: asset_ref = hero.jpg (bereits in job.vorlage.json)\n"
1832 .
"Motiv: " . ($heroBrief !==
'' ? $heroBrief :
'passend zum Seitenthema') .
"\n";
1835 private function renderTemplateFile(
string $basename, array $vars): string {
1836 $path = dirname(__DIR__) .
'/tpl/briefing/' . $basename;
1837 if (!is_file($path)) {
1840 $text = file_get_contents($path);
1841 if (!is_string($text)) {
1844 foreach ($vars as $key =>
$value) {
1845 $text = str_replace(
'{' . $key .
'}', (
string)
$value, $text);
1850 private function menschAnleitungCreate(
string $title): string {
1851 return
"# Anleitung fuer Menschen\n\n"
1852 .
"## Schritt 1 — Formular (erledigt)\n\n"
1853 .
"Sie haben den Auftrag **" . $title .
"** spezifiziert.\n\n"
1854 .
"## Schritt 2 — ZIP an die KI\n\n"
1855 .
"1. Diese ZIP bei ChatGPT, DeepSeek o.ae. hochladen **oder**\n"
1856 .
"2. Den Inhalt von `KI-AUFTRAG.md` kopieren und einfügen.\n\n"
1857 .
"Sagen Sie der KI: *„Arbeite KI-AUFTRAG.md exakt ab. Assets-Regeln nicht abweichen. Liefere antwort.zip mit job.json.“*\n\n"
1858 .
"## Schritt 3 — Antwort importieren\n\n"
1859 .
"1. In dbXapp: **dbxKi → Bundle importieren**\n"
1860 .
"2. Die ZIP der KI hochladen\n"
1861 .
"3. Vorschau pruefen → **Ausfuehren**\n";
1864 private function menschAnleitungUpdate(): string {
1865 return
"# Anleitung fuer Menschen\n\n"
1866 .
"1. ZIP an die KI geben (siehe KI-AUFTRAG.md)\n"
1867 .
"2. Fertige ZIP mit `job.json` zurueck erhalten\n"
1868 .
"3. Unter dbxKi → Bundle importieren und ausfuehren\n";
1871 private function menschAnleitungTranslate(
string $title,
string $targetLng): string {
1872 return
"# Anleitung fuer Menschen\n\n"
1873 .
"## Schritt 1 — Formular (erledigt)\n\n"
1874 .
"Uebersetzungsauftrag fuer **" . $title .
"** nach **" . strtoupper($targetLng) .
"**.\n\n"
1875 .
"## Schritt 2 — ZIP an die KI\n\n"
1876 .
"1. Diese ZIP bei ChatGPT, DeepSeek o.ae. hochladen **oder**\n"
1877 .
"2. Den Inhalt von `KI-AUFTRAG.md` kopieren und einfügen.\n\n"
1878 .
"Sagen Sie der KI: *„Arbeite KI-AUFTRAG.md exakt ab und liefere antwort.zip mit job.json.“*\n\n"
1879 .
"## Schritt 3 — Antwort importieren\n\n"
1880 .
"1. In dbXapp: **dbxKi → Bundle importieren**\n"
1881 .
"2. Die ZIP der KI hochladen\n"
1882 .
"3. Vorschau pruefen → **Ausfuehren**\n";
1885 private function buildLngOptions(
string $selected): string {
1886 $lngs = $this->availableLngs();
1888 foreach ($lngs as
$lng) {
1889 $lng = strtolower(trim((
string)
$lng));
1893 $sel =
$lng === $selected ?
' selected' :
'';
1894 $html .=
'<option value="' . $this->
esc($lng) .
'"' . $sel .
'>' . strtoupper($this->
esc($lng)) .
'</option>';
1899 private function buildTargetLngCheckboxes(
string $sourceLng, array $selected): string {
1900 $selected = $this->normalizeTargetLngs($selected, true);
1902 foreach ($this->availableLngs() as
$lng) {
1903 $lng = strtolower(trim((
string)
$lng));
1907 $checked = in_array(
$lng, $selected,
true) ?
' checked' :
'';
1908 $isSource =
$lng === $sourceLng;
1909 $id =
'dbxKiTargetLng_' . preg_replace(
'/[^a-z0-9_]/',
'_',
$lng);
1910 $html .=
'<label class="dbx-ki-lng-choice' . ($isSource ?
' is-source' :
'') .
'" data-ki-target-lng="' . $this->
esc($lng) .
'">'
1911 .
'<input type="checkbox" name="target_lngs[]" id="' . $this->
esc($id) .
'" value="' . $this->
esc($lng) .
'"' .
$checked .
'>'
1912 .
'<span><strong>' . strtoupper($this->
esc($lng)) .
'</strong><small data-ki-target-mode>'
1913 . ($isSource ?
'Rechtschreibung/Grammatik' :
'Uebersetzung')
1920 private function buildTargetLngOptions(
string $sourceLng,
string $selected): string {
1921 $lngs = $this->availableLngs();
1923 foreach ($lngs as
$lng) {
1924 $lng = strtolower(trim((
string)
$lng));
1925 if (
$lng ===
'' ||
$lng === $sourceLng) {
1928 $sel =
$lng === $selected ?
' selected' :
'';
1929 $html .=
'<option value="' . $this->
esc($lng) .
'"' . $sel .
'>' . strtoupper($this->
esc($lng)) .
'</option>';
1934 private function selectedTargetLngsFromRequest(
string $sourceLng,
bool $defaultAllOthers): array {
1935 $raw =
dbx()->get_request_var(
'target_lngs', array(),
'*');
1936 if (!is_array($raw)) {
1937 $raw = $raw !==
'' ? array($raw) : array();
1939 $fallback = strtolower(trim((
string)
dbx()->get_request_var(
'target_lng',
'',
'*')));
1943 $selected = $this->normalizeTargetLngs($raw,
true);
1944 if (!$selected && $defaultAllOthers) {
1945 foreach ($this->availableLngs() as
$lng) {
1946 if (
$lng !== $sourceLng) {
1951 return array_values(array_unique($selected));
1954 private function normalizeTargetLngs(array $lngs,
bool $allowSource): array {
1955 $allowed = array_fill_keys($this->availableLngs(), true);
1957 foreach ($lngs as
$lng) {
1958 $lng = strtolower(trim((
string)
$lng));
1962 if (!$allowSource &&
$lng === strtolower(trim((
string) dbxContentLng::current()))) {
1967 return array_values(
$out);
1970 private function availableLngs(): array {
1972 if (class_exists(dbxContentLngSync::class)) {
1973 $lngs = dbxContentLngSync::accessibleLngs();
1975 if (!is_array($lngs) || !$lngs) {
1976 $lngs = array(dbxContentLng::current());
1979 foreach ($lngs as
$lng) {
1980 $lng = strtolower(trim((
string)
$lng));
1985 return array_values(
$out);
1988 private function targetInstructionLabels(
string $sourceLng, array
$targets): array {
1992 'lng' => $targetLng,
1993 'mode' => $targetLng === $sourceLng ?
'proofread' :
'translate',
1994 'label' => $targetLng === $sourceLng
1995 ? strtoupper($targetLng) .
': Rechtschreib- und Grammatikpruefung der Quellsprache'
1996 : strtoupper($sourceLng) .
' -> ' . strtoupper($targetLng) .
': Uebersetzung',
2002 private function targetInstructionsForPrompt(
string $sourceLng, array
$targets): string {
2005 if ($targetLng === $sourceLng) {
2006 $lines[] =
'- ' . strtoupper($targetLng) .
': Kein Sprachwechsel. Korrigiere Rechtschreibung, Grammatik, Zeichensetzung und offensichtliche Tippfehler. Inhalt, Sinn, HTML-Struktur, Medien und Modul-Aufrufe beibehalten. Nutze den `page.update`-Step `proofread_' . $targetLng .
'`.';
2008 $lines[] =
'- ' . strtoupper($sourceLng) .
' -> ' . strtoupper($targetLng) .
': Vollstaendige Uebersetzung aller Felder. Nutze den `translation.apply`-Step `translation_' . $targetLng .
'`.';
2011 return implode(
"\n", $lines);
2014 private function buildFolderOptions(
string $lng,
int $selected): string {
2015 $labels = $this->folderLabels(
$lng);
2016 $html =
'<option value="">— Ordner waehlen —</option>';
2017 foreach ($labels as $id => $label) {
2018 $sel = ((int) $id === $selected) ?
' selected' :
'';
2019 $html .=
'<option value="' . (int) $id .
'"' . $sel .
'>' . $this->
esc($label) .
' (#' . (int) $id .
')</option>';
2024 private function buildPageOptions(
string $lng,
int $selected): string {
2025 $snap = $this->cms()->bundleSnapshot(array(
'lng' =>
$lng,
'limit' => 300));
2026 $folders = $this->folderLabels(
$lng);
2027 $rows = is_array($snap[
'pages'][
'rows'] ??
null) ? $snap[
'pages'][
'rows'] : array();
2028 $html =
'<option value="">— Seite waehlen —</option>';
2029 foreach (
$rows as $row) {
2030 if (!is_array($row)) {
2033 $id = (int) ($row[
'id'] ?? 0);
2037 $fid = (int) ($row[
'folder'] ?? 0);
2038 $folder = $folders[$fid] ?? (
'Ordner #' . $fid);
2039 $title = trim((
string) ($row[
'title'] ??
''));
2040 $sel = ($id === $selected) ?
' selected' :
'';
2041 $html .=
'<option value="' . $id .
'"' . $sel .
'>' . $this->
esc($title) .
' — ' . $this->
esc($folder) .
' (#' . $id .
')</option>';
2046 private function buildStyleOptions(
string $selected): string {
2048 foreach ($this->writingStyles() as $key => $meta) {
2049 $sel = ($key === $selected) ?
' selected' :
'';
2050 $html .=
'<option value="' . $this->
esc($key) .
'"' . $sel .
'>' . $this->
esc($meta[
'label'] ?? $key) .
'</option>';
2055 private function folderLabels(
string $lng): array {
2056 $snap = $this->cms()->bundleSnapshot(array(
'lng' =>
$lng,
'limit' => 500));
2057 $rows = is_array($snap[
'folders'][
'rows'] ??
null) ? $snap[
'folders'][
'rows'] : array();
2059 foreach (
$rows as $row) {
2060 if (!is_array($row)) {
2063 $byId[(int) ($row[
'id'] ?? 0)] = $row;
2066 foreach (
$byId as $id => $row) {
2070 while ($cur > 0 && isset(
$byId[$cur]) && $guard++ < 25) {
2071 array_unshift($parts, (
string) (
$byId[$cur][
'name'] ??
''));
2072 $cur = (int) (
$byId[$cur][
'parent_id'] ?? 0);
2074 $labels[$id] = implode(
' / ', array_filter($parts));
2076 asort($labels, SORT_NATURAL | SORT_FLAG_CASE);
2080 private function folderLabel(
string $lng,
int $folderId): string {
2081 $labels = $this->folderLabels(
$lng);
2082 return (
string) ($labels[$folderId] ?? (
'Ordner #' . $folderId));
2085 private function sorterAfterPage(
string $lng,
int $pageId): string {
2086 $db =
dbx()->get_system_obj(
'dbxDB');
2087 $dd = dbxContentLng::ddContent(
$lng);
2089 if (!is_array(
$page)) {
2092 $folder = (int) (
$page[
'folder'] ?? 0);
2093 $sorter = (int) (
$page[
'sorter'] ?? 0);
2097 $rows =
$db->select(
$dd,
'folder = ' . $folder .
' AND sorter > ' . $sorter,
'sorter,id',
'sorter,id',
'ASC',
'', 1, 0, 0);
2098 $nextSorter = is_array(
$rows) && isset(
$rows[0]) ? (int) (
$rows[0][
'sorter'] ?? 0) : 0;
2099 if ($nextSorter > ($sorter + 1)) {
2100 return sprintf(
'%04d', $sorter + 1);
2102 return sprintf(
'%04d', $sorter);
2105 private function loadPage(
string $lng,
int $pageId): array {
2106 $db =
dbx()->get_system_obj(
'dbxDB');
2108 if (!is_array($row)) {
2109 throw new \InvalidArgumentException(
'Seite nicht gefunden.');
2114 private function pageContentExcerpt(
string $lng,
int $pageId): string {
2117 return $this->truncate(strip_tags((
string) ($row[
'content'] ??
'')), 2000);
2118 }
catch (\Throwable $e) {
2123 private function truncate(
string $text,
int $max): string {
2124 $text = trim(preg_replace(
'/\s+/u',
' ', $text));
2125 if (mb_strlen($text) <= $max) {
2128 return mb_substr($text, 0, $max) .
'…';