17 private const TOKEN_SCOPE =
'dbxKi.cms.execute';
18 private const API_VERSION =
'0.1';
23 $this->db =
dbx()->get_system_obj(
'dbxDB');
26 public function handle(
string $route =
'api'): void {
28 $request = $this->request();
29 if ($route ===
'describe') {
30 $request[
'action'] =
'system.describe';
31 } elseif ($route ===
'preview') {
32 $request[
'mode'] =
'preview';
33 } elseif ($route ===
'execute') {
34 $request[
'mode'] =
'execute';
37 $action = strtolower(trim((
string)($request[
'action'] ??
'system.describe')));
38 $mode = strtolower(trim((
string)($request[
'mode'] ??
'preview')));
39 $params = is_array($request[
'params'] ??
null) ? $request[
'params'] : array();
41 if ($action ===
'system.describe') {
42 $this->respond($this->describe());
45 if ($action ===
'bundle.describe') {
46 $bundle = dbx()->get_include_obj(
'dbxKiBundleService',
'dbxKi');
47 $this->respond($bundle->describeBundle());
50 if ($action ===
'system.health') {
51 $this->respond($this->health());
54 $catalog = $this->catalog();
55 if (!isset($catalog[$action])) {
56 $this->fail(
'unknown_action',
'Unbekannte Aktion.', array(
58 'available_actions' => array_keys($catalog),
62 $write = (bool)($catalog[$action][
'write'] ??
false);
64 $result = $this->read_action($action, $params);
67 'api_version' => self::API_VERSION,
74 if (!in_array($mode, array(
'preview',
'execute'),
true)) {
75 $this->fail(
'invalid_mode',
'Erlaubte Modi sind preview und execute.');
78 $plan = $this->build_plan($action, $params);
79 $planId = $this->plan_id($action, $plan);
81 if ($mode ===
'preview') {
84 'api_version' => self::API_VERSION,
87 'will_execute' =>
false,
90 'execute_request' => array(
93 'token' => dbx()->action_token(self::TOKEN_SCOPE),
94 'expected_plan_id' => $planId,
95 'confirm' => (
bool)($catalog[$action][
'destructive'] ??
false),
101 $this->authorize_execute($request, (
bool)($catalog[$action][
'destructive'] ??
false));
102 $expected = trim((
string)($request[
'expected_plan_id'] ??
''));
103 if ($expected !==
'' && !hash_equals($planId, $expected)) {
104 $this->fail(
'plan_changed',
'Der aktuelle Plan stimmt nicht mehr mit expected_plan_id überein.', array(
105 'expected_plan_id' => $expected,
106 'current_plan_id' => $planId,
107 'current_plan' => $plan,
111 $result = $this->execute_action($action, $params, $plan);
116 'KI-CMS-Aktion ausgeführt',
117 'uid=' . (
int)dbx()->user() .
' plan=' . $planId
120 $this->respond(array(
122 'api_version' => self::API_VERSION,
126 'plan_id' => $planId,
129 }
catch (\Throwable $e) {
130 dbx()->sys_msg(
'error',
'dbxKi',
'api',
'Ausnahme', $e->getMessage());
131 $this->fail(
'exception', $e->getMessage());
135 private function request(): array {
137 $raw = file_get_contents(
'php://input');
138 if (is_string($raw) && trim($raw) !==
'') {
139 $json = json_decode($raw,
true);
140 if (is_array($json)) {
145 foreach (array(
'action',
'mode',
'token',
'expected_plan_id',
'confirm') as $key) {
146 if (!array_key_exists($key, $request)) {
147 $value =
dbx()->get_request_var($key,
null);
148 if ($value !==
null) {
149 $request[$key] = $value;
154 if (!isset($request[
'params']) || !is_array($request[
'params'])) {
155 $params =
dbx()->get_request_var(
'params', array(),
'*');
156 if (is_string($params) && trim($params) !==
'') {
157 $decoded = json_decode($params,
true);
158 $params = is_array($decoded) ? $decoded : array();
160 $request[
'params'] = is_array($params) ? $params : array();
166 private function respond(array $data): void {
167 dbx()->json_response($data, true);
170 private function fail(
string $code,
string $message, array $details = array()): void {
171 $this->respond(array(
173 'api_version' => self::API_VERSION,
176 'message' => $message,
177 'details' => $details,
182 private function authorize_execute(array $request,
bool $destructive): void {
183 if ((int)
dbx()->get_config(
'dbxKi',
'allow_execute', 1) !== 1) {
184 $this->fail(
'execute_disabled',
'Automatische Ausführung ist in der Modulkonfiguration deaktiviert.');
186 $token = trim((
string)($request[
'token'] ??
''));
187 if (!
dbx()->check_action_token(self::TOKEN_SCOPE, $token)) {
188 $this->fail(
'invalid_token',
'Ungültiges oder abgelaufenes Aktions-Token.');
190 if ($destructive && !$this->bool_value($request[
'confirm'] ??
false)) {
191 $this->fail(
'confirmation_required',
'Diese Aktion löscht Daten. Für automatische Ausführung confirm=true senden.');
195 private function describe(): array {
198 'api_version' => self::API_VERSION,
200 'purpose' =>
'KI-optimierte Bedienung des dbXapp-CMS ohne direkten SQL-Zugriff.',
201 'endpoint' =>
'?dbx_modul=dbxKi&dbx_run1=api',
202 'authentication' => array(
203 'read_and_preview' =>
'Normale dbXapp-Modulberechtigung.',
204 'execute' =>
'Admin-Sitzung plus token.',
205 'token' =>
dbx()->action_token(self::TOKEN_SCOPE),
206 'token_scope' => self::TOKEN_SCOPE,
209 'method' =>
'GET oder POST; für komplexe Daten POST mit application/json verwenden.',
211 'action' =>
'Eine Aktion aus actions.',
212 'mode' =>
'preview oder execute; bei Leseaktionen wird mode ignoriert.',
213 'params' =>
'Aktionsparameter.',
214 'token' =>
'Nur für execute.',
215 'expected_plan_id' =>
'Optional. Verhindert Ausführung, falls sich der Plan seit preview geändert hat.',
216 'confirm' =>
'Bei Löschaktionen für execute zwingend true.',
218 'automation' => array(
219 'safe' =>
'Erst preview aufrufen und execute_request aus der Antwort unverändert senden.',
220 'direct' =>
'Für vollautomatische Ausführung action, mode=execute, token und params direkt senden.',
221 'rule' =>
'Keine SQL-Befehle erzeugen. Ausschließlich diese Aktionen verwenden.',
224 'page_workflows' => $this->page_workflows(),
225 'languages' => dbxContentLngSync::accessibleLngs(),
226 'actions' => $this->catalog(),
228 'preview_page_create' => array(
229 'action' =>
'page.create',
234 'title' =>
'Neue KI-Seite',
235 'content' =>
'<p>Inhalt</p>',
238 'automatic_page_update' => array(
239 'action' =>
'page.update',
241 'token' =>
dbx()->action_token(self::TOKEN_SCOPE),
245 'title' =>
'Aktualisierter Titel',
248 'translation' => array(
249 'action' =>
'translation.apply',
251 'token' =>
dbx()->action_token(self::TOKEN_SCOPE),
253 'source_lng' =>
'de',
254 'target_lng' =>
'en',
256 'translation' => array(
257 'title' =>
'Translated title',
258 'description' =>
'Translated description',
259 'keywords' =>
'translated, keywords',
260 'content' =>
'<p>Translated content</p>',
268 private function page_workflows(): array {
271 'ki_role' =>
'Die KI erzeugt nur JSON-Dateien und optionale Assets. dbxKi importiert, prueft und fuehrt alles aus.',
272 'no_external_tools' =>
'Keine eigenen PHP-, SQL-, Shell-, Python- oder Node-Tools fuer CMS-Aenderungen erzeugen.',
273 'delivery' =>
'antwort.zip mit manifest.json, job.json, optional assets/ und README.md; alternativ job_json im dbxKi-Importformular.',
274 'auto_execute' =>
'Wenn dbxKi nach erfolgreicher Pruefung automatisch ausfuehren soll: manifest.auto_execute = true setzen.',
276 'page_create' => array(
277 'guide_action' =>
'page.create_guide',
279 'Arbeitskontext mit cms.snapshot oder page.create_guide lesen.',
280 'Neue Medien zuerst mit media.create_base64 oder media.create_image_variant als Step anlegen.',
281 'page.create mit lng, folder_id, title, template, content, description, keywords, activ anlegen.',
282 'Inline-Bilder im content immer mit $ref:{media_step}.inline_src und data-cms-media-id setzen.',
283 'Verwendete Inline-/Gallery-/Hero-Medien mit media.assign zuordnen.',
284 'dbxKi importiert job.json, validiert jeden Step und fuehrt den Prozess aus.',
286 'fixed_rules' => array(
287 'folder_id, lng und title aus dem Auftrag nicht eigenmaechtig aendern.',
288 'template nur aus Auftrag/Guide verwenden; bei Root-Seiten nie parent verwenden.',
289 'Kein SQL, keine direkten Dateipfade files/media/... in img src.',
290 'HTML ist erlaubt; Bootstrap-5-Klassen sind erlaubt; kein eigenes JavaScript.',
291 'Hero-Bilder unter img/hero, Gallery-Bilder unter img/gallery, normale Inline-Bilder unter img/images.',
294 'page_update' => array(
295 'guide_action' =>
'page.update_guide',
297 'Bestehende Seite mit page.get oder page.update_guide lesen.',
298 'Nur Felder aendern, die im Auftrag/change_fields erlaubt sind.',
299 'Bei content-Aenderung vorhandene data-cms-media-id, dbx_mid-URLs, Links und Modulaufrufe erhalten, ausser der Auftrag fordert Aenderung.',
300 'Bestehendes Hero-Bild ersetzen: page.hero_replace_image. Neues Hero-Bild setzen: page.hero_create_image.',
301 'page.update nur fuer Seitenfelder verwenden; Medien danach bei Bedarf mit media.assign verknuepfen.',
302 'dbxKi importiert job.json, validiert jeden Step und fuehrt den Prozess aus.',
304 'fixed_rules' => array(
305 'id, lng und permalink der Zielseite nicht eigenmaechtig aendern.',
306 'Kein page.delete in KI-Auftraegen.',
307 'Keine vorhandenen Medienpfade manuell umschreiben.',
308 'Wenn content nicht in change_fields steht, content unveraendert lassen.',
314 private function page_create_guide(array $params): array {
315 $lng = $this->language($params[
'lng'] ??
'');
316 $folderId = max(0, (
int)($params[
'folder_id'] ?? $params[
'folder'] ?? 0));
317 $title = $this->clean($params[
'title'] ??
'___TITEL___', 254);
318 $withHero = $this->bool_value($params[
'with_hero'] ??
false);
319 $withGallery = $this->bool_value($params[
'with_gallery'] ??
false);
320 $template = $this->clean($params[
'template'] ?? ($withHero ?
'c-title-hero_header-gallery-body1-footer' :
'c-body1-footer'), 254);
321 if ($folderId === 0 && strtolower($template) ===
'parent') {
322 $template =
'c-body1-footer';
329 'action' =>
'media.create_base64',
331 'asset_ref' =>
'hero.jpg',
332 'file_name' =>
'hero.jpg',
333 'media_folder' =>
'img/hero',
334 'title' => $title .
' Hero',
342 'action' =>
'media.create_base64',
344 'asset_ref' =>
'gallery-1.jpg',
345 'file_name' =>
'gallery-1.jpg',
346 'media_folder' =>
'img/gallery',
347 'title' => $title .
' Galerie',
354 'action' =>
'page.create',
357 'folder_id' => $folderId,
359 'template' => $template,
360 'hero_height' => $withHero ?
'300px' :
'parent',
361 'description' =>
'___SEO_BESCHREIBUNG___',
362 'keywords' =>
'___KEYWORDS___',
364 'content' =>
'___HTML_CONTENT___',
369 'id' =>
'hero_assign',
370 'action' =>
'media.assign',
373 'media_id' =>
'$ref:hero.media_id',
374 'content_id' =>
'$ref:page.page_id',
381 'id' =>
'gallery_assign_1',
382 'action' =>
'media.assign',
385 'media_id' =>
'$ref:gallery_1.media_id',
386 'content_id' =>
'$ref:page.page_id',
393 'workflow' => $this->page_workflows()[
'page_create'],
396 'recipe' =>
'page.create.v1',
398 'auto_execute' =>
true,
400 'job' => array(
'steps' => $steps),
401 'content_contract' => $this->content_contract(),
405 private function page_update_guide(array $params): array {
406 $lng = $this->language($params[
'lng'] ??
'');
407 $id = max(0, (
int)($params[
'id'] ?? 0));
408 $heroMode = strtolower(trim((
string)($params[
'hero_mode'] ??
'none')));
409 if (!in_array($heroMode, array(
'none',
'replace',
'create'),
true)) {
412 $fields = $params[
'change_fields'] ?? array(
'content');
414 $fields = array_values(array_filter(array_map(
'trim', explode(
',',
$fields))));
421 if ($heroMode ===
'replace') {
423 'id' =>
'hero_replace',
424 'action' =>
'page.hero_replace_image',
428 'source_file' =>
'assets/hero.jpg',
434 } elseif ($heroMode ===
'create') {
436 'id' =>
'hero_create',
437 'action' =>
'page.hero_create_image',
441 'source_file' =>
'assets/hero.jpg',
442 'file_name' =>
'hero.jpg',
453 if (
$field ===
'')
continue;
454 $patch[
$field] =
$field ===
'content' ?
'___HTML_CONTENT___' :
'___' . strtoupper(
$field) .
'___';
458 'id' =>
'page_update',
459 'action' =>
'page.update',
471 $current = $this->page_get(array(
'lng' => $lng,
'id' => $id));
472 }
catch (\Throwable $e) {
473 $current = array(
'error' => $e->getMessage());
478 'workflow' => $this->page_workflows()[
'page_update'],
479 'target' => array(
'lng' => $lng,
'id' => $id,
'change_fields' => array_values(
$fields),
'hero_mode' => $heroMode),
480 'current_page' => $current,
482 'title' =>
'Seite ' . $id .
' aktualisieren',
483 'recipe' =>
'page.update.v1',
485 'auto_execute' =>
true,
487 'job' => array(
'steps' => $steps),
488 'content_contract' => $this->content_contract(),
492 private function content_contract(): array {
494 'html_allowed' => true,
495 'bootstrap_allowed' => true,
496 'forbidden' => array(
'SQL',
'direkte SQLite-Aenderungen',
'eigene PHP-Tools',
'eigene JavaScript-Logik im Content',
'files/media/... als img src'),
497 'inline_media' =>
'Immer inline_src/index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid={id} plus data-cms-media-id verwenden.',
498 'openwin' =>
'openWin nur ueber class dbx-win und data-dbx="lib=openWin|url=...|title=...|width=...|height=..." verwenden.',
500 'dbx:hero' =>
'Text davor wird Hero-Text.',
501 'dbx:header' =>
'Text danach bis zum naechsten Marker wird Header.',
502 'dbx:footer' =>
'Text danach wird Footer.',
507 private function catalog(): array {
509 'system.health' => array(
511 'description' =>
'Prüft Modul, Benutzer, Sprachen und CMS-Datenzugriff.',
514 'cms.snapshot' => array(
516 'description' =>
'Liefert Ordner, Seiten und Medien in einem begrenzten Arbeitskontext.',
517 'params' => array(
'lng' =>
'Sprachcode',
'folder_id' =>
'Optionaler Ordner',
'limit' =>
'1..200'),
519 'folder.list' => array(
521 'description' =>
'Listet CMS-Ordner.',
522 'params' => array(
'lng' =>
'Sprachcode',
'parent_id' =>
'Optionaler Parent',
'limit' =>
'1..500'),
524 'folder.get' => array(
526 'description' =>
'Liest einen Ordner.',
527 'params' => array(
'lng' =>
'Sprachcode',
'id' =>
'Ordner-ID'),
529 'folder.create' => array(
531 'description' =>
'Erstellt einen Ordner.',
532 'required' => array(
'name'),
534 'lng' =>
'Sprachcode',
535 'name' =>
'Bezeichnung',
536 'parent_id' =>
'Parent-ID, Standard 0',
537 'group_read' =>
'parent oder kommaseparierte Gruppen',
538 'template' =>
'Content-Template',
539 'hero_*' =>
'Optionale Hero-Einstellungen',
542 'folder.update' => array(
544 'description' =>
'Ändert oder verschiebt einen Ordner.',
545 'required' => array(
'id'),
546 'params' => array(
'lng' =>
'Sprachcode',
'id' =>
'Ordner-ID',
'patch' =>
'Zu ändernde Felder oder Felder direkt in params'),
548 'folder.delete' => array(
550 'destructive' => true,
551 'description' =>
'Löscht einen leeren Ordner in einer Sprache.',
552 'required' => array(
'id'),
553 'params' => array(
'lng' =>
'Sprachcode',
'id' =>
'Ordner-ID'),
555 'page.list' => array(
557 'description' =>
'Listet CMS-Seiten.',
558 'params' => array(
'lng' =>
'Sprachcode',
'folder_id' =>
'Optionaler Ordner',
'limit' =>
'1..500'),
562 'description' =>
'Liest eine Seite einschließlich Medienzuordnungen.',
563 'params' => array(
'lng' =>
'Sprachcode',
'id' =>
'Seiten-ID'),
565 'page.create_guide' => array(
567 'description' =>
'Liefert den verbindlichen KI-Ablauf, Regeln und ein job.json-Skelett fuer das Anlegen einer CMS-Seite.',
568 'params' => array(
'lng' =>
'Sprachcode',
'folder_id' =>
'Zielordner',
'title' =>
'Seitentitel',
'with_hero' =>
'0/1',
'with_gallery' =>
'0/1'),
570 'page.update_guide' => array(
572 'description' =>
'Liefert den verbindlichen KI-Ablauf, Regeln und ein job.json-Skelett fuer das Aendern einer CMS-Seite.',
573 'params' => array(
'lng' =>
'Sprachcode',
'id' =>
'Seiten-ID',
'change_fields' =>
'Liste erlaubter Felder',
'hero_mode' =>
'none, replace oder create'),
575 'page.create' => array(
577 'description' =>
'Erstellt eine CMS-Seite.',
578 'required' => array(
'title'),
580 'lng' =>
'Sprachcode',
581 'folder_id' =>
'Ordner-ID',
583 'content' =>
'HTML-Inhalt',
584 'description' =>
'Meta-Beschreibung',
585 'keywords' =>
'Meta-Keywords',
586 'permalink' =>
'Optional; wird sonst erzeugt',
587 'activ' =>
'0 oder 1',
588 'template' =>
'Content-Template',
591 'page.update' => array(
593 'description' =>
'Aktualisiert ausgewählte Seitenfelder. Inline-Bilder in content werden automatisch auf CMS-Medien-URLs (dbx_mid) normalisiert.',
594 'required' => array(
'id'),
596 'lng' =>
'Sprachcode',
598 'patch' =>
'Zu ändernde Felder oder Felder direkt in params',
599 'package_product_image' =>
'Optional 1: Paket-Card auf vorhandenes Produktbild (home-package-*) umstellen',
600 'package_media_id' =>
'Optional: Medien-ID statt Permalink-Zuordnung',
601 'package_image_alt' =>
'Optional: alt-Text fuer das Produktbild',
604 'page.hero_replace_image' => array(
606 'description' =>
'Ersetzt nur die bestehende Hero-Bilddatei einer Seite. Medienverknüpfung und Seitenfelder bleiben unverändert.',
607 'required' => array(
'id',
'source_file'),
609 'lng' =>
'Sprachcode',
611 'source_file' =>
'Absolute oder dbxapp-relative neue Bildquelle',
612 'width' =>
'Optional; Standard Breite des bestehenden Hero-Mediums',
613 'height' =>
'Optional; Standard Höhe des bestehenden Hero-Mediums',
614 'fit' =>
'cover oder contain, Standard cover',
615 'quality' =>
'1..100, Standard 82',
618 'page.hero_create_image' => array(
620 'description' =>
'Erstellt ein neues Hero-Bild in files/media/img/hero und setzt es als Hero der Seite.',
621 'required' => array(
'id',
'source_file'),
623 'lng' =>
'Sprachcode',
625 'source_file' =>
'Absolute oder dbxapp-relative Bildquelle',
626 'file_name' =>
'Optionaler Dateiname, Standard aus Permalink',
627 'width' =>
'Zielbreite, Standard 1280',
628 'height' =>
'Zielhöhe, Standard 300',
629 'fit' =>
'cover oder contain, Standard cover',
630 'quality' =>
'1..100, Standard 82',
633 'page.delete' => array(
635 'destructive' => true,
636 'description' =>
'Löscht eine Seite in einer Sprache und deaktiviert ihre Medienzuordnungen.',
637 'required' => array(
'id'),
638 'params' => array(
'lng' =>
'Sprachcode',
'id' =>
'Seiten-ID'),
640 'media.list' => array(
642 'description' =>
'Listet aktive Medien und optional deren Zuordnungen.',
643 'params' => array(
'media_type' =>
'image, video, file oder external_video',
'folder' =>
'Medienordner',
'limit' =>
'1..500'),
645 'media.get' => array(
647 'description' =>
'Liest ein Medium und seine aktiven Verwendungen.',
648 'params' => array(
'id' =>
'Medien-ID'),
650 'module.assets' => array(
652 'description' =>
'Listet vorhandene Modulbilder aus dbx/modules/*/tpl/mod und files/mod fuer Content- und Modul-Visualisierungen.',
653 'params' => array(
'module' =>
'Optionaler Modulname',
'limit' =>
'1..500'),
655 'media.create_base64' => array(
657 'description' =>
'Speichert eine Base64-Datei über dbXapp und registriert sie als Medium. Liefert inline_src/inline_img fuer die Content-Einbindung.',
658 'required' => array(
'file_name',
'data_base64'),
660 'file_name' =>
'Dateiname mit Endung',
661 'data_base64' =>
'Reines Base64 oder Data-URL',
662 'media_folder' =>
'Standard img/images, img/video oder file/ki; Hero immer img/hero, Gallery immer img/gallery',
664 'alt' =>
'Alternativtext',
665 'caption' =>
'Bildunterschrift',
668 'returns' => array(
'id',
'row',
'inline_src',
'inline_img'),
669 'usage' =>
'Im Content immer inline_src oder inline_img verwenden. Niemals files/media/... direkt in img src setzen.',
671 'media.create_image_variant' => array(
673 'description' =>
'Erzeugt aus einer lokalen Bildquelle eine zugeschnittene, skalierte und optional farblich getönte Bildvariante und registriert sie als Medium. Liefert inline_src/inline_img fuer die Content-Einbindung.',
674 'required' => array(
'source_file',
'file_name'),
676 'source_file' =>
'Absolute oder dbxapp-relative Quelldatei',
677 'file_name' =>
'Zieldateiname mit .webp, .jpg, .jpeg oder .png',
678 'width' =>
'Zielbreite, Standard Originalbreite',
679 'height' =>
'Zielhöhe, Standard Originalhöhe',
680 'fit' =>
'cover oder contain, Standard cover',
681 'crop_x/crop_y/crop_width/crop_height' =>
'Optionaler Quell-Ausschnitt in Pixeln vor dem Skalieren',
682 'tint' =>
'Optionale Farbe als #RRGGBB',
683 'tint_strength' =>
'0..1, Standard 0',
684 'quality' =>
'1..100, Standard 82',
685 'media_folder' =>
'Standard img/images; Hero immer img/hero, Gallery immer img/gallery',
687 'alt' =>
'Alternativtext',
688 'caption' =>
'Bildunterschrift',
691 'returns' => array(
'id',
'row',
'inline_src',
'inline_img'),
692 'usage' =>
'Im Content immer inline_src oder inline_img verwenden. Niemals files/media/... direkt in img src setzen.',
694 'media.update' => array(
696 'description' =>
'Ändert Metadaten eines Mediums.',
697 'required' => array(
'id'),
698 'params' => array(
'id' =>
'Medien-ID',
'patch' =>
'title, alt, caption, tags, template'),
700 'media.assign' => array(
702 'description' =>
'Ordnet ein Medium einer Seite oder einem Ordner zu.',
703 'required' => array(
'media_id'),
705 'media_id' =>
'Medien-ID',
706 'content_id' =>
'Seiten-ID',
707 'folder_id' =>
'Ordner-ID',
708 'slot' =>
'hero, gallery, inline, header, teaser oder footer',
709 'template' =>
'Darstellungs-Template',
710 'caption' =>
'Kontextspezifische Bildunterschrift',
711 'settings' =>
'Objekt oder JSON-Text',
714 'media.unassign' => array(
716 'description' =>
'Deaktiviert eine Medienzuordnung.',
717 'required' => array(
'usage_id'),
718 'params' => array(
'usage_id' =>
'ID aus dbxMediaUsage'),
720 'media.delete' => array(
722 'destructive' => true,
723 'description' =>
'Löscht ein unbenutztes Medium einschließlich lokaler Datei.',
724 'required' => array(
'id'),
725 'params' => array(
'id' =>
'Medien-ID'),
727 'translation.preview' => array(
729 'description' =>
'Liefert Quelltext, vorhandenes Ziel und genaue Übersetzungsanweisung.',
730 'required' => array(
'source_lng',
'target_lng',
'source_id'),
731 'params' => array(
'source_lng' =>
'Quellsprache',
'target_lng' =>
'Zielsprache',
'source_id' =>
'Quellseiten-ID'),
733 'translation.apply' => array(
735 'description' =>
'Speichert eine von der KI gelieferte Übersetzung; kein externer Übersetzungsdienst nötig.',
736 'required' => array(
'source_lng',
'target_lng',
'source_id',
'translation'),
738 'source_lng' =>
'Quellsprache',
739 'target_lng' =>
'Zielsprache',
740 'source_id' =>
'Quellseiten-ID',
741 'translation' =>
'Objekt mit title, description, keywords und content',
742 'copy_media' =>
'1 kopiert aktive Medienzuordnungen, Standard 1',
745 'translation.sync_all' => array(
747 'description' =>
'Übersetzt eine komplette CMS-Sprachstruktur aus einer Quellsprache in eine oder mehrere Zielsprachen.',
748 'required' => array(
'source_lng'),
750 'source_lng' =>
'Quellsprache',
751 'target_lngs' =>
'Optional: Array oder kommaseparierte Zielsprachen; Standard alle aktiven Sprachen außer source_lng',
752 'root_folder_id' =>
'Optional: Ordner-Teilbaum; Standard 0 = alle Ordner und Seiten',
753 'update_existing' =>
'1 aktualisiert vorhandene Zielseiten/-ordner, Standard 1',
754 'skip_manual' =>
'1 überspringt Ziel-Datensätze mit lng_sync=manual, Standard 0',
755 'copy_media' =>
'1 kopiert aktive Medienzuordnungen, Standard 1',
756 'replace_media_usage' =>
'1 ersetzt Medienzuordnungen der Zielseite; Standard 0 = nur fehlende ergänzen',
762 private function health(): array {
763 $this->ensure_schema();
766 'api_version' => self::API_VERSION,
767 'user_id' => (
int)
dbx()->user(),
769 'execute_enabled' => (
int)
dbx()->get_config(
'dbxKi',
'allow_execute', 1),
770 'languages' => dbxContentLngSync::accessibleLngs(),
771 'master_language' => dbxContentLngSync::masterLng(),
772 'content_count' => $this->db->count(dbxContentLng::ddContent($this->language(
''))),
773 'folder_count' => $this->db->count(dbxContentLng::ddFolder($this->language(
''))),
774 'media_count' => $this->db->count(
'dbxMedia',
'active = 1'),
778 private function read_action(
string $action, array $params) {
779 $this->ensure_schema();
782 return $this->snapshot($params);
784 return $this->folder_list($params);
786 return $this->folder_get($params);
788 return $this->page_list($params);
790 return $this->page_get($params);
791 case 'page.create_guide':
792 return $this->page_create_guide($params);
793 case 'page.update_guide':
794 return $this->page_update_guide($params);
796 return $this->media_list($params);
798 return $this->media_get($params);
799 case 'module.assets':
800 return $this->module_assets($params);
801 case 'translation.preview':
802 return $this->translation_preview($params);
804 throw new \RuntimeException(
'Leseaktion nicht implementiert: ' . $action);
807 private function build_plan(
string $action, array $params): array {
808 $this->ensure_schema();
810 case 'folder.create':
811 return $this->plan_folder_create($params);
812 case 'folder.update':
813 return $this->plan_folder_update($params);
814 case 'folder.delete':
815 return $this->plan_folder_delete($params);
817 return $this->plan_page_create($params);
819 return $this->plan_page_update($params);
820 case 'page.hero_replace_image':
821 return $this->plan_page_hero_replace_image($params);
822 case 'page.hero_create_image':
823 return $this->plan_page_hero_create_image($params);
825 return $this->plan_page_delete($params);
826 case 'media.create_base64':
827 return $this->plan_media_create($params);
828 case 'media.create_image_variant':
829 return $this->plan_media_create_image_variant($params);
831 return $this->plan_media_update($params);
833 return $this->plan_media_assign($params);
834 case 'media.unassign':
835 return $this->plan_media_unassign($params);
837 return $this->plan_media_delete($params);
838 case 'translation.apply':
839 return $this->plan_translation_apply($params);
840 case 'translation.sync_all':
841 return $this->plan_translation_sync_all($params);
843 throw new \RuntimeException(
'Planung nicht implementiert: ' . $action);
846 private function execute_action(
string $action, array $params, array $plan) {
848 case 'folder.create':
849 return $this->execute_folder_create($plan);
850 case 'folder.update':
851 return $this->execute_folder_update($plan);
852 case 'folder.delete':
853 return $this->execute_folder_delete($plan);
855 return $this->execute_page_create($plan);
857 return $this->execute_page_update($plan);
858 case 'page.hero_replace_image':
859 return $this->execute_page_hero_replace_image($plan);
860 case 'page.hero_create_image':
861 return $this->execute_page_hero_create_image($plan);
863 return $this->execute_page_delete($plan);
864 case 'media.create_base64':
865 return $this->execute_media_create($params, $plan);
866 case 'media.create_image_variant':
867 return $this->execute_media_create_image_variant($plan);
869 return $this->execute_media_update($plan);
871 return $this->execute_media_assign($plan);
872 case 'media.unassign':
873 return $this->execute_media_unassign($plan);
875 return $this->execute_media_delete($plan);
876 case 'translation.apply':
877 return $this->execute_translation_apply($params, $plan);
878 case 'translation.sync_all':
879 return $this->execute_translation_sync_all($plan);
881 throw new \RuntimeException(
'Ausführung nicht implementiert: ' . $action);
884 private function ensure_schema(): void {
885 if (!is_object($this->db)) {
886 throw new \RuntimeException(
'dbxDB ist nicht verfügbar.');
888 dbxContentLngSync::ensureSchema($this->db);
891 private function language($value): string {
892 $lng = strtolower(trim((string)$value));
894 $lng = dbxContentLng::current();
896 if (!in_array($lng, dbxContentLngSync::accessibleLngs(),
true)) {
897 throw new \InvalidArgumentException(
'Nicht unterstützte Sprache: ' . $lng);
902 private function id(array $params,
string $key =
'id'): int {
903 $id = (int)($params[$key] ?? 0);
905 throw new \InvalidArgumentException($key .
' muss größer als 0 sein.');
910 private function limit(array $params,
int $default = 100,
int $max = 500): int {
911 return max(1, min($max, (int)($params[
'limit'] ?? $default)));
914 private function bool_value($value): bool {
915 if (is_bool($value)) return $value;
916 return in_array(strtolower(trim((
string)$value)), array(
'1',
'true',
'yes',
'ja',
'on'),
true);
919 private function clean($value,
int $max = 0): string {
920 $value = trim((string)$value);
922 $value = mb_substr($value, 0, $max);
927 private function plan_id(
string $action, array $plan): string {
928 return hash(
'sha256', $action .
'|' . json_encode($plan, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
931 private function snapshot(array $params): array {
933 'language' => $this->language($params[
'lng'] ??
''),
934 'folders' => $this->folder_list($params),
935 'pages' => $this->page_list($params),
936 'media' => $this->media_list(array(
'limit' => $this->limit($params, 100, 200))),
937 'module_assets' => $this->module_assets(array(
'limit' => 200)),
941 private function folder_list(array $params): array {
942 $lng = $this->language($params[
'lng'] ??
'');
944 if (array_key_exists(
'parent_id', $params)) {
945 $where =
'parent_id = ' . max(0, (
int)$params[
'parent_id']);
947 $rows = $this->db->select(dbxContentLng::ddFolder($lng), $where,
'*',
'sorter,id',
'ASC',
'', $this->limit($params), 0, 1);
948 return array(
'lng' => $lng,
'rows' => is_array($rows) ? $rows : array());
951 private function folder_get(array $params): array {
952 $lng = $this->language($params[
'lng'] ??
'');
953 $id = $this->id($params);
954 $row = $this->db->select1(dbxContentLng::ddFolder($lng), $id);
955 if (!is_array($row))
throw new \RuntimeException(
'Ordner nicht gefunden.');
956 return array(
'lng' => $lng,
'row' => $row);
959 private function page_list(array $params): array {
960 $lng = $this->language($params[
'lng'] ??
'');
962 if (array_key_exists(
'folder_id', $params)) {
963 $where =
'folder = ' . max(0, (
int)$params[
'folder_id']);
965 $rows = $this->db->select(dbxContentLng::ddContent($lng), $where,
'*',
'folder,sorter,id',
'ASC',
'', $this->limit($params), 0, 1);
966 return array(
'lng' => $lng,
'rows' => is_array($rows) ? $rows : array());
969 private function page_get(array $params): array {
970 $lng = $this->language($params[
'lng'] ??
'');
971 $id = $this->id($params);
972 $row = $this->db->select1(dbxContentLng::ddContent($lng), $id);
973 if (!is_array($row))
throw new \RuntimeException(
'Seite nicht gefunden.');
974 $usage = $this->db->select(
'dbxMediaUsage',
'content_id = ' . $id .
' AND active = 1',
'*',
'slot,sorter,id',
'ASC');
975 $hint = $this->package_page_hint($row);
979 'media_usage' => is_array($usage) ? $usage : array(),
980 'package_hint' => $hint,
984 private function media_list(array $params): array {
985 $rows = $this->db->select(
'dbxMedia',
'active = 1',
'*',
'id',
'DESC',
'', $this->limit($params), 0, 1);
986 $type = strtolower(trim((
string)($params[
'media_type'] ??
'')));
987 $folder = trim((
string)($params[
'folder'] ??
''));
988 $rows = array_values(array_filter(is_array($rows) ? $rows : array(),
static function($row) use ($type, $folder) {
989 if ($type !==
'' && strtolower((
string)($row[
'media_type'] ??
'')) !== $type)
return false;
990 if ($folder !==
'' && (
string)($row[
'media_folder'] ??
'') !== $folder)
return false;
993 return array(
'rows' => $rows);
996 private function media_get(array $params): array {
997 $id = $this->id($params);
998 $row = $this->db->select1(
'dbxMedia', $id);
999 if (!is_array($row))
throw new \RuntimeException(
'Medium nicht gefunden.');
1000 $usage = $this->db->select(
'dbxMediaUsage',
'media_id = ' . $id .
' AND active = 1',
'*',
'id',
'ASC');
1001 return array(
'row' => $row,
'usage' => is_array($usage) ? $usage : array());
1004 private function module_assets(array $params): array {
1006 $moduleFilter = strtolower(trim((
string)($params[
'module'] ??
'')));
1007 $limit = $this->limit($params, 200, 500);
1011 $add =
static function(
string $file,
string $source) use (&$rows, &$seen,
$base, $moduleFilter):
void {
1012 $path = str_replace(
'\\',
'/', $file);
1013 if (!is_file($file)) {
1016 if (!preg_match(
'/\.(svg|png|jpe?g|webp|gif)$/i', $path)) {
1020 $rel = str_starts_with($path,
$base) ? substr($path, strlen(
$base)) : $path;
1021 $name = basename($path);
1024 if (preg_match(
'#dbx/modules/([^/]+)/tpl/mod/([^/]+)\.[^.]+$#', $rel, $m)) {
1025 $module = (string)$m[1];
1026 $stem = (string)$m[2];
1028 $stem = preg_replace(
'/\.[^.]+$/',
'', $name);
1029 if (preg_match(
'/^([A-Za-z0-9_]+)__(.+)$/', (
string)$stem, $m)) {
1030 $module = (string)$m[1];
1031 $action = (string)$m[2];
1034 if ($action ===
'' && $module !==
'') {
1035 $prefix = $module .
'_';
1036 $stem = preg_replace(
'/\.[^.]+$/',
'', $name);
1037 $action = str_starts_with((
string)$stem, $prefix) ? substr((
string)$stem, strlen($prefix)) : (string)$stem;
1039 if ($moduleFilter !==
'' && strtolower($module) !== $moduleFilter) {
1043 if (isset($seen[$key])) {
1048 'module' => $module,
1049 'action' => $action,
1051 'source' => $source,
1054 'bytes' => filesize($file),
1058 foreach (glob(
dbx_get_base_dir() .
'dbx/modules/*/tpl/mod/*') ?: array() as $file) {
1059 $add($file,
'module_tpl_mod');
1060 if (count($rows) >= $limit) {
1064 if (count($rows) < $limit) {
1066 $add($file,
'files_mod');
1067 if (count($rows) >= $limit) {
1075 'usage' =>
'Im Content als <img src=\"{src}\" ...> verwenden. Vorhandene Modulbilder bevorzugen, wenn Module visuell dargestellt werden.',
1079 private function translation_preview(array $params): array {
1080 $sourceLng = $this->language($params[
'source_lng'] ??
'');
1081 $targetLng = $this->language($params[
'target_lng'] ??
'');
1082 if ($sourceLng === $targetLng)
throw new \InvalidArgumentException(
'Quell- und Zielsprache müssen verschieden sein.');
1083 $sourceId = $this->id($params,
'source_id');
1084 $sourceDd = dbxContentLng::ddContent($sourceLng);
1085 $source = $this->db->select1($sourceDd, $sourceId);
1086 if (!is_array($source))
throw new \RuntimeException(
'Quellseite nicht gefunden.');
1087 $uid = trim((
string)($source[
'lng_uid'] ??
''));
1088 $targetId = $uid !==
''
1089 ? dbxContentLngSync::resolveIdByUid($this->db, dbxContentLng::ddContent($targetLng), $uid, $targetLng)
1091 $target = $targetId > 0 ? $this->db->select1(dbxContentLng::ddContent($targetLng), $targetId) :
null;
1093 'source_lng' => $sourceLng,
1094 'target_lng' => $targetLng,
1095 'source' => $source,
1096 'target' => $target,
1097 'source_uid_missing' => $uid ===
'',
1098 'instruction' => array(
1099 'translate_fields' => array(
'title',
'description',
'keywords',
'content'),
1100 'preserve' =>
'HTML-Struktur, Links, data-cms-media-id, Platzhalter, IDs, CSS-Klassen und technische Attribute unverändert lassen.',
1101 'do_not_translate' =>
'Dateipfade, URLs, Modulnamen, Template-Namen, Shortcodes und Code.',
1102 'quality' =>
'Natürlich, fachlich korrekt und zur Zielsprache passend übersetzen. Keine zusätzlichen Aussagen erfinden.',
1103 'next_action' =>
'translation.apply mit translation-Objekt aufrufen.',
1108 private function plan_folder_create(array $params): array {
1109 $lng = $this->language($params[
'lng'] ??
'');
1110 $name = $this->clean($params[
'name'] ??
'', 120);
1111 if ($name ===
'')
throw new \InvalidArgumentException(
'name ist erforderlich.');
1112 $parent = max(0, (
int)($params[
'parent_id'] ?? 0));
1113 if ($parent > 0 && !is_array($this->db->select1(dbxContentLng::ddFolder($lng), $parent))) {
1114 throw new \RuntimeException(
'Parent-Ordner nicht gefunden.');
1117 'operation' =>
'insert',
1118 'entity' =>
'folder',
1120 'data' => $this->folder_data($params, $parent, $name),
1124 private function plan_folder_update(array $params): array {
1125 $lng = $this->language($params[
'lng'] ??
'');
1126 $id = $this->id($params);
1127 $dd = dbxContentLng::ddFolder($lng);
1128 $before = $this->db->select1($dd, $id);
1129 if (!is_array($before))
throw new \RuntimeException(
'Ordner nicht gefunden.');
1130 $patch = $this->patch($params);
1131 $parent = array_key_exists(
'parent_id', $patch) ? max(0, (
int)$patch[
'parent_id']) : (int)($before[
'parent_id'] ?? 0);
1132 if ($parent === $id || $this->folder_descendant($dd, $parent, $id)) {
1133 throw new \InvalidArgumentException(
'Ungültiger Parent: Schleife im Ordnerbaum.');
1135 if ($parent > 0 && !is_array($this->db->select1($dd, $parent)))
throw new \RuntimeException(
'Parent-Ordner nicht gefunden.');
1136 $allowed = array(
'name',
'parent_id',
'group_read',
'template',
'hero_template',
'hero_image_id',
'hero_margin_top',
'hero_height',
'hero_variant',
'hero_sticky',
'hero_scroll_layer',
'sorter');
1137 $data = $this->whitelist($patch, $allowed);
1138 if (isset($data[
'name'])) $data[
'name'] = $this->clean($data[
'name'], 120);
1139 if (!$data)
throw new \InvalidArgumentException(
'Keine änderbaren Felder übergeben.');
1140 return array(
'operation' =>
'update',
'entity' =>
'folder',
'lng' => $lng,
'id' => $id,
'before' => $before,
'changes' => $data);
1143 private function plan_folder_delete(array $params): array {
1144 $lng = $this->language($params[
'lng'] ??
'');
1145 $id = $this->id($params);
1146 $row = $this->db->select1(dbxContentLng::ddFolder($lng), $id);
1147 if (!is_array($row))
throw new \RuntimeException(
'Ordner nicht gefunden.');
1148 $check = dbxContentLngSync::folderDeletable($this->db, $lng, $id);
1149 if ((
int)(
$check[
'deletable'] ?? 0) !== 1) {
1150 throw new \RuntimeException((
string)(
$check[
'reason'] ??
'Ordner ist nicht löschbar.'));
1152 return array(
'operation' =>
'delete',
'entity' =>
'folder',
'lng' => $lng,
'id' => $id,
'before' => $row);
1155 private function plan_page_create(array $params): array {
1156 $lng = $this->language($params[
'lng'] ??
'');
1157 $title = $this->clean($params[
'title'] ??
'', 254);
1158 if ($title ===
'')
throw new \InvalidArgumentException(
'title ist erforderlich.');
1159 $folder = max(0, (
int)($params[
'folder_id'] ?? $params[
'folder'] ?? 0));
1160 if ($folder > 0 && !is_array($this->db->select1(dbxContentLng::ddFolder($lng), $folder))) {
1161 throw new \RuntimeException(
'Zielordner nicht gefunden.');
1164 'operation' =>
'insert',
1167 'data' => $this->page_data($params, $lng, $folder, $title),
1171 private function plan_page_update(array $params): array {
1172 $lng = $this->language($params[
'lng'] ??
'');
1173 $id = $this->id($params);
1174 $dd = dbxContentLng::ddContent($lng);
1175 $before = $this->db->select1($dd, $id);
1176 if (!is_array($before))
throw new \RuntimeException(
'Seite nicht gefunden.');
1177 $patch = $this->patch($params);
1178 if (array_key_exists(
'folder_id', $patch) && !array_key_exists(
'folder', $patch)) {
1179 $patch[
'folder'] = $patch[
'folder_id'];
1181 $packageProductImage = $this->bool_value($patch[
'package_product_image'] ??
false);
1182 $packageMediaId = max(0, (
int)($patch[
'package_media_id'] ?? 0));
1183 $packageImageAlt = $this->clean($patch[
'package_image_alt'] ??
'', 254);
1184 unset($patch[
'package_product_image'], $patch[
'package_media_id'], $patch[
'package_image_alt']);
1186 'activ',
'folder',
'title',
'permalink',
'description',
'keywords',
'group_read',
'template',
'content',
'sorter',
1187 'hero_template',
'hero_image_id',
'hero_margin_top',
'hero_height',
'hero_variant',
'hero_sticky',
1188 'hero_scroll_layer',
'gallery_template',
'gallery_visible_count',
'gallery_image_size',
1189 'gallery_lightbox_width',
'gallery_overflow',
'gallery_click_behavior'
1191 $data = $this->whitelist($patch, $allowed);
1192 if (isset($data[
'title'])) $data[
'title'] = $this->clean($data[
'title'], 254);
1193 if (isset($data[
'folder'])) {
1194 $data[
'folder'] = max(0, (
int)$data[
'folder']);
1195 if ($data[
'folder'] > 0 && !is_array($this->db->select1(dbxContentLng::ddFolder($lng), $data[
'folder']))) {
1196 throw new \RuntimeException(
'Zielordner nicht gefunden.');
1199 if (!$data && !$packageProductImage && $packageMediaId <= 0) {
1200 throw new \InvalidArgumentException(
'Keine änderbaren Felder übergeben.');
1202 if (array_key_exists(
'content', $data)) {
1203 $data[
'content'] = $this->normalize_content_inline_media_urls((
string)$data[
'content']);
1205 $packageMediaApplied = 0;
1206 if ($packageProductImage || $packageMediaId > 0) {
1207 $mediaId = $packageMediaId > 0
1209 : $this->package_media_id_for_permalink((
string)($before[
'permalink'] ??
''));
1210 if ($mediaId <= 0) {
1211 throw new \RuntimeException(
'Kein Paket-Produktbild fuer diese Seite gefunden. package_media_id angeben oder home-package-* Medium anlegen.');
1213 $content = array_key_exists(
'content', $data)
1214 ? (string)$data[
'content']
1215 : (string)($before[
'content'] ??
'');
1216 $data[
'content'] = $this->normalize_content_inline_media_urls(
1217 $this->apply_package_product_image($content, $mediaId, $packageImageAlt)
1219 $packageMediaApplied = $mediaId;
1221 $plan = array(
'operation' =>
'update',
'entity' =>
'page',
'lng' => $lng,
'id' => $id,
'before' => $before,
'changes' => $data);
1222 if ($packageMediaApplied > 0) {
1223 $plan[
'package_media_id_applied'] = $packageMediaApplied;
1228 private function plan_page_hero_replace_image(array $params): array {
1229 $lng = $this->language($params[
'lng'] ??
'');
1230 $id = $this->id($params);
1231 $hero = $this->hero_media_for_page($lng, $id);
1232 $media = $hero[
'media'];
1233 $source = $this->source_image_plan($params);
1234 $target = $this->media_local_file($media);
1235 if ($target ===
'')
throw new \RuntimeException(
'Hero-Medium ist keine lokale Datei.');
1237 $width = max(1, (
int)($params[
'width'] ?? $media[
'width'] ?? 0));
1238 $height = max(1, (
int)($params[
'height'] ?? $media[
'height'] ?? 0));
1239 if ($width <= 1 || $height <= 1) {
1243 $mime = (string)($media[
'mime'] ??
'');
1244 if (!in_array($mime, array(
'image/jpeg',
'image/png',
'image/webp'),
true)) {
1245 $mime = $this->mime_from_file_name((
string)($media[
'file_name'] ??
'hero.webp'));
1249 'operation' =>
'replace_page_hero_file',
1250 'entity' =>
'page_hero',
1253 'page' => $hero[
'page'],
1255 'usage' => $hero[
'usage'],
1256 'source' => $source,
1257 'target_file' => $target,
1259 'height' => $height,
1260 'fit' => $this->image_fit($params[
'fit'] ??
'cover'),
1261 'quality' => $this->image_quality($params[
'quality'] ?? 82),
1266 private function plan_page_hero_create_image(array $params): array {
1267 $lng = $this->language($params[
'lng'] ??
'');
1268 $id = $this->id($params);
1269 $dd = dbxContentLng::ddContent($lng);
1270 $page = $this->db->select1($dd, $id);
1271 if (!is_array($page))
throw new \RuntimeException(
'Seite nicht gefunden.');
1273 $permalink = trim((
string)($page[
'permalink'] ??
''));
1274 $baseName = $permalink !==
'' ? $permalink : (
'page-' . $id);
1275 $fileName = $this->safe_file_name($params[
'file_name'] ?? ($baseName .
'-hero.webp'));
1276 if ($fileName ===
'') $fileName =
'page-' . $id .
'-hero.webp';
1278 $variant = $this->plan_media_create_image_variant(array_merge($params, array(
1279 'file_name' => $fileName,
1280 'width' => max(1, (
int)($params[
'width'] ?? 1280)),
1281 'height' => max(1, (
int)($params[
'height'] ?? 300)),
1282 'fit' => $params[
'fit'] ??
'cover',
1283 'quality' => $params[
'quality'] ?? 82,
1284 'media_folder' =>
'img/hero',
1285 'title' => $params[
'title'] ?? (
'Hero ' . ($page[
'title'] ?? $fileName)),
1286 'alt' => $params[
'alt'] ?? (
string)($page[
'title'] ??
''),
1290 'operation' =>
'create_page_hero_media',
1291 'entity' =>
'page_hero',
1295 'media_plan' => $variant,
1299 private function plan_page_delete(array $params): array {
1300 $lng = $this->language($params[
'lng'] ??
'');
1301 $id = $this->id($params);
1302 $row = $this->db->select1(dbxContentLng::ddContent($lng), $id);
1303 if (!is_array($row))
throw new \RuntimeException(
'Seite nicht gefunden.');
1304 $usage = $this->db->count(
'dbxMediaUsage',
'content_id = ' . $id .
' AND active = 1');
1305 return array(
'operation' =>
'delete',
'entity' =>
'page',
'lng' => $lng,
'id' => $id,
'before' => $row,
'media_usage_to_deactivate' => $usage);
1308 private function plan_media_create(array $params): array {
1309 $name = $this->safe_file_name($params[
'file_name'] ??
'');
1310 $raw = (string)($params[
'data_base64'] ??
'');
1311 if ($name ===
'' || trim($raw) ===
'')
throw new \InvalidArgumentException(
'file_name und data_base64 sind erforderlich.');
1312 $decoded = $this->decode_base64($raw);
1313 $max = max(1024, (
int)
dbx()->get_config(
'dbxKi',
'max_base64_bytes', 10485760));
1314 if (strlen($decoded) > $max)
throw new \InvalidArgumentException(
'Datei überschreitet das konfigurierte Größenlimit.');
1315 $mime = $this->detect_mime($decoded, $name);
1316 $allowed = array(
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'video/mp4',
'video/webm',
'video/quicktime',
'application/pdf',
'text/plain');
1317 if (!in_array($mime, $allowed,
true))
throw new \InvalidArgumentException(
'Nicht unterstützter MIME-Typ: ' . $mime);
1318 $type = strpos($mime,
'image/') === 0 ?
'image' : (strpos($mime,
'video/') === 0 ?
'video' :
'file');
1319 $defaultFolder = $type ===
'image' ?
'img/images' : ($type ===
'video' ?
'img/video' :
'file/ki');
1320 $folder = $this->media_folder($params[
'media_folder'] ?? $defaultFolder, $type);
1322 'operation' =>
'create_file_and_insert',
1323 'entity' =>
'media',
1324 'file_name' => $name,
1325 'bytes' => strlen($decoded),
1326 'sha256' => hash(
'sha256', $decoded),
1328 'media_type' => $type,
1329 'media_folder' => $folder,
1330 'metadata' => array(
1331 'title' => $this->clean($params[
'title'] ?? pathinfo($name, PATHINFO_FILENAME), 160),
1332 'alt' => $this->clean($params[
'alt'] ??
'', 254),
1333 'caption' => $this->clean($params[
'caption'] ??
''),
1334 'tags' => $this->clean($params[
'tags'] ??
'', 254),
1339 private function plan_media_create_image_variant(array $params): array {
1340 if (!extension_loaded(
'gd')) {
1341 throw new \RuntimeException(
'GD ist erforderlich, um Bildvarianten zu erzeugen.');
1344 $source = $this->resolve_local_file((
string)($params[
'source_file'] ??
''));
1345 if ($source ===
'' || !is_file($source) || !is_readable($source)) {
1346 throw new \InvalidArgumentException(
'source_file ist nicht lesbar.');
1349 $name = $this->safe_file_name($params[
'file_name'] ??
'');
1350 if ($name ===
'')
throw new \InvalidArgumentException(
'file_name ist erforderlich.');
1351 $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
1352 $mimeMap = array(
'jpg' =>
'image/jpeg',
'jpeg' =>
'image/jpeg',
'png' =>
'image/png',
'webp' =>
'image/webp');
1353 if (!isset($mimeMap[$ext])) {
1354 throw new \InvalidArgumentException(
'file_name muss .webp, .jpg, .jpeg oder .png verwenden.');
1357 $info = @getimagesize($source);
1358 if (!is_array($info) || empty($info[0]) || empty($info[1])) {
1359 throw new \InvalidArgumentException(
'source_file ist kein lesbares Bild.');
1361 $sourceMime = (string)($info[
'mime'] ??
'');
1362 if (!in_array($sourceMime, array(
'image/jpeg',
'image/png',
'image/webp',
'image/gif'),
true)) {
1363 throw new \InvalidArgumentException(
'Nicht unterstützter Quellbildtyp: ' . $sourceMime);
1366 $sourceWidth = (int)$info[0];
1367 $sourceHeight = (int)$info[1];
1368 $crop = $this->image_crop_rect($params, $sourceWidth, $sourceHeight);
1369 $width = max(1, (
int)($params[
'width'] ?? $sourceWidth));
1370 $height = max(1, (
int)($params[
'height'] ?? $sourceHeight));
1371 $fit = strtolower(trim((
string)($params[
'fit'] ??
'cover')));
1372 if (!in_array($fit, array(
'cover',
'contain'),
true)) $fit =
'cover';
1373 $quality = min(100, max(1, (
int)($params[
'quality'] ?? 82)));
1374 $tint = $this->normalize_hex_color((
string)($params[
'tint'] ??
''));
1375 $tintStrength = max(0.0, min(1.0, (
float)($params[
'tint_strength'] ?? 0)));
1376 $folder = $this->media_folder($params[
'media_folder'] ??
'img/images',
'image');
1379 'operation' =>
'create_image_variant_and_insert',
1380 'entity' =>
'media',
1381 'source_file' => $source,
1382 'source_sha256' => hash_file(
'sha256', $source),
1383 'source_mime' => $sourceMime,
1384 'source_width' => $sourceWidth,
1385 'source_height' => $sourceHeight,
1387 'file_name' => $name,
1388 'mime' => $mimeMap[$ext],
1389 'media_type' =>
'image',
1390 'media_folder' => $folder,
1392 'height' => $height,
1394 'quality' => $quality,
1396 'tint_strength' => $tintStrength,
1397 'metadata' => array(
1398 'title' => $this->clean($params[
'title'] ?? pathinfo($name, PATHINFO_FILENAME), 160),
1399 'alt' => $this->clean($params[
'alt'] ??
'', 254),
1400 'caption' => $this->clean($params[
'caption'] ??
''),
1401 'tags' => $this->clean($params[
'tags'] ??
'', 254),
1406 private function plan_media_update(array $params): array {
1407 $id = $this->id($params);
1408 $before = $this->db->select1(
'dbxMedia', $id);
1409 if (!is_array($before) || (
int)($before[
'active'] ?? 0) !== 1)
throw new \RuntimeException(
'Medium nicht gefunden.');
1410 $data = $this->whitelist($this->patch($params), array(
'title',
'alt',
'caption',
'tags',
'template'));
1411 if (!$data)
throw new \InvalidArgumentException(
'Keine änderbaren Metadaten übergeben.');
1412 return array(
'operation' =>
'update',
'entity' =>
'media',
'id' => $id,
'before' => $before,
'changes' => $data);
1415 private function plan_media_assign(array $params): array {
1416 $mediaId = $this->id($params,
'media_id');
1417 $media = $this->db->select1(
'dbxMedia', $mediaId);
1418 if (!is_array($media) || (
int)($media[
'active'] ?? 0) !== 1)
throw new \RuntimeException(
'Medium nicht gefunden.');
1419 $contentId = max(0, (
int)($params[
'content_id'] ?? 0));
1420 $folderId = max(0, (
int)($params[
'folder_id'] ?? 0));
1421 if (($contentId > 0) === ($folderId > 0))
throw new \InvalidArgumentException(
'Genau content_id oder folder_id muss gesetzt sein.');
1422 $lng = $this->language($params[
'lng'] ??
'');
1423 if ($contentId > 0 && !is_array($this->db->select1(dbxContentLng::ddContent($lng), $contentId)))
throw new \RuntimeException(
'Seite nicht gefunden.');
1424 if ($folderId > 0 && !is_array($this->db->select1(dbxContentLng::ddFolder($lng), $folderId)))
throw new \RuntimeException(
'Ordner nicht gefunden.');
1425 $slot = $this->slot($params[
'slot'] ??
'gallery');
1427 'operation' =>
'insert',
1428 'entity' =>
'media_usage',
1433 'media_id' => $mediaId,
1434 'content_id' => $contentId,
1435 'folder_id' => $folderId,
1437 'template' => $this->clean($params[
'template'] ?? $media[
'template'] ??
'', 80),
1438 'caption' => $this->clean($params[
'caption'] ??
''),
1439 'settings' => is_array($params[
'settings'] ??
null)
1440 ? json_encode($params[
'settings'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
1441 : $this->clean($params[
'settings'] ??
''),
1446 private function plan_media_unassign(array $params): array {
1447 $id = $this->id($params,
'usage_id');
1448 $row = $this->db->select1(
'dbxMediaUsage', $id);
1449 if (!is_array($row) || (
int)($row[
'active'] ?? 0) !== 1)
throw new \RuntimeException(
'Aktive Medienzuordnung nicht gefunden.');
1450 return array(
'operation' =>
'update',
'entity' =>
'media_usage',
'id' => $id,
'before' => $row,
'changes' => array(
'active' => 0));
1453 private function plan_media_delete(array $params): array {
1454 $id = $this->id($params);
1455 $data = $this->media_get(array(
'id' => $id));
1456 if (count($data[
'usage']))
throw new \RuntimeException(
'Medium wird noch verwendet. Zuerst media.unassign ausführen.');
1457 return array(
'operation' =>
'delete',
'entity' =>
'media',
'id' => $id,
'before' => $data[
'row']);
1460 private function plan_translation_apply(array $params): array {
1461 $preview = $this->translation_preview($params);
1462 $translation = is_array($params[
'translation'] ??
null) ? $params[
'translation'] : array();
1463 foreach (array(
'title',
'description',
'keywords',
'content') as
$field) {
1464 if (!array_key_exists(
$field, $translation))
throw new \InvalidArgumentException(
'translation.' .
$field .
' fehlt.');
1466 $translation = $this->whitelist($translation, array(
'title',
'description',
'keywords',
'content'));
1467 $translation[
'title'] = $this->clean($translation[
'title'], 254);
1468 $translation[
'description'] = $this->clean($translation[
'description'], 254);
1469 $translation[
'keywords'] = $this->clean($translation[
'keywords'], 254);
1470 if ($translation[
'title'] ===
'')
throw new \InvalidArgumentException(
'Übersetzter Titel darf nicht leer sein.');
1471 $translation[
'content'] = $this->normalize_content_inline_media_urls((
string)$translation[
'content']);
1473 'operation' => is_array($preview[
'target']) ?
'update' :
'insert',
1474 'entity' =>
'translation',
1475 'source_lng' => $preview[
'source_lng'],
1476 'target_lng' => $preview[
'target_lng'],
1477 'source' => $preview[
'source'],
1478 'target' => $preview[
'target'],
1479 'translation' => $translation,
1480 'copy_media' => !array_key_exists(
'copy_media', $params) || $this->bool_value($params[
'copy_media']),
1484 private function plan_translation_sync_all(array $params): array {
1485 $sourceLng = $this->language($params[
'source_lng'] ??
'');
1486 $targetLngs = $this->target_languages($params, $sourceLng);
1487 if (!count($targetLngs)) {
1488 throw new \InvalidArgumentException(
'Keine Zielsprachen gefunden.');
1491 $rootFolderId = max(0, (
int)($params[
'root_folder_id'] ?? $params[
'folder_id'] ?? 0));
1492 if ($rootFolderId > 0 && !is_array($this->db->select1(dbxContentLng::ddFolder($sourceLng), $rootFolderId))) {
1493 throw new \RuntimeException(
'Quellordner nicht gefunden.');
1496 $folderIds = $this->collect_folder_ids_for_lng($sourceLng, $rootFolderId);
1497 $pageIds = $this->collect_page_ids_for_lng($sourceLng, $rootFolderId, $folderIds);
1500 'operation' =>
'translation_sync_all',
1501 'entity' =>
'content_language',
1502 'source_lng' => $sourceLng,
1503 'target_lngs' => $targetLngs,
1504 'root_folder_id' => $rootFolderId,
1505 'update_existing' => !array_key_exists(
'update_existing', $params) || $this->bool_value($params[
'update_existing']),
1506 'skip_manual' => array_key_exists(
'skip_manual', $params) && $this->bool_value($params[
'skip_manual']),
1507 'copy_media' => !array_key_exists(
'copy_media', $params) || $this->bool_value($params[
'copy_media']),
1508 'replace_media_usage' => array_key_exists(
'replace_media_usage', $params) && $this->bool_value($params[
'replace_media_usage']),
1509 'provider' => dbxContentTranslate::provider(),
1511 'folders' => count($folderIds),
1512 'pages' => count($pageIds),
1513 'target_languages' => count($targetLngs),
1515 'source_ids' => array(
1516 'folders' => $folderIds,
1517 'pages' => $pageIds,
1522 private function execute_folder_create(array $plan): array {
1523 $dd = dbxContentLng::ddFolder($plan[
'lng']);
1524 $data = $plan[
'data'];
1525 $data[
'sorter'] = $this->next_sorter($dd,
'parent_id', (
int)$data[
'parent_id']);
1526 $data += $this->lng_fields(
'f', $plan[
'lng']);
1527 if ($this->db->insert($dd, $data) !== 1)
throw new \RuntimeException(
'Ordner konnte nicht erstellt werden.');
1528 $id = $this->db->get_insert_id();
1529 $this->invalidate_folder($id);
1530 return array(
'id' => $id,
'row' => $this->db->select1($dd, $id));
1533 private function execute_folder_update(array $plan): array {
1534 $dd = dbxContentLng::ddFolder($plan[
'lng']);
1535 $data = $plan[
'changes'];
1536 $data = $this->advance_revision($dd, $plan[
'id'], $data, $plan[
'lng']);
1537 if ($this->db->update($dd, $data, $plan[
'id']) !== 1)
throw new \RuntimeException(
'Ordner konnte nicht aktualisiert werden.');
1538 $this->invalidate_folder($plan[
'id']);
1539 return array(
'id' => $plan[
'id'],
'row' => $this->db->select1($dd, $plan[
'id']));
1542 private function execute_folder_delete(array $plan): array {
1543 $dd = dbxContentLng::ddFolder($plan[
'lng']);
1544 if ($this->db->delete($dd, $plan[
'id']) !== 1)
throw new \RuntimeException(
'Ordner konnte nicht gelöscht werden.');
1545 $this->invalidate_folder($plan[
'id']);
1546 return array(
'deleted' =>
true,
'id' => $plan[
'id'],
'lng' => $plan[
'lng']);
1549 private function execute_page_create(array $plan): array {
1550 $dd = dbxContentLng::ddContent($plan[
'lng']);
1551 $data = $plan[
'data'];
1552 if (trim((
string)($data[
'sorter'] ??
'')) ===
'') {
1553 $data[
'sorter'] = $this->next_sorter($dd,
'folder', (
int)$data[
'folder']);
1555 $data += $this->lng_fields(
'p', $plan[
'lng']);
1556 if ($this->db->insert($dd, $data) !== 1)
throw new \RuntimeException(
'Seite konnte nicht erstellt werden.');
1557 $id = $this->db->get_insert_id();
1558 $this->invalidate_page($id, $plan[
'lng'], $data);
1559 return array(
'id' => $id,
'row' => $this->db->select1($dd, $id));
1562 private function execute_page_update(array $plan): array {
1563 $dd = dbxContentLng::ddContent($plan[
'lng']);
1564 $data = $this->advance_revision($dd, $plan[
'id'], $plan[
'changes'], $plan[
'lng']);
1565 if ($this->db->update($dd, $data, $plan[
'id']) !== 1)
throw new \RuntimeException(
'Seite konnte nicht aktualisiert werden.');
1566 $mediaId = (int)($plan[
'package_media_id_applied'] ?? 0);
1568 $this->ensure_inline_media_usage((
int)$plan[
'id'], $mediaId);
1570 $row = $this->db->select1($dd, $plan[
'id']);
1571 $this->invalidate_page($plan[
'id'], $plan[
'lng'], $row);
1572 $result = array(
'id' => $plan[
'id'],
'row' => $row);
1574 $result[
'package_media_id'] = $mediaId;
1575 $result = array_merge(
$result, $this->media_inline_payload($mediaId));
1580 private function execute_page_hero_replace_image(array $plan): array {
1581 $target = (string)$plan[
'target_file'];
1582 $this->render_image_variant_to_file($plan[
'source'], $target, (
int)$plan[
'width'], (
int)$plan[
'height'], (
string)$plan[
'fit'], (
string)$plan[
'mime'], (
int)$plan[
'quality']);
1584 $mediaId = (int)($plan[
'media'][
'id'] ?? 0);
1586 'size' => (
int)@filesize($target),
1587 'width' => (
int)$plan[
'width'],
1588 'height' => (
int)$plan[
'height'],
1589 'mime' => (
string)$plan[
'mime'],
1591 if ($mediaId <= 0)
throw new \RuntimeException(
'Hero-Medium konnte nicht aktualisiert werden.');
1592 $this->db->update(
'dbxMedia', $data, $mediaId);
1593 $this->invalidate_media_references($mediaId);
1594 $this->invalidate_page((
int)$plan[
'id'], (
string)$plan[
'lng'], $plan[
'page']);
1596 'id' => (
int)$plan[
'id'],
1597 'media_id' => $mediaId,
1598 'file' => str_replace(
'\\',
'/', $target),
1603 private function execute_page_hero_create_image(array $plan): array {
1604 $media = $this->execute_media_create_image_variant($plan[
'media_plan']);
1605 $mediaId = (int)($media[
'id'] ?? 0);
1606 if ($mediaId <= 0)
throw new \RuntimeException(
'Hero-Medium konnte nicht erstellt werden.');
1610 'media_id' => $mediaId,
1611 'content_id' => (
int)$plan[
'id'],
1618 $where =
'content_id = ' . (int)$plan[
'id'];
1619 $this->db->update(
'dbxMediaUsage', array(
'active' => 0), $where .
" AND slot = 'hero' AND active = 1", 0, 1, 1, 1);
1620 $data[
'sorter'] = $this->next_usage_sorter((
int)$plan[
'id'], 0,
'hero');
1621 if ($this->db->insert(
'dbxMediaUsage', $data) !== 1) {
1622 throw new \RuntimeException(
'Hero-Medienzuordnung konnte nicht erstellt werden.');
1624 $usageId = $this->db->get_insert_id();
1625 $this->sync_hero_setting((
string)$plan[
'lng'], $data);
1626 $this->invalidate_usage($data);
1627 $row = $this->db->select1(dbxContentLng::ddContent((
string)$plan[
'lng']), (
int)$plan[
'id']);
1629 'id' => (
int)$plan[
'id'],
1630 'media_id' => $mediaId,
1631 'usage_id' => $usageId,
1633 'media' => $media[
'row'] ?? array(),
1637 private function execute_page_delete(array $plan): array {
1638 $dd = dbxContentLng::ddContent($plan[
'lng']);
1639 if ($this->db->delete($dd, $plan[
'id']) !== 1)
throw new \RuntimeException(
'Seite konnte nicht gelöscht werden.');
1640 $this->db->update(
'dbxMediaUsage', array(
'active' => 0),
'content_id = ' . (
int)$plan[
'id'] .
' AND active = 1', 0, 1, 1, 1);
1641 dbxContentPageCache::invalidateContent($plan[
'id']);
1642 dbxContentPageCache::invalidateAllMenus();
1643 dbxContentPermalinkIndex::removeByCid($plan[
'id'], $plan[
'lng']);
1644 return array(
'deleted' =>
true,
'id' => $plan[
'id'],
'lng' => $plan[
'lng']);
1647 private function execute_media_create(array $params, array $plan): array {
1648 $bytes = $this->decode_base64((string)$params[
'data_base64']);
1649 if (!hash_equals((
string)$plan[
'sha256'], hash(
'sha256', $bytes))) {
1650 throw new \RuntimeException(
'Der Medieninhalt stimmt nicht mit dem geprüften Plan überein.');
1654 if (!is_dir(
$dir) && !mkdir(
$dir, 0777,
true) && !is_dir(
$dir))
throw new \RuntimeException(
'Medienordner konnte nicht erstellt werden.');
1655 $name = $this->unique_name(
$dir, $plan[
'file_name']);
1656 $file = rtrim(
$dir,
'/\\') . DIRECTORY_SEPARATOR . $name;
1657 if (file_put_contents($file, $bytes) ===
false)
throw new \RuntimeException(
'Mediendatei konnte nicht geschrieben werden.');
1658 $relative =
'media/' . trim(str_replace(
'\\',
'/', $plan[
'media_folder']),
'/') .
'/' . $name;
1661 $size = @getimagesize($file);
1662 if (is_array($size)) {
1663 $width = (int)($size[0] ?? 0);
1664 $height = (int)($size[1] ?? 0);
1666 $data = array_merge($plan[
'metadata'], array(
1668 'file_name' => $name,
1669 'file_path' => $relative,
1670 'mime' => $plan[
'mime'],
1671 'size' => strlen($bytes),
1673 'height' => $height,
1674 'media_type' => $plan[
'media_type'],
1675 'storage_type' =>
'local',
1676 'media_folder' => $plan[
'media_folder'],
1678 if ($this->db->insert(
'dbxMedia', $data) !== 1) {
1680 throw new \RuntimeException(
'Medium konnte nicht registriert werden.');
1682 $id = $this->db->get_insert_id();
1683 return array_merge(array(
'id' => $id,
'row' => $this->db->select1(
'dbxMedia', $id)), $this->media_inline_payload($id));
1686 private function execute_media_create_image_variant(array $plan): array {
1687 $source = (string)($plan[
'source_file'] ??
'');
1688 if (!is_file($source) || !is_readable($source)) {
1689 throw new \RuntimeException(
'Quellbild ist nicht lesbar.');
1691 if (!hash_equals((
string)($plan[
'source_sha256'] ??
''), hash_file(
'sha256', $source))) {
1692 throw new \RuntimeException(
'Das Quellbild stimmt nicht mehr mit dem geprüften Plan überein.');
1695 $src = $this->gd_load_image($source, (
string)$plan[
'source_mime']);
1696 $width = max(1, (
int)$plan[
'width']);
1697 $height = max(1, (
int)$plan[
'height']);
1698 $dst = imagecreatetruecolor($width, $height);
1699 imagealphablending($dst,
false);
1700 imagesavealpha($dst,
true);
1701 $transparent = imagecolorallocatealpha($dst, 255, 255, 255, 127);
1702 imagefilledrectangle($dst, 0, 0, $width, $height, $transparent);
1704 $sourceWidth = imagesx($src);
1705 $sourceHeight = imagesy($src);
1708 $crop = is_array($plan[
'crop'] ??
null) ? $plan[
'crop'] : array();
1710 $sourceX = max(0, min((
int)($crop[
'x'] ?? 0), $sourceWidth - 1));
1711 $sourceY = max(0, min((
int)($crop[
'y'] ?? 0), $sourceHeight - 1));
1712 $sourceWidth = max(1, min((
int)($crop[
'width'] ?? $sourceWidth), imagesx($src) - $sourceX));
1713 $sourceHeight = max(1, min((
int)($crop[
'height'] ?? $sourceHeight), imagesy($src) - $sourceY));
1715 $fit = (string)($plan[
'fit'] ??
'cover');
1716 if ($fit ===
'contain') {
1717 $scale = min($width / $sourceWidth, $height / $sourceHeight);
1718 $copyWidth = max(1, (
int)round($sourceWidth * $scale));
1719 $copyHeight = max(1, (
int)round($sourceHeight * $scale));
1720 $dstX = (int)floor(($width - $copyWidth) / 2);
1721 $dstY = (int)floor(($height - $copyHeight) / 2);
1722 imagecopyresampled($dst, $src, $dstX, $dstY, $sourceX, $sourceY, $copyWidth, $copyHeight, $sourceWidth, $sourceHeight);
1724 $sourceRatio = $sourceWidth / $sourceHeight;
1725 $targetRatio = $width / $height;
1726 if ($sourceRatio > $targetRatio) {
1727 $cropHeight = $sourceHeight;
1728 $cropWidth = (int)round($sourceHeight * $targetRatio);
1729 $srcX = $sourceX + (int)floor(($sourceWidth - $cropWidth) / 2);
1732 $cropWidth = $sourceWidth;
1733 $cropHeight = (int)round($sourceWidth / $targetRatio);
1735 $srcY = $sourceY + (int)floor(($sourceHeight - $cropHeight) / 2);
1737 imagecopyresampled($dst, $src, 0, 0, $srcX, $srcY, $width, $height, $cropWidth, $cropHeight);
1741 $this->gd_apply_tint($dst, (
string)($plan[
'tint'] ??
''), (
float)($plan[
'tint_strength'] ?? 0));
1745 if (!is_dir(
$dir) && !mkdir(
$dir, 0777,
true) && !is_dir(
$dir))
throw new \RuntimeException(
'Medienordner konnte nicht erstellt werden.');
1746 $name = $this->unique_name(
$dir, $plan[
'file_name']);
1747 $file = rtrim(
$dir,
'/\\') . DIRECTORY_SEPARATOR . $name;
1748 $this->gd_save_image($dst, $file, (
string)$plan[
'mime'], (
int)$plan[
'quality']);
1751 $relative =
'media/' . trim(str_replace(
'\\',
'/', $plan[
'media_folder']),
'/') .
'/' . $name;
1752 $data = array_merge($plan[
'metadata'], array(
1754 'file_name' => $name,
1755 'file_path' => $relative,
1756 'mime' => $plan[
'mime'],
1757 'size' => (
int)@filesize($file),
1759 'height' => $height,
1760 'media_type' =>
'image',
1761 'storage_type' =>
'local',
1762 'media_folder' => $plan[
'media_folder'],
1764 if ($this->db->insert(
'dbxMedia', $data) !== 1) {
1766 throw new \RuntimeException(
'Medium konnte nicht registriert werden.');
1768 $id = $this->db->get_insert_id();
1769 return array_merge(array(
'id' => $id,
'row' => $this->db->select1(
'dbxMedia', $id)), $this->media_inline_payload($id));
1772 private function execute_media_update(array $plan): array {
1773 if ($this->db->update(
'dbxMedia', $plan[
'changes'], $plan[
'id']) !== 1) throw new \RuntimeException(
'Medium konnte nicht aktualisiert werden.');
1774 $this->invalidate_media_references((
int)$plan[
'id']);
1775 return array(
'id' => $plan[
'id'],
'row' => $this->db->select1(
'dbxMedia', $plan[
'id']));
1778 private function execute_media_assign(array $plan): array {
1779 $data = $plan[
'data'];
1780 if ($data[
'slot'] ===
'hero') {
1781 $where = $data[
'content_id'] > 0
1782 ?
'content_id = ' . (int)$data[
'content_id']
1783 :
'folder_id = ' . (int)$data[
'folder_id'];
1784 $this->db->update(
'dbxMediaUsage', array(
'active' => 0), $where .
" AND slot = 'hero' AND active = 1", 0, 1, 1, 1);
1786 $data[
'sorter'] = $this->next_usage_sorter($data[
'content_id'], $data[
'folder_id'], $data[
'slot']);
1787 if ($this->db->insert(
'dbxMediaUsage', $data) !== 1)
throw new \RuntimeException(
'Medienzuordnung konnte nicht erstellt werden.');
1788 $id = $this->db->get_insert_id();
1789 if ($data[
'slot'] ===
'hero') {
1790 $this->sync_hero_setting((
string)($plan[
'lng'] ??
''), $data);
1792 $this->invalidate_usage($data);
1793 return array(
'usage_id' => $id,
'row' => $this->db->select1(
'dbxMediaUsage', $id));
1796 private function sync_hero_setting(
string $lng, array $usage): void {
1797 $mediaId = (int)($usage[
'media_id'] ?? 0);
1798 $contentId = (int)($usage[
'content_id'] ?? 0);
1799 $folderId = (int)($usage[
'folder_id'] ?? 0);
1800 if ($mediaId <= 0) {
1804 $lng = dbxContentLng::current();
1807 if ($contentId > 0) {
1808 $dd = dbxContentLng::ddContent($lng);
1809 $page = $this->db->select1($dd, $contentId);
1810 if (!is_array($page)) {
1813 $patch = array(
'hero_image_id' => (
string)$mediaId);
1814 $heroTemplate = trim((
string)($page[
'hero_template'] ??
''));
1815 if ($heroTemplate ===
'' || $heroTemplate ===
'parent') {
1816 $patch[
'hero_template'] =
'image-hero';
1818 if ($this->db->update($dd, $patch, $contentId) !== 1) {
1821 $row = $this->db->select1($dd, $contentId);
1822 if (is_array($row)) {
1823 $this->invalidate_page($contentId, $lng, $row);
1828 if ($folderId > 0) {
1829 $dd = dbxContentLng::ddFolder($lng);
1830 $folder = $this->db->select1($dd, $folderId);
1831 if (!is_array($folder)) {
1834 $patch = array(
'hero_image_id' => (
string)$mediaId);
1835 $heroTemplate = trim((
string)($folder[
'hero_template'] ??
''));
1836 if ($heroTemplate ===
'' || $heroTemplate ===
'parent') {
1837 $patch[
'hero_template'] =
'image-hero';
1839 if ($this->db->update($dd, $patch, $folderId) === 1) {
1840 $this->invalidate_folder($folderId);
1845 private function execute_media_unassign(array $plan): array {
1846 if ($this->db->update(
'dbxMediaUsage', array(
'active' => 0), $plan[
'id']) !== 1) throw new \RuntimeException(
'Medienzuordnung konnte nicht entfernt werden.');
1847 $this->invalidate_usage($plan[
'before']);
1848 return array(
'unassigned' =>
true,
'usage_id' => $plan[
'id']);
1851 private function execute_media_delete(array $plan): array {
1852 require_once dirname(__DIR__, 2) .
'/dbxContent_admin/include/dbxContent_cms.class.php';
1853 $cms = new \dbx\dbxContent_admin\dbxContent_cms();
1854 $result =
$cms->delete_media_record((
int)$plan[
'id']);
1855 if ((
int)(
$result[
'ok'] ?? 0) !== 1) {
1856 throw new \RuntimeException(implode(
' ', is_array(
$result[
'errors'] ??
null) ?
$result[
'errors'] : array(
'Medium konnte nicht gelöscht werden.')));
1861 private function execute_translation_apply(array $params, array $plan): array {
1862 $source = $plan[
'source'];
1863 $targetLng = $plan[
'target_lng'];
1864 $targetDd = dbxContentLng::ddContent($targetLng);
1865 $sourceUid = trim((
string)($source[
'lng_uid'] ??
''));
1866 if ($sourceUid ===
'') {
1867 $sourceUid = dbxContentLngSync::ensureRecordUid(
1869 dbxContentLng::ddContent($plan[
'source_lng']),
1874 $targetFolder = dbxContentLngSync::ensureFolderIdInLng($this->db, (
int)($source[
'folder'] ?? 0), $targetLng);
1875 $data = $this->whitelist($source, array(
1876 'activ',
'template',
'sorter',
'hero_template',
'hero_image_id',
'hero_margin_top',
'hero_height',
1877 'hero_variant',
'hero_sticky',
'hero_scroll_layer',
'gallery_template',
'gallery_visible_count',
1878 'gallery_image_size',
'gallery_lightbox_width',
'gallery_overflow',
'gallery_click_behavior'
1880 $data = array_merge($data, $plan[
'translation']);
1881 $data[
'folder'] = $targetFolder;
1882 $data[
'permalink'] = dbxContent_permalink::build($this->db, dbxContentLng::ddFolder($targetLng), $targetFolder, $data[
'title']);
1883 $data[
'lng_uid'] = $sourceUid;
1884 $data[
'lng_sync'] =
'manual';
1885 $data[
'lng_rev'] = max(1, (
int)($plan[
'target'][
'lng_rev'] ?? 0) + 1);
1886 $data[
'lng_synced_rev'] = (int)($source[
'lng_rev'] ?? 1);
1888 $targetId = (int)($plan[
'target'][
'id'] ?? 0);
1889 if ($targetId > 0) {
1890 if ($this->db->update($targetDd, $data, $targetId) !== 1)
throw new \RuntimeException(
'Übersetzung konnte nicht aktualisiert werden.');
1892 if ($this->db->insert($targetDd, $data) !== 1)
throw new \RuntimeException(
'Übersetzung konnte nicht erstellt werden.');
1893 $targetId = $this->db->get_insert_id();
1897 if ($plan[
'copy_media']) {
1900 array(
'active' => 0),
1901 'content_id = ' . $targetId .
' AND active = 1',
1907 $mediaCopied = $this->copy_media_usage((
int)$source[
'id'], $targetId, $targetFolder);
1909 $row = $this->db->select1($targetDd, $targetId);
1910 $this->invalidate_page($targetId, $targetLng, $row);
1911 return array(
'id' => $targetId,
'lng' => $targetLng,
'row' => $row,
'media_copied' => $mediaCopied);
1914 private function execute_translation_sync_all(array $plan): array {
1915 $sourceLng = (string)($plan[
'source_lng'] ??
'');
1916 $targetLngs = is_array($plan[
'target_lngs'] ??
null) ? $plan[
'target_lngs'] : array();
1917 $updateExisting = (bool)($plan[
'update_existing'] ??
true);
1918 $skipManual = (bool)($plan[
'skip_manual'] ??
false);
1919 $copyMedia = (bool)($plan[
'copy_media'] ??
true);
1920 $replaceMediaUsage = (bool)($plan[
'replace_media_usage'] ??
false);
1921 $sourceIds = is_array($plan[
'source_ids'] ??
null) ? $plan[
'source_ids'] : array();
1922 $folderIds = is_array($sourceIds[
'folders'] ??
null) ? array_map(
'intval', $sourceIds[
'folders']) : array();
1923 $pageIds = is_array($sourceIds[
'pages'] ??
null) ? array_map(
'intval', $sourceIds[
'pages']) : array();
1925 dbxContentTranslate::clearWarnings();
1928 'source_lng' => $sourceLng,
1929 'target_lngs' => $targetLngs,
1930 'provider' => dbxContentTranslate::provider(),
1931 'folders' => array(
'created' => array(),
'updated' => array(),
'skipped' => array()),
1932 'pages' => array(
'created' => array(),
'updated' => array(),
'skipped' => array()),
1933 'media_copied' => 0,
1934 'errors' => array(),
1935 'warnings' => array(),
1938 foreach ($targetLngs as $targetLng) {
1939 $targetLng = $this->language($targetLng);
1940 foreach ($folderIds as $folderId) {
1942 $item = $this->sync_translate_folder($sourceLng, $targetLng, $folderId, $updateExisting, $skipManual);
1943 $bucket = (string)($item[
'status'] ??
'skipped');
1944 $result[
'folders'][$bucket ===
'created' ?
'created' : ($bucket ===
'updated' ?
'updated' :
'skipped')][] = $item;
1945 }
catch (\Throwable $e) {
1946 $result[
'errors'][] =
'Ordner #' . $folderId .
' nach ' . strtoupper($targetLng) .
': ' . $e->getMessage();
1950 foreach ($pageIds as $pageId) {
1952 $item = $this->sync_translate_page($sourceLng, $targetLng, $pageId, $updateExisting, $skipManual, $copyMedia, $replaceMediaUsage);
1953 $bucket = (string)($item[
'status'] ??
'skipped');
1954 $result[
'pages'][$bucket ===
'created' ?
'created' : ($bucket ===
'updated' ?
'updated' :
'skipped')][] = $item;
1955 $result[
'media_copied'] += (int)($item[
'media_copied'] ?? 0);
1956 }
catch (\Throwable $e) {
1957 $result[
'errors'][] =
'Seite #' . $pageId .
' nach ' . strtoupper($targetLng) .
': ' . $e->getMessage();
1962 $result[
'warnings'] = dbxContentTranslate::warnings();
1966 private function sync_translate_folder(
string $sourceLng,
string $targetLng,
int $sourceId,
bool $updateExisting,
bool $skipManual): array {
1967 $sourceDd = dbxContentLng::ddFolder($sourceLng);
1968 $targetDd = dbxContentLng::ddFolder($targetLng);
1969 $source = $this->db->select1($sourceDd, $sourceId);
1970 if (!is_array($source)) {
1971 throw new \RuntimeException(
'Quellordner nicht gefunden.');
1974 $uid = dbxContentLngSync::ensureRecordUid($this->db, $sourceDd, $sourceId,
'f');
1976 throw new \RuntimeException(
'Sprach-ID konnte nicht erzeugt werden.');
1979 $targetId = dbxContentLngSync::resolveIdByUid($this->db, $targetDd, $uid, $targetLng);
1980 $target = $targetId > 0 ? $this->db->select1($targetDd, $targetId) :
null;
1981 if (is_array($target) && !$updateExisting) {
1982 return array(
'status' =>
'skipped',
'reason' =>
'exists',
'entity' =>
'folder',
'source_id' => $sourceId,
'target_lng' => $targetLng,
'target_id' => $targetId);
1984 if (is_array($target) && $skipManual && strtolower(trim((
string)($target[
'lng_sync'] ??
''))) ===
'manual') {
1985 return array(
'status' =>
'skipped',
'reason' =>
'manual',
'entity' =>
'folder',
'source_id' => $sourceId,
'target_lng' => $targetLng,
'target_id' => $targetId);
1988 $name = dbxContentTranslate::translate((
string)($source[
'name'] ??
''), $sourceLng, $targetLng,
'folder_name');
1989 if ($name ===
'' && trim((
string)($source[
'name'] ??
'')) !==
'') {
1990 $name = (string)$source[
'name'];
1996 $data = $this->copy_folder_structure($source);
1997 $data[
'name'] = $this->clean($name, 120);
1998 $data[
'parent_id'] = $this->target_folder_id_from_source_parent($sourceLng, $targetLng, (
int)($source[
'parent_id'] ?? 0));
1999 $data[
'lng_uid'] = $uid;
2000 $data[
'lng_sync'] =
'auto';
2001 $data[
'lng_rev'] = is_array($target) ? max(1, (
int)($target[
'lng_rev'] ?? 0) + 1) : 0;
2002 $data[
'lng_synced_rev'] = max(1, (
int)($source[
'lng_rev'] ?? 1));
2004 if ($targetId > 0) {
2005 if ($this->db->update($targetDd, $data, $targetId) !== 1) {
2006 throw new \RuntimeException(
'Zielordner konnte nicht aktualisiert werden.');
2008 $status =
'updated';
2010 if ($this->db->insert($targetDd, $data) !== 1) {
2011 throw new \RuntimeException(
'Zielordner konnte nicht erstellt werden.');
2013 $targetId = (int)$this->db->get_insert_id();
2014 $status =
'created';
2017 $this->invalidate_folder($targetId);
2018 return array(
'status' => $status,
'entity' =>
'folder',
'source_id' => $sourceId,
'target_lng' => $targetLng,
'target_id' => $targetId,
'name' => $data[
'name']);
2021 private function sync_translate_page(
string $sourceLng,
string $targetLng,
int $sourceId,
bool $updateExisting,
bool $skipManual,
bool $copyMedia,
bool $replaceMediaUsage): array {
2022 $sourceDd = dbxContentLng::ddContent($sourceLng);
2023 $targetDd = dbxContentLng::ddContent($targetLng);
2024 $source = $this->db->select1($sourceDd, $sourceId);
2025 if (!is_array($source)) {
2026 throw new \RuntimeException(
'Quellseite nicht gefunden.');
2029 $uid = dbxContentLngSync::ensureRecordUid($this->db, $sourceDd, $sourceId,
'p');
2031 throw new \RuntimeException(
'Sprach-ID konnte nicht erzeugt werden.');
2034 $targetId = dbxContentLngSync::resolveIdByUid($this->db, $targetDd, $uid, $targetLng);
2035 $target = $targetId > 0 ? $this->db->select1($targetDd, $targetId) :
null;
2036 if (is_array($target) && !$updateExisting) {
2037 return array(
'status' =>
'skipped',
'reason' =>
'exists',
'entity' =>
'page',
'source_id' => $sourceId,
'target_lng' => $targetLng,
'target_id' => $targetId);
2039 if (is_array($target) && $skipManual && strtolower(trim((
string)($target[
'lng_sync'] ??
''))) ===
'manual') {
2040 return array(
'status' =>
'skipped',
'reason' =>
'manual',
'entity' =>
'page',
'source_id' => $sourceId,
'target_lng' => $targetLng,
'target_id' => $targetId);
2043 $title = dbxContentTranslate::translate((
string)($source[
'title'] ??
''), $sourceLng, $targetLng,
'title');
2044 if ($title ===
'' && trim((
string)($source[
'title'] ??
'')) !==
'') {
2045 $title = (string)$source[
'title'];
2047 if ($title ===
'') {
2048 throw new \RuntimeException(
'Übersetzter Titel ist leer.');
2051 $targetFolder = $this->target_folder_id_from_source_parent($sourceLng, $targetLng, (
int)($source[
'folder'] ?? 0));
2052 if ((
int)($source[
'folder'] ?? 0) > 0 && $targetFolder <= 0) {
2053 throw new \RuntimeException(
'Zielordner konnte nicht aufgelöst werden.');
2056 $data = $this->copy_page_structure($source);
2057 $data[
'folder'] = $targetFolder;
2058 $data[
'title'] = $this->clean($title, 254);
2059 $data[
'description'] = $this->clean(dbxContentTranslate::translate((
string)($source[
'description'] ??
''), $sourceLng, $targetLng,
'description'), 254);
2060 $data[
'keywords'] = $this->clean(dbxContentTranslate::translate((
string)($source[
'keywords'] ??
''), $sourceLng, $targetLng,
'keywords'), 254);
2061 $data[
'content'] = $this->normalize_content_inline_media_urls(dbxContentTranslate::translate((
string)($source[
'content'] ??
''), $sourceLng, $targetLng,
'content'));
2062 foreach (array(
'seo_title',
'img_alt_1',
'img_alt_2',
'img_alt_3',
'img_des_1',
'img_des_2',
'img_des_3') as
$field) {
2063 if (array_key_exists(
$field, $source)) {
2064 $max =
$field ===
'seo_title' || strpos(
$field,
'img_alt_') === 0 ? 254 : 0;
2065 $data[
$field] = $this->clean(dbxContentTranslate::translate((
string)($source[
$field] ??
''), $sourceLng, $targetLng,
$field), $max);
2068 $data[
'permalink'] = dbxContent_permalink::build($this->db, dbxContentLng::ddFolder($targetLng), $targetFolder, $data[
'title']);
2069 $data[
'lng_uid'] = $uid;
2070 $data[
'lng_sync'] =
'auto';
2071 $data[
'lng_rev'] = is_array($target) ? max(1, (
int)($target[
'lng_rev'] ?? 0) + 1) : 0;
2072 $data[
'lng_synced_rev'] = max(1, (
int)($source[
'lng_rev'] ?? 1));
2074 if ($targetId > 0) {
2075 if ($this->db->update($targetDd, $data, $targetId) !== 1) {
2076 throw new \RuntimeException(
'Zielseite konnte nicht aktualisiert werden.');
2078 $status =
'updated';
2080 if ($this->db->insert($targetDd, $data) !== 1) {
2081 throw new \RuntimeException(
'Zielseite konnte nicht erstellt werden.');
2083 $targetId = (int)$this->db->get_insert_id();
2084 $status =
'created';
2089 if ($replaceMediaUsage) {
2090 $this->db->update(
'dbxMediaUsage', array(
'active' => 0),
'content_id = ' . $targetId .
' AND active = 1', 0, 1, 1, 1);
2091 $mediaCopied = $this->copy_media_usage($sourceId, $targetId, $targetFolder);
2093 $mediaCopied = $this->copy_missing_media_usage($sourceId, $targetId, $targetFolder);
2097 $row = $this->db->select1($targetDd, $targetId);
2098 $this->invalidate_page($targetId, $targetLng, $row);
2099 return array(
'status' => $status,
'entity' =>
'page',
'source_id' => $sourceId,
'target_lng' => $targetLng,
'target_id' => $targetId,
'title' => $data[
'title'],
'media_copied' => $mediaCopied);
2102 private function target_languages(array $params,
string $sourceLng): array {
2103 $raw = $params[
'target_lngs'] ?? $params[
'target_lng'] ?? array();
2104 if (is_string($raw)) {
2105 $raw = array_values(array_filter(array_map(
'trim', explode(
',', $raw))));
2106 } elseif (!is_array($raw)) {
2110 $raw = dbxContentLngSync::accessibleLngs();
2114 foreach ($raw as $lng) {
2115 $lng = $this->language($lng);
2116 if ($lng === $sourceLng || in_array($lng, $out,
true)) {
2124 private function collect_folder_ids_for_lng(
string $lng,
int $rootFolderId = 0): array {
2125 $dd = dbxContentLng::ddFolder($lng);
2126 if ($rootFolderId <= 0) {
2127 $rows = $this->db->select($dd,
'',
'id',
'parent_id,sorter,id',
'ASC',
'', 0, 0, 0);
2128 return $this->ids_from_rows($rows);
2133 $queue = array($rootFolderId);
2134 while (count($queue)) {
2135 $id = (int)array_shift($queue);
2136 if ($id <= 0 || isset($seen[$id])) {
2141 $rows = $this->db->select($dd,
'parent_id = ' . $id,
'id',
'sorter,id',
'ASC',
'', 0, 0, 0);
2142 foreach ($this->ids_from_rows($rows) as $childId) {
2143 if (!isset($seen[$childId])) {
2144 $queue[] = $childId;
2151 private function collect_page_ids_for_lng(
string $lng,
int $rootFolderId, array $folderIds): array {
2152 $dd = dbxContentLng::ddContent($lng);
2153 if ($rootFolderId <= 0) {
2154 $rows = $this->db->select($dd,
'',
'id',
'folder,sorter,id',
'ASC',
'', 0, 0, 0);
2155 return $this->ids_from_rows($rows);
2158 $folderIds = array_values(array_filter(array_map(
'intval', $folderIds),
static function($id) {
2161 if (!count($folderIds)) {
2164 $rows = $this->db->select($dd,
'folder IN (' . implode(
',', $folderIds) .
')',
'id',
'folder,sorter,id',
'ASC',
'', 0, 0, 0);
2165 return $this->ids_from_rows($rows);
2168 private function ids_from_rows($rows): array {
2170 foreach (is_array($rows) ? $rows : array() as $row) {
2171 if (is_array($row) && (
int)($row[
'id'] ?? 0) > 0) {
2172 $out[] = (int)$row[
'id'];
2178 private function target_folder_id_from_source_parent(
string $sourceLng,
string $targetLng,
int $sourceFolderId): int {
2179 $sourceFolderId = (int)$sourceFolderId;
2180 if ($sourceFolderId <= 0) {
2183 $sourceDd = dbxContentLng::ddFolder($sourceLng);
2184 $source = $this->db->select1($sourceDd, $sourceFolderId);
2185 if (!is_array($source)) {
2189 $uid = dbxContentLngSync::ensureRecordUid($this->db, $sourceDd, $sourceFolderId,
'f');
2193 $targetDd = dbxContentLng::ddFolder($targetLng);
2194 $targetId = dbxContentLngSync::resolveIdByUid($this->db, $targetDd, $uid, $targetLng);
2195 if ($targetId > 0) {
2200 $created = $this->sync_translate_folder($sourceLng, $targetLng, $sourceFolderId,
true,
false);
2201 return (
int)(
$created[
'target_id'] ?? 0);
2202 }
catch (\Throwable $e) {
2207 private function copy_page_structure(array $source): array {
2208 $skip = array(
'id',
'create_date',
'create_uid',
'update_date',
'update_uid',
'owner',
'title',
'permalink',
'description',
'keywords',
'content',
'lng_uid',
'lng_sync',
'lng_rev',
'lng_synced_rev');
2210 foreach ($source as $key => $value) {
2211 if (!in_array($key, $skip,
true)) {
2212 $data[$key] = $value;
2218 private function copy_folder_structure(array $source): array {
2219 $skip = array(
'id',
'create_date',
'create_uid',
'update_date',
'update_uid',
'owner',
'name',
'parent_id',
'lng_uid',
'lng_sync',
'lng_rev',
'lng_synced_rev');
2221 foreach ($source as $key => $value) {
2222 if (!in_array($key, $skip,
true)) {
2223 $data[$key] = $value;
2229 private function copy_missing_media_usage(
int $sourceId,
int $targetId,
int $targetFolder): int {
2230 $rows = $this->db->select(
'dbxMediaUsage',
'content_id = ' . $sourceId .
' AND active = 1',
'*',
'slot,sorter,id',
'ASC',
'', 0, 0, 0);
2232 foreach (is_array($rows) ? $rows : array() as $row) {
2233 if (!is_array($row)) {
2236 $mediaId = (int)($row[
'media_id'] ?? 0);
2237 $slot = str_replace(
"'",
"''", (
string)($row[
'slot'] ??
''));
2238 if ($mediaId <= 0 || $slot ===
'') {
2241 if ($slot ===
'hero' && (
int)$this->db->count(
'dbxMediaUsage',
'content_id = ' . $targetId .
" AND slot = 'hero' AND active = 1") > 0) {
2244 $exists = (int)$this->db->count(
'dbxMediaUsage',
'content_id = ' . $targetId .
' AND media_id = ' . $mediaId .
" AND slot = '" . $slot .
"' AND active = 1");
2248 $data = $this->whitelist($row, array(
'media_id',
'slot',
'sorter',
'template',
'caption',
'settings'));
2249 $data[
'active'] = 1;
2250 $data[
'content_id'] = $targetId;
2251 $data[
'folder_id'] = $targetFolder;
2252 if ($this->db->insert(
'dbxMediaUsage', $data) === 1) {
2259 private function folder_data(array $params,
int $parent,
string $name): array {
2262 'parent_id' => $parent,
2263 'group_read' => $this->clean($params[
'group_read'] ?? ($parent > 0 ?
'parent' :
'*'), 512),
2264 'template' => $this->clean($params[
'template'] ?? ($parent > 0 ?
'parent' :
'c-content'), 254),
2265 'hero_template' => $this->clean($params[
'hero_template'] ?? ($parent > 0 ?
'parent' :
'image-hero'), 80),
2266 'hero_image_id' => $this->clean($params[
'hero_image_id'] ??
'parent', 32),
2267 'hero_margin_top' => $this->clean($params[
'hero_margin_top'] ??
'parent', 32),
2268 'hero_height' => $this->clean($params[
'hero_height'] ?? ($parent > 0 ?
'parent' :
'300px'), 32),
2269 'hero_variant' => $this->clean($params[
'hero_variant'] ??
'parent', 32),
2270 'hero_sticky' => $this->clean($params[
'hero_sticky'] ??
'parent', 32),
2271 'hero_scroll_layer' => $this->clean($params[
'hero_scroll_layer'] ??
'parent', 32),
2275 private function page_data(array $params,
string $lng,
int $folder,
string $title): array {
2276 $permalink = $this->clean($params[
'permalink'] ??
'', 254);
2277 if ($permalink ===
'') {
2278 $permalink = dbxContent_permalink::build($this->db, dbxContentLng::ddFolder($lng), $folder, $title);
2280 $permalink = dbxContent_permalink::normalize($permalink);
2283 'activ' => $this->bool_value($params[
'activ'] ??
true) ? 1 : 0,
2284 'folder' => $folder,
2286 'permalink' => $permalink,
2287 'description' => $this->clean($params[
'description'] ??
'', 254),
2288 'keywords' => $this->clean($params[
'keywords'] ??
'', 254),
2289 'group_read' => $this->clean($params[
'group_read'] ??
'parent', 512),
2290 'template' => $this->clean($params[
'template'] ??
'parent', 254),
2291 'hero_template' => $this->clean($params[
'hero_template'] ??
'parent', 80),
2292 'hero_image_id' => $this->clean($params[
'hero_image_id'] ??
'parent', 32),
2293 'hero_margin_top' => $this->clean($params[
'hero_margin_top'] ??
'parent', 32),
2294 'hero_height' => $this->clean($params[
'hero_height'] ??
'300px', 32),
2295 'hero_variant' => $this->clean($params[
'hero_variant'] ??
'parent', 32),
2296 'hero_sticky' => $this->clean($params[
'hero_sticky'] ??
'parent', 32),
2297 'hero_scroll_layer' => $this->clean($params[
'hero_scroll_layer'] ??
'parent', 32),
2298 'gallery_template' => $this->clean($params[
'gallery_template'] ??
'image-gallery', 80),
2299 'gallery_visible_count' => $this->clean($params[
'gallery_visible_count'] ??
'3', 32),
2300 'gallery_image_size' => $this->clean($params[
'gallery_image_size'] ??
'original', 32),
2301 'gallery_lightbox_width' => $this->clean($params[
'gallery_lightbox_width'] ??
'100vw', 32),
2302 'gallery_overflow' => $this->clean($params[
'gallery_overflow'] ??
'grid', 32),
2303 'gallery_click_behavior' => $this->clean($params[
'gallery_click_behavior'] ??
'lightbox', 32),
2304 'sorter' => $this->clean($params[
'sorter'] ??
'', 32),
2305 'content' => $this->normalize_content_inline_media_urls((
string)($params[
'content'] ??
'')),
2309 private function patch(array $params): array {
2310 $patch = is_array($params[
'patch'] ?? null) ? $params[
'patch'] : $params;
2311 foreach (array(
'id',
'lng',
'patch',
'folder_id') as $key) {
2312 if ($key !==
'folder_id') unset($patch[$key]);
2317 private function whitelist(array $data, array $allowed): array {
2318 return array_intersect_key($data, array_flip($allowed));
2321 private function lng_fields(
string $prefix,
string $lng): array {
2323 'lng_uid' => dbxContentLngSync::newUid($prefix),
2324 'lng_sync' => $lng === dbxContentLngSync::masterLng() ?
'auto' :
'manual',
2326 'lng_synced_rev' => 0,
2330 private function advance_revision(
string $dd,
int $id, array $data,
string $lng): array {
2331 $row = $this->db->select1($dd, $id,
'lng_uid,lng_rev', 0);
2332 $uid = trim((
string)($row[
'lng_uid'] ??
''));
2333 if ($uid ===
'') $uid = dbxContentLngSync::newUid(strpos($dd,
'folder') !==
false ?
'f' :
'p');
2334 $data[
'lng_uid'] = $uid;
2335 $data[
'lng_rev'] = max(1, (
int)($row[
'lng_rev'] ?? 0)) + 1;
2336 if ($lng !== dbxContentLngSync::masterLng()) $data[
'lng_sync'] =
'manual';
2340 private function next_sorter(
string $dd,
string $field,
int $parent): string {
2341 $rows = $this->db->select($dd,
$field .
' = ' . $parent,
'sorter,id',
'sorter,id',
'DESC',
'', 1, 0, 0);
2342 $max = is_array($rows) && isset($rows[0]) ? (int)($rows[0][
'sorter'] ?? 0) : 0;
2343 return sprintf(
'%04d', $max + 10);
2346 private function next_usage_sorter(
int $content,
int $folder,
string $slot): string {
2347 $where =
"active = 1 AND slot = '" . str_replace(
"'",
"''", $slot) .
"'";
2348 if ($content > 0) $where .=
' AND content_id = ' . $content;
2349 if ($folder > 0) $where .=
' AND folder_id = ' . $folder;
2350 $rows = $this->db->select(
'dbxMediaUsage', $where,
'sorter,id',
'sorter,id',
'DESC',
'', 1, 0, 0);
2351 $max = is_array($rows) && isset($rows[0]) ? (int)($rows[0][
'sorter'] ?? 0) : 0;
2352 return sprintf(
'%04d', $max + 10);
2355 private function folder_descendant(
string $dd,
int $candidate,
int $ancestor): bool {
2357 while ($candidate > 0 && !isset($seen[$candidate])) {
2358 if ($candidate === $ancestor)
return true;
2359 $seen[$candidate] = 1;
2360 $row = $this->db->select1($dd, $candidate,
'parent_id', 0);
2361 if (!is_array($row))
break;
2362 $candidate = (int)($row[
'parent_id'] ?? 0);
2367 private function slot($value): string {
2368 $slot = strtolower(trim((string)$value));
2369 $allowed = array(
'hero',
'gallery',
'inline',
'header',
'teaser',
'footer');
2370 if (!in_array($slot, $allowed,
true))
throw new \InvalidArgumentException(
'Ungültiger Medienslot: ' . $slot);
2374 private function safe_file_name($value): string {
2375 $name = basename(str_replace(
'\\',
'/', trim((string)$value)));
2376 $name = preg_replace(
'/[^A-Za-z0-9._-]+/',
'-', $name);
2377 return trim((
string)$name,
'.-');
2380 private function decode_base64(
string $raw): string {
2382 if (preg_match(
'~^data:[^;]+;base64,(.*)$~s', $raw, $match)) $raw = $match[1];
2383 $decoded = base64_decode(preg_replace(
'/\s+/',
'', $raw),
true);
2384 if ($decoded ===
false)
throw new \InvalidArgumentException(
'data_base64 ist ungültig.');
2388 private function detect_mime(
string $bytes,
string $name): string {
2389 if (class_exists(
'\finfo')) {
2390 $finfo = new \finfo(FILEINFO_MIME_TYPE);
2391 $mime = (string)$finfo->buffer($bytes);
2392 if ($mime !==
'')
return $mime;
2394 $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
2395 $map = array(
'jpg' =>
'image/jpeg',
'jpeg' =>
'image/jpeg',
'png' =>
'image/png',
'webp' =>
'image/webp',
'gif' =>
'image/gif',
'pdf' =>
'application/pdf',
'txt' =>
'text/plain');
2396 return $map[$ext] ??
'application/octet-stream';
2399 private function resolve_local_file(
string $path): string {
2400 $path = trim(str_replace(
'\\',
'/', $path));
2401 if ($path ===
'')
return '';
2402 if (!preg_match(
'~^(?:[A-Za-z]:/|/)~', $path)) {
2403 $path = rtrim(str_replace(
'\\',
'/',
dbx_get_base_dir()),
'/') .
'/' . ltrim($path,
'/');
2408 private function media_local_file(array $media): string {
2409 if (($media[
'storage_type'] ??
'local') !==
'local') return
'';
2410 $filePath = trim((
string)($media[
'file_path'] ??
''));
2411 if ($filePath ===
'')
return '';
2412 $filePath = preg_replace(
'~^files/~',
'', str_replace(
'\\',
'/', $filePath));
2413 if (strpos($filePath,
'media/') !== 0)
return '';
2417 private function hero_media_for_page(
string $lng,
int $id): array {
2418 $page = $this->db->select1(dbxContentLng::ddContent($lng), $id);
2419 if (!is_array($page))
throw new \RuntimeException(
'Seite nicht gefunden.');
2422 $mediaId = (int)($page[
'hero_image_id'] ?? 0);
2423 if ($mediaId <= 0) {
2424 $rows = $this->db->select(
'dbxMediaUsage',
'content_id = ' . $id .
" AND slot = 'hero' AND active = 1",
'*',
'sorter,id',
'DESC',
'', 1, 0, 0);
2425 if (is_array($rows) && is_array($rows[0] ??
null)) {
2427 $mediaId = (int)($usage[
'media_id'] ?? 0);
2430 if ($mediaId <= 0)
throw new \RuntimeException(
'Die Seite hat kein bestehendes Hero-Bild.');
2432 $media = $this->db->select1(
'dbxMedia', $mediaId);
2433 if (!is_array($media) || (
int)($media[
'active'] ?? 0) !== 1)
throw new \RuntimeException(
'Hero-Medium nicht gefunden.');
2435 $rows = $this->db->select(
'dbxMediaUsage',
'content_id = ' . $id .
' AND media_id = ' . $mediaId .
" AND slot = 'hero' AND active = 1",
'*',
'sorter,id',
'DESC',
'', 1, 0, 0);
2436 if (is_array($rows) && is_array($rows[0] ??
null)) $usage = $rows[0];
2438 return array(
'page' => $page,
'media' => $media,
'usage' => $usage);
2441 private function source_image_plan(array $params): array {
2442 $source = $this->resolve_local_file((string)($params[
'source_file'] ??
''));
2443 if ($source ===
'' || !is_file($source) || !is_readable($source)) {
2444 throw new \InvalidArgumentException(
'source_file ist nicht lesbar.');
2446 $info = @getimagesize($source);
2447 if (!is_array($info) || empty($info[0]) || empty($info[1])) {
2448 throw new \InvalidArgumentException(
'source_file ist kein lesbares Bild.');
2450 $mime = (string)($info[
'mime'] ??
'');
2451 if (!in_array($mime, array(
'image/jpeg',
'image/png',
'image/webp',
'image/gif'),
true)) {
2452 throw new \InvalidArgumentException(
'Nicht unterstützter Quellbildtyp: ' . $mime);
2456 'sha256' => hash_file(
'sha256', $source),
2458 'width' => (
int)$info[0],
2459 'height' => (
int)$info[1],
2460 'crop' => $this->image_crop_rect($params, (
int)$info[0], (
int)$info[1]),
2461 'tint' => $this->normalize_hex_color((
string)($params[
'tint'] ??
'')),
2462 'tint_strength' => max(0.0, min(1.0, (
float)($params[
'tint_strength'] ?? 0))),
2466 private function image_fit($value): string {
2467 $fit = strtolower(trim((string)$value));
2468 return in_array($fit, array(
'cover',
'contain'),
true) ? $fit :
'cover';
2471 private function image_quality($value): int {
2472 return min(100, max(1, (int)$value));
2475 private function image_crop_rect(array $params,
int $sourceWidth,
int $sourceHeight): array {
2476 $sourceWidth = max(1, $sourceWidth);
2477 $sourceHeight = max(1, $sourceHeight);
2478 $x = (int)($params[
'crop_x'] ?? 0);
2479 $y = (int)($params[
'crop_y'] ?? 0);
2480 $width = (int)($params[
'crop_width'] ?? $sourceWidth);
2481 $height = (int)($params[
'crop_height'] ?? $sourceHeight);
2483 $x = max(0, min($x, $sourceWidth - 1));
2484 $y = max(0, min($y, $sourceHeight - 1));
2485 $width = max(1, min($width, $sourceWidth - $x));
2486 $height = max(1, min($height, $sourceHeight - $y));
2488 return array(
'x' => $x,
'y' => $y,
'width' => $width,
'height' => $height);
2491 private function mime_from_file_name(
string $name): string {
2492 $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
2493 $map = array(
'jpg' =>
'image/jpeg',
'jpeg' =>
'image/jpeg',
'png' =>
'image/png',
'webp' =>
'image/webp');
2494 return $map[$ext] ??
'image/webp';
2497 private function normalize_hex_color(
string $value): string {
2498 $value = trim($value);
2499 if ($value ===
'')
return '';
2500 if ($value[0] !==
'#') $value =
'#' . $value;
2501 return preg_match(
'/^#[0-9A-Fa-f]{6}$/', $value) ? strtoupper($value) :
'';
2504 private function gd_load_image(
string $file,
string $mime) {
2507 $image = @imagecreatefromjpeg($file);
2510 $image = @imagecreatefrompng($file);
2513 $image = function_exists(
'imagecreatefromwebp') ? @imagecreatefromwebp($file) : false;
2516 $image = @imagecreatefromgif($file);
2521 if (!$image)
throw new \RuntimeException(
'Bild konnte nicht geladen werden.');
2522 imagealphablending($image,
true);
2523 imagesavealpha($image,
true);
2527 private function render_image_variant_to_file(array $source,
string $file,
int $width,
int $height,
string $fit,
string $mime,
int $quality): void {
2528 if (!hash_equals((string)($source[
'sha256'] ??
''), hash_file(
'sha256', (string)$source[
'file']))) {
2529 throw new \RuntimeException(
'Das Quellbild stimmt nicht mehr mit dem geprüften Plan überein.');
2531 $src = $this->gd_load_image((
string)$source[
'file'], (
string)$source[
'mime']);
2532 $dst = imagecreatetruecolor($width, $height);
2533 imagealphablending($dst,
false);
2534 imagesavealpha($dst,
true);
2535 $transparent = imagecolorallocatealpha($dst, 255, 255, 255, 127);
2536 imagefilledrectangle($dst, 0, 0, $width, $height, $transparent);
2538 $sourceWidth = imagesx($src);
2539 $sourceHeight = imagesy($src);
2542 $crop = is_array($source[
'crop'] ??
null) ? $source[
'crop'] : array();
2544 $sourceX = max(0, min((
int)($crop[
'x'] ?? 0), $sourceWidth - 1));
2545 $sourceY = max(0, min((
int)($crop[
'y'] ?? 0), $sourceHeight - 1));
2546 $sourceWidth = max(1, min((
int)($crop[
'width'] ?? $sourceWidth), imagesx($src) - $sourceX));
2547 $sourceHeight = max(1, min((
int)($crop[
'height'] ?? $sourceHeight), imagesy($src) - $sourceY));
2549 if ($fit ===
'contain') {
2550 $scale = min($width / $sourceWidth, $height / $sourceHeight);
2551 $copyWidth = max(1, (
int)round($sourceWidth * $scale));
2552 $copyHeight = max(1, (
int)round($sourceHeight * $scale));
2553 imagecopyresampled($dst, $src, (
int)floor(($width - $copyWidth) / 2), (
int)floor(($height - $copyHeight) / 2), $sourceX, $sourceY, $copyWidth, $copyHeight, $sourceWidth, $sourceHeight);
2555 $sourceRatio = $sourceWidth / $sourceHeight;
2556 $targetRatio = $width / $height;
2557 if ($sourceRatio > $targetRatio) {
2558 $cropHeight = $sourceHeight;
2559 $cropWidth = (int)round($sourceHeight * $targetRatio);
2560 $srcX = $sourceX + (int)floor(($sourceWidth - $cropWidth) / 2);
2563 $cropWidth = $sourceWidth;
2564 $cropHeight = (int)round($sourceWidth / $targetRatio);
2566 $srcY = $sourceY + (int)floor(($sourceHeight - $cropHeight) / 2);
2568 imagecopyresampled($dst, $src, 0, 0, $srcX, $srcY, $width, $height, $cropWidth, $cropHeight);
2571 $this->gd_apply_tint($dst, (
string)($source[
'tint'] ??
''), (
float)($source[
'tint_strength'] ?? 0));
2572 $this->gd_save_image($dst, $file, $mime, $quality);
2576 private function gd_apply_tint($image,
string $hex,
float $strength): void {
2577 if ($hex ===
'' || $strength <= 0) return;
2578 $r = hexdec(substr($hex, 1, 2));
2579 $g = hexdec(substr($hex, 3, 2));
2580 $b = hexdec(substr($hex, 5, 2));
2581 $overlay = imagecreatetruecolor(imagesx($image), imagesy($image));
2582 imagealphablending($overlay,
false);
2583 imagesavealpha($overlay,
true);
2584 $color = imagecolorallocate($overlay, $r, $g, $b);
2585 imagefilledrectangle($overlay, 0, 0, imagesx($overlay), imagesy($overlay), $color);
2586 imagecopymerge($image, $overlay, 0, 0, 0, 0, imagesx($image), imagesy($image), (
int)round($strength * 100));
2587 imagedestroy($overlay);
2590 private function gd_save_image($image,
string $file,
string $mime,
int $quality): void {
2592 if ($mime ===
'image/webp' && function_exists(
'imagewebp')) {
2593 $ok = @imagewebp($image, $file, $quality);
2594 } elseif ($mime ===
'image/png') {
2595 $compression = (int)round((100 - $quality) / 100 * 9);
2596 $ok = @imagepng($image, $file, max(0, min(9, $compression)));
2597 } elseif ($mime ===
'image/jpeg') {
2598 $white = imagecreatetruecolor(imagesx($image), imagesy($image));
2599 $bg = imagecolorallocate($white, 255, 255, 255);
2600 imagefilledrectangle($white, 0, 0, imagesx($white), imagesy($white), $bg);
2601 imagecopy($white, $image, 0, 0, 0, 0, imagesx($image), imagesy($image));
2602 $ok = @imagejpeg($white, $file, $quality);
2603 imagedestroy($white);
2605 if (!$ok)
throw new \RuntimeException(
'Bildvariante konnte nicht gespeichert werden.');
2608 private function media_folder($value,
string $type): string {
2609 $folder = trim(str_replace(
'\\',
'/', (string)$value),
'/');
2610 $folder = preg_replace(
'~[^A-Za-z0-9/_-]+~',
'-', $folder);
2611 if ($type ===
'video') {
2614 $root = $type ===
'image' ?
'img' :
'file';
2615 if ($folder ===
'' || ($folder !==
$root && strpos($folder,
$root .
'/') !== 0)) $folder = $type ===
'image' ?
'img/images' :
'file/ki';
2619 private function unique_name(
string $dir,
string $name): string {
2620 $base = pathinfo($name, PATHINFO_FILENAME);
2621 $ext = pathinfo($name, PATHINFO_EXTENSION);
2624 while (is_file(rtrim(
$dir,
'/\\') . DIRECTORY_SEPARATOR . $candidate)) {
2625 $candidate =
$base .
'-' . $i++ . ($ext !==
'' ?
'.' . $ext :
'');
2630 private function invalidate_page(
int $id,
string $lng, array $row): void {
2631 dbxContentPageCache::invalidateContent($id);
2632 dbxContentPageCache::invalidateAllMenus();
2633 $previousLng =
dbx()->get_system_var(
'dbx_lng', dbxContentLngSync::masterLng());
2634 dbx()->set_system_var(
'dbx_lng', $lng);
2635 $renderer =
new dbxContentRenderer();
2636 $rights = $renderer->getPublicFolderRights((
int)($row[
'folder'] ?? 0));
2637 if ((
int)($row[
'activ'] ?? 1) === 1 && trim((
string)($row[
'permalink'] ??
'')) !==
'') {
2638 dbxContentPermalinkIndex::upsertPage($id, (
string)$row[
'permalink'], $rights, 1, $lng);
2640 dbxContentPermalinkIndex::removeByCid($id, $lng);
2642 dbxContentHome::refreshHomeCache($this->db, $id, $lng);
2643 dbx()->set_system_var(
'dbx_lng', $previousLng);
2646 private function invalidate_folder(
int $id): void {
2647 dbxContentPageCache::invalidateFolderTree($this->db, $id);
2648 dbxContentPageCache::invalidateAllMenus();
2651 private function invalidate_usage(array $usage): void {
2652 $content = (int)($usage[
'content_id'] ?? 0);
2653 $folder = (int)($usage[
'folder_id'] ?? 0);
2654 if ($content > 0) dbxContentPageCache::invalidateContent($content);
2655 if ($folder > 0) dbxContentPageCache::invalidateFolderTree($this->db, $folder);
2656 dbxContentPageCache::invalidateAllMenus();
2659 private function invalidate_media_references(
int $mediaId): void {
2660 $mediaId = (int)$mediaId;
2661 if ($mediaId <= 0) {
2665 $rows = $this->db->select(
'dbxMediaUsage',
'media_id = ' . $mediaId .
' AND active = 1',
'*',
'id',
'ASC',
'', 0, 0, 0);
2666 foreach (is_array($rows) ? $rows : array() as $row) {
2667 if (is_array($row)) {
2668 $this->invalidate_usage($row);
2673 private function copy_media_usage(
int $sourceId,
int $targetId,
int $targetFolder): int {
2674 $rows = $this->db->select(
'dbxMediaUsage',
'content_id = ' . $sourceId .
' AND active = 1',
'*',
'slot,sorter,id',
'ASC',
'', 0, 0, 0);
2676 foreach (is_array($rows) ? $rows : array() as $row) {
2677 $data = $this->whitelist($row, array(
'media_id',
'slot',
'sorter',
'template',
'caption',
'settings'));
2678 $data[
'active'] = 1;
2679 $data[
'content_id'] = $targetId;
2680 $data[
'folder_id'] = $targetFolder;
2681 if ($this->db->insert(
'dbxMediaUsage', $data) === 1)
$count++;
2686 private function inline_media_src(
int $id): string {
2687 return
'index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=' . max(0, (int)$id);
2690 private function package_media_file_map(): array {
2692 'dbxapp-paket-demo' =>
'paket-demo-360x480.webp',
2693 'dbxapp-paket-non-profit' =>
'paket-nonprofit-360x480.webp',
2694 'dbxapp-paket-business' =>
'paket-business-360x480.webp',
2695 'dbxapp-paket-intranet' =>
'paket-intranet-360x480.webp',
2696 'dbxapp-paket-enterprise' =>
'paket-enterprise-360x480.webp',
2700 private function package_media_id_for_permalink(
string $permalink): int {
2701 $permalink = trim(strtolower($permalink));
2702 $map = $this->package_media_file_map();
2703 $fileName = (string)($map[$permalink] ??
'');
2704 if ($fileName ===
'') {
2707 $where =
"active = 1 AND file_name = '" . str_replace(
"'",
"''", $fileName) .
"'";
2708 $row = $this->db->select1(
'dbxMedia', $where);
2709 return is_array($row) ? (int)($row[
'id'] ?? 0) : 0;
2712 private function package_page_hint(array $page): ?array {
2713 $permalink = trim((string)($page[
'permalink'] ??
''));
2714 $mediaId = $this->package_media_id_for_permalink($permalink);
2715 if ($mediaId <= 0) {
2719 'permalink' => $permalink,
2720 'media_id' => $mediaId,
2721 'file_name' => (
string)($this->package_media_file_map()[strtolower($permalink)] ??
''),
2722 'inline_src' => $this->inline_media_src($mediaId),
2723 'update_patch' => array(
'package_product_image' =>
true),
2727 private function apply_package_product_image(
string $content,
int $mediaId,
string $alt =
''): string {
2728 if ($mediaId <= 0 || stripos($content,
'col-md-4') === false || stripos($content,
'card') === false) {
2731 $srcEsc = htmlspecialchars($this->inline_media_src($mediaId), ENT_QUOTES,
'UTF-8');
2732 $altEsc = htmlspecialchars($alt !==
'' ? $alt :
'Paket', ENT_QUOTES,
'UTF-8');
2733 $img =
'<img class="card-img-top" src="' . $srcEsc .
'" data-cms-media-id="' . $mediaId .
'" alt="' . $altEsc .
'">';
2735 $updated = preg_replace_callback(
2736 '/<div class="col-md-4"><div class="card shadow-sm(?:\s+position-relative)?">(?:<img[^>]*card-img-top[^>]*>)?(?:<span class="position-absolute[^>]*>[\s\S]*?<\/span>)?<div class="card-body text-center">([\s\S]*?)<\/div><\/div><\/div>/i',
2737 function($m) use ($img) {
2738 $body = (string)($m[1] ??
'');
2740 if (preg_match(
'/<span class="badge[^>]*bg-success[^>]*>([\s\S]*?)<\/span>/i', $body, $badgeMatch)) {
2741 $label = trim(strip_tags((
string)($badgeMatch[1] ??
'Kostenlos')));
2742 if ($label !==
'') {
2743 $badge =
'<span class="position-absolute top-0 end-0 badge rounded-pill bg-success m-2">' . htmlspecialchars($label, ENT_QUOTES,
'UTF-8') .
'</span>';
2746 $body = preg_replace(
'/<span class="badge[^>]*>[\s\S]*?<\/span>\s*(?:<br\s*\/?>)?\s*/i',
'', $body, 1);
2747 $body = preg_replace(
'/<img\b[^>]*>\s*/i',
'', $body, 1);
2748 $body = preg_replace(
'/\bh5 mt-3\b/',
'h5', $body, 1);
2749 return '<div class="col-md-4"><div class="card shadow-sm position-relative">' . $img . $badge .
'<div class="card-body text-center">' . $body .
'</div></div></div>';
2755 return is_string($updated) && $updated !==
'' ? $updated : $content;
2758 private function ensure_inline_media_usage(
int $contentId,
int $mediaId): void {
2759 $contentId = (int)$contentId;
2760 $mediaId = (int)$mediaId;
2761 if ($contentId <= 0 || $mediaId <= 0) {
2764 $where =
'content_id = ' . $contentId .
' AND media_id = ' . $mediaId .
" AND slot = 'inline' AND active = 1";
2765 if (is_array($this->db->select1(
'dbxMediaUsage', $where))) {
2770 'media_id' => $mediaId,
2771 'content_id' => $contentId,
2777 'sorter' => $this->next_usage_sorter($contentId, 0,
'inline'),
2779 $this->db->insert(
'dbxMediaUsage', $data);
2782 private function media_inline_payload(
int $id): array {
2783 $id = max(0, (int)$id);
2787 $src = $this->inline_media_src($id);
2789 'inline_src' => $src,
2790 'inline_img' =>
'<img src="' . htmlspecialchars($src, ENT_QUOTES,
'UTF-8') .
'" data-cms-media-id="' . $id .
'" alt="">',
2794 private function normalize_content_inline_media_urls(
string $html): string {
2795 $html = (string)$html;
2796 if ($html ===
'' || stripos($html,
'<img') ===
false) {
2800 return preg_replace_callback(
'/<img\b([^>]*?)>/i',
function($m) {
2801 $tag = (string)($m[0] ??
'');
2802 $attrs = (string)($m[1] ??
'');
2804 if (preg_match(
'/\bdata-cms-media-id=["\']?([0-9]+)/i', $attrs, $id_match)) {
2805 $id = (int)$id_match[1];
2806 } elseif (preg_match(
'/\bdbx_mid=([0-9]+)/i', $attrs, $id_match)) {
2807 $id = (int)$id_match[1];
2808 } elseif (preg_match(
'/\bsrc=(["\'])([^"\']+)\1/i', $attrs, $src_match)) {
2809 $id = $this->media_id_by_inline_src((
string)$src_match[2]);
2814 return $this->patch_img_tag_for_inline_media($tag, $id);
2818 private function media_id_by_inline_src(
string $src): int {
2819 $src = html_entity_decode(trim($src), ENT_QUOTES,
'UTF-8');
2820 if ($src ===
'' || preg_match(
'#^(?:https?:)?//#i', $src) || stripos($src,
'dbx_mid=') !==
false) {
2824 $path = preg_replace(
'/[?#].*$/',
'', str_replace(
'\\',
'/', $src));
2826 if (preg_match(
'#(?:^|/)(?:files/)?media/(.+)$#i', $path, $match)) {
2827 $rel =
'media/' . ltrim((
string)$match[1],
'/');
2832 static $cache = array();
2833 if (isset($cache[$rel])) {
2834 return (
int)$cache[$rel];
2837 $where =
"active = 1 AND file_path = '" . str_replace(
"'",
"''", $rel) .
"'";
2838 $row = $this->db->select1(
'dbxMedia', $where);
2839 if (is_array($row) && (
int)($row[
'id'] ?? 0) > 0) {
2840 return $cache[$rel] = (int)$row[
'id'];
2843 $base = basename($rel);
2845 return $cache[$rel] = 0;
2847 $rows = $this->db->select(
2849 "active = 1 AND file_name = '" . str_replace(
"'",
"''",
$base) .
"'",
2858 if (is_array($rows)) {
2859 foreach ($rows as $candidate) {
2860 $candidatePath = ltrim(str_replace(
'\\',
'/', (
string)($candidate[
'file_path'] ??
'')),
'/');
2861 if ($candidatePath === $rel || basename($candidatePath) ===
$base) {
2862 return $cache[$rel] = (int)($candidate[
'id'] ?? 0);
2867 return $cache[$rel] = 0;
2870 private function patch_img_tag_for_inline_media(
string $tag,
int $id): string {
2871 $id = max(0, (int)$id);
2875 $src = $this->inline_media_src($id);
2876 $src_attr = htmlspecialchars($src, ENT_QUOTES,
'UTF-8');
2877 $tag = preg_replace(
'/\s*data-cms-media-id\s*=\s*["\']?[^"\'>\s]*["\']*/i',
'', $tag);
2878 if (preg_match(
'/\bsrc=(["\'])([^"\']*)\1/i', $tag)) {
2879 $tag = preg_replace(
'/\bsrc=(["\'])([^"\']*)\1/i',
'src="' . $src_attr .
'"', $tag, 1);
2881 $tag = preg_replace(
'/^<img\b/i',
'<img src="' . $src_attr .
'"', $tag);
2883 $tag = preg_replace(
'/^<img\b/i',
'<img data-cms-media-id="' . $id .
'"', $tag);
2888 return $this->catalog();
2892 $catalog = $this->catalog();
2893 if (!isset($catalog[$action]) || !($catalog[$action][
'write'] ??
false)) {
2896 if (!empty($catalog[$action][
'destructive'])) {
2904 throw new \InvalidArgumentException(
'Aktion im Bundle nicht erlaubt: ' . $action);
2906 return $this->build_plan($action, $params);
2911 throw new \InvalidArgumentException(
'Aktion im Bundle nicht erlaubt: ' . $action);
2913 return $this->execute_action($action, $params, $plan);
2917 return
dbx()->action_token(self::TOKEN_SCOPE);
2921 return
dbx()->check_action_token(self::TOKEN_SCOPE, $token);
2925 return $this->snapshot($params);
2929 return $this->describe();