dbxapp 4.1.3
CMS, Shop, Workflows und modulare Geschäftsanwendungen
Loading...
Searching...
No Matches
dbxContent_cms.class.php
Go to the documentation of this file.
1<?php
2namespace dbx\dbxContent_admin;
3
4use dbx\dbxContent\dbxContentHome;
5use dbx\dbxContent\dbxContentLng;
6use dbx\dbxContent\dbxContentLngSync;
7use dbx\dbxContent\dbxContentMediaUsageScope;
8use dbx\dbxContent\dbxContentPageCache;
9use dbx\dbxContent\dbxContentPermalinkIndex;
10use dbx\dbxContent\dbxContentRenderer;
11use dbx\dbxContent\dbxContentTranslate;
12use dbx\dbxContent\dbxContent_permalink;
13
14require_once dirname(__DIR__, 2) . '/dbxContent/include/dbxContent_bootstrap_sync.php';
15require_once __DIR__ . '/dbxContentMediaUsageMaintenance.class.php';
16
17class dbxContent_cms extends \dbxObj {
18
19 private const ACTION_TOKEN_SCOPE = 'dbxContent_admin.actions';
20
21 private $dd_media = 'dbxMedia';
22 private $dd_media_usage = 'dbxMediaUsage';
23 private $cms_form_security = array();
24 private $cmsTexts = null;
25 private $contentTemplateNames = null;
26
34 private function cms_texts() {
35 if ($this->cmsTexts) return $this->cmsTexts;
36 dbx()->get_system_obj('dbxForm', 'use');
37 $texts = new \dbxForm();
38 $texts->init('cms-page-texts');
39 $texts->_fd = 'dbxContent_admin|cms-page';
40 $texts->load_fd_messages();
41 $texts->set_form_help_enabled(false);
42 $this->cmsTexts = $texts;
43 return $this->cmsTexts;
44 }
45
46 private function cms_js_messages(): string {
47 $texts = $this->cms_texts();
48 $keys = array(
49 'page_select_first', 'page_loading', 'page_load_error',
50 'page_saved', 'page_save_error', 'page_deleted', 'page_delete_error',
51 'page_duplicating', 'page_duplicated', 'page_duplicate_error',
52 'folder_selected', 'folder_edit', 'folder_saved', 'folder_save_error',
53 'folder_delete_error', 'folder_select_first', 'folder_name_required',
54 'unsaved', 'saved', 'unsaved_title', 'saved_title',
55 'page_save_title', 'folder_save_title', 'page_delete_title',
56 'folder_delete_title', 'duplicate_title', 'duplicate_select_title',
57 'selection_label', 'tree_show', 'tree_hide', 'right_show', 'right_hide',
58 'editor_marker_tooltip', 'editor_media_tooltip', 'editor_module_tooltip',
59 'editor_bootstrap_tooltip', 'editor_text_format', 'editor_horizontal_rule',
60 'editor_columns_two', 'editor_columns_three',
61 'editor_columns_first', 'editor_columns_second', 'editor_columns_third',
62 'editor_columns_new', 'editor_columns_stacked', 'editor_columns_responsive',
63 'editor_column_add', 'editor_columns_dissolve',
64 'editor_context_menu', 'editor_context_undo', 'editor_context_redo',
65 'editor_context_select_all', 'editor_context_block_up', 'editor_context_block_down',
66 'editor_context_module', 'editor_context_video', 'editor_image_edit', 'editor_image_remove',
67 'editor_context_copy',
68 'editor_context_cut', 'editor_context_paste', 'editor_context_delete',
69 'editor_save_all', 'editor_bold', 'editor_italic', 'editor_underline',
70 'editor_strike', 'editor_align_left', 'editor_align_center',
71 'editor_align_right', 'editor_align_justify', 'editor_print_break',
72 'hero_parent_empty',
73 'media_inline_empty', 'media_inline_focus', 'media_inline_edit',
74 'media_inline_remove', 'media_inline_removed', 'media_gallery_empty', 'media_hero_empty',
75 'media_area_empty', 'media_all_folders', 'media_folders_title',
76 'media_no_folders', 'media_folder_empty', 'media_back',
77 'media_folder_instruction', 'media_folder_label', 'media_count',
78 'media_view', 'media_view_small', 'media_view_medium', 'media_view_large',
79 );
80 $messages = array();
81 foreach ($keys as $key) $messages[$key] = $texts->get_fd_message($key);
82 return json_encode($messages, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
83 }
84
85 private function cms_json_response(array $data): void {
86 if ($this->cms_form_security) {
87 $data['form_security'] = $this->cms_form_security;
88 }
89 dbx()->json_response($data, true);
90 }
91
95 private function media_forms(): dbxContentMediaForms {
96 return dbx()->get_include_obj('dbxContentMediaForms', 'dbxContent_admin');
97 }
98
106 private function verify_media_form(string $kind): bool {
107 $action = $this->base_url($kind === 'external' ? 'cms_external_video' : 'cms_upload');
108 $state = $this->media_forms()->verify($kind, $action);
109 $this->cms_form_security = is_array($state['security'] ?? null) ? $state['security'] : array();
110 return !empty($state['submitted']);
111 }
112
113 private function lng_provision_open_flag($db, string $entity, int $id): int {
114 if (!dbxContentLngSync::isMasterLng() || !is_object($db)) {
115 return 0;
116 }
117
118 if (count(dbxContentLngSync::slaveLngs()) <= 0) {
119 return 0;
120 }
121
122 if ($id <= 0) {
123 return 1;
124 }
125
126 return dbxContentLngSync::hasMissingSlaveLng($db, $entity, $id) ? 1 : 0;
127 }
128
129 private function apply_cms_lng_context(): void {
130 $lng = strtolower(trim((string) dbx()->get_request_var('dbx_lng', '')));
131 if ($lng === '') {
132 return;
133 }
134
135 $allowed = dbxContentLngSync::accessibleLngs();
136 if (!in_array($lng, $allowed, true)) {
137 return;
138 }
139
140 dbx()->set_system_var('dbx_lng', $lng);
141 dbx()->set_remember_var('dbx_lng', $lng, 'dbx');
142 }
143
144 private function ini_size_bytes($value) {
145 $value = trim((string)$value);
146 if ($value === '') return 0;
147 $unit = strtolower(substr($value, -1));
148 $num = (float)$value;
149 if ($unit === 'g') $num *= 1024 * 1024 * 1024;
150 elseif ($unit === 'm') $num *= 1024 * 1024;
151 elseif ($unit === 'k') $num *= 1024;
152 return (int)$num;
153 }
154
155 private function upload_max_bytes() {
156 $upload = $this->ini_size_bytes(ini_get('upload_max_filesize'));
157 $post = $this->ini_size_bytes(ini_get('post_max_size'));
158 if ($upload <= 0) return $post;
159 if ($post <= 0) return $upload;
160 return min($upload, $post);
161 }
162
163 private function base_url($action, $params = array()) {
164 $url = $this->app_url() . '?dbx_modul=dbxContent_admin&dbx_run1=' . rawurlencode((string)$action);
165 foreach ($params as $key => $value) {
166 $url .= '&' . rawurlencode((string)$key) . '=' . rawurlencode((string)$value);
167 }
168 if ($this->tokenized_action((string)$action)) {
169 $url .= '&dbx_token=' . rawurlencode(dbx()->action_token(self::ACTION_TOKEN_SCOPE));
170 }
171 return $url;
172 }
173
181 private function tokenized_action(string $action): bool {
182 return in_array($action, array(
183 'cms_lng_provision',
184 'cms_lng_provision_tree',
185 'cms_lng_reset_sync',
186 'cms_save',
187 'cms_new_page',
188 'cms_duplicate_page',
189 'cms_new_folder',
190 'cms_save_folder',
191 'cms_delete_folder',
192 'cms_delete_page',
193 'cms_move_node',
194 'cms_media',
195 'cms_media_process',
196 'cms_upload',
197 'cms_external_video',
198 'cms_media_folder_create',
199 'cms_media_folder_delete',
200 'cms_media_folder_rename',
201 'cms_media_move',
202 'cms_remove_media',
203 'cms_delete_media',
204 'cms_edit_media',
205 'cms_set_media_slot',
206 'cms_assign_media',
207 'cms_sort_media',
208 ), true);
209 }
210
211 private function action_requires_token(string $action): bool {
212 if ($action === 'cms_media') {
213 return (int)dbx()->get_modul_var('sync', 0, 'int') === 1;
214 }
215 return $this->tokenized_action($action);
216 }
217
218 private function check_action_token(string $action): bool {
219 $token = (string)dbx()->get_modul_var('dbx_token', '', 'varchar|max=128');
220 if (dbx()->check_action_token(self::ACTION_TOKEN_SCOPE, $token)) {
221 return true;
222 }
223
224 dbx()->sys_msg(
225 'security',
226 'dbxContent_admin',
227 $action,
228 'CMS-Aktion ohne gueltigen Token abgewiesen',
229 'ip=' . (string)($_SERVER['REMOTE_ADDR'] ?? '')
230 );
231 return false;
232 }
233
234 private function reject_action_token(string $action) {
235 http_response_code(403);
236 $message = 'Die Aktion wurde aus Sicherheitsgruenden abgewiesen. Bitte laden Sie das CMS neu.';
237 if ($action === 'cms_media_process') {
238 return '<div class="alert alert-danger m-3">' . dbx()->esc($message) . '</div>';
239 }
240 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => $message));
241 return '';
242 }
243
244 private function app_url(): string {
245 $script = str_replace('\\', '/', (string)($_SERVER['SCRIPT_NAME'] ?? ''));
246 if ($script === '') {
247 return '';
248 }
249
250 $dir = str_replace('\\', '/', dirname($script));
251 if ($dir === '.' || $dir === '/' || $dir === '\\') {
252 return '/';
253 }
254
255 return rtrim($dir, '/') . '/';
256 }
257
258 private function load_page_cache_classes(): void {
259 dbx()->load_content_cache_classes();
260 }
261
262 private function sync_permalink_index($db, int $cid, string $lng = ''): void {
263 $cid = (int) $cid;
264 if ($cid <= 0 || !is_object($db)) {
265 return;
266 }
267
268 $this->load_page_cache_classes();
269
270 $lng = strtolower(trim($lng));
271 $prevLng = null;
272 if ($lng !== '' && $lng !== dbxContentLng::current()) {
273 $prevLng = dbx()->get_system_var('dbx_lng', 'de');
274 dbx()->set_system_var('dbx_lng', $lng);
275 }
276
277 $rec = $db->select1(dbxContentLng::ddContent(), $cid, 'permalink,activ,folder,title,seo_title,description,keywords,meta_robots,seo_image_id,update_date,lng_uid', 0);
278 if (!is_array($rec)) {
279 if ($prevLng !== null) {
280 dbx()->set_system_var('dbx_lng', $prevLng);
281 }
282 return;
283 }
284
285 if ((int)($rec['activ'] ?? 0) !== 1) {
286 dbxContentPermalinkIndex::removeByCid($cid, dbxContentLng::current());
287 if (class_exists('dbx\\dbxContent\\dbxContentSitemap')) {
289 }
290 if ($prevLng !== null) {
291 dbx()->set_system_var('dbx_lng', $prevLng);
292 }
293 return;
294 }
295
297 $rights = $renderer->getPublicFolderRights((int)($rec['folder'] ?? 0));
298 $permalink = (string)($rec['permalink'] ?? '');
299
300 dbxContentPageCache::writePageMeta($cid, array(
301 'cid' => $cid,
302 'permalink' => $permalink,
303 'rights' => $rights,
304 'activ' => (int)($rec['activ'] ?? 1),
305 'saved_at' => date('c'),
306 'seo' => dbxContentRenderer::seoMetaFromRecord($rec),
307 ));
308
309 if (trim($permalink) !== '') {
310 dbxContentPermalinkIndex::upsertPage(
311 $cid,
312 $permalink,
313 $rights,
314 (int)($rec['activ'] ?? 1),
315 dbxContentLng::current()
316 );
317 }
318
319 dbxContentHome::refreshHomeCache($db, $cid, dbxContentLng::current());
320
321 if (class_exists('dbx\\dbxContent\\dbxContentSitemap')) {
323 }
324
325 if ($prevLng !== null) {
326 dbx()->set_system_var('dbx_lng', $prevLng);
327 }
328 }
329
330 private function flush_lng_sync_cache($db, array $syncResult): void {
331 if (!is_object($db) || !is_array($syncResult)) {
332 return;
333 }
334
335 $updated = is_array($syncResult['updated'] ?? null) ? $syncResult['updated'] : array();
336 if (!count($updated)) {
337 return;
338 }
339
340 $this->load_page_cache_classes();
341
342 $menuFlushed = false;
343 foreach ($updated as $item) {
344 if (!is_array($item)) {
345 continue;
346 }
347
348 $id = (int) ($item['id'] ?? 0);
349 $lng = trim((string) ($item['lng'] ?? ''));
350 $entity = (($item['entity'] ?? 'page') === 'folder') ? 'folder' : 'page';
351 if ($id <= 0) {
352 continue;
353 }
354
355 if ($entity === 'page') {
356 dbxContentPageCache::invalidateContent($id);
357 if ($lng !== '') {
358 $this->sync_permalink_index($db, $id, $lng);
359 }
360 } elseif (!$menuFlushed) {
361 dbxContentPageCache::invalidateAllMenus();
362 $menuFlushed = true;
363 }
364 }
365
366 if (!$menuFlushed) {
367 dbxContentPageCache::invalidateAllMenus();
368 }
369 }
370
371 private function flush_saved_page_cache($db, int $cid): void {
372 $cid = (int) $cid;
373 if ($cid <= 0) {
374 return;
375 }
376
377 $this->load_page_cache_classes();
378 dbxContentPageCache::invalidateContent($cid);
379 dbxContentPageCache::invalidateAllMenus();
380 $this->sync_permalink_index($db, $cid);
381 }
382
383 private function flush_deleted_page_cache(int $cid, string $lng = ''): void {
384 $cid = (int) $cid;
385 if ($cid <= 0) {
386 return;
387 }
388
389 $this->load_page_cache_classes();
390
391 $lng = strtolower(trim($lng));
392 if ($lng === '') {
393 $lng = dbxContentLng::current();
394 }
395
396 dbxContentPageCache::invalidateContent($cid);
397 dbxContentPageCache::invalidateAllMenus();
398 dbxContentPermalinkIndex::removeByCid($cid, $lng);
399 }
400
401 private function copy_page_media_usage(
402 $db,
403 int $sourceCid,
404 int $targetCid,
405 int $targetFolderId,
406 bool $replace = true,
407 string $sourceLng = '',
408 string $targetLng = ''
409 ): int {
410 $sourceCid = (int) $sourceCid;
411 $targetCid = (int) $targetCid;
412 $targetFolderId = (int) $targetFolderId;
413 $sourceLng = dbxContentMediaUsageScope::language($sourceLng);
414 $targetLng = dbxContentMediaUsageScope::language($targetLng);
415 if ($sourceCid <= 0 || $targetCid <= 0 || !is_object($db)) {
416 return 0;
417 }
418
419 $copySlots = array('hero', 'gallery', 'header', 'teaser', 'footer');
420 if ($replace) {
421 foreach ($copySlots as $slot) {
422 $db->update(
423 $this->dd_media_usage,
424 array('active' => 0),
425 dbxContentMediaUsageScope::withLanguage(
426 "content_id = " . $targetCid . " AND slot = '" . str_replace("'", "''", $slot) . "' AND active = 1",
427 $targetLng
428 ),
429 0,
430 1,
431 1,
432 0
433 );
434 }
435 }
436
437 $rows = $db->select(
438 $this->dd_media_usage,
439 dbxContentMediaUsageScope::withLanguage(
440 "content_id = " . $sourceCid . " AND active = 1 AND slot IN ('hero','gallery','header','teaser','footer')",
441 $sourceLng
442 ),
443 '*',
444 'slot,sorter,id',
445 'ASC',
446 '',
447 0,
448 0,
449 0
450 );
451 if (!is_array($rows)) {
452 return 0;
453 }
454
455 $copied = 0;
456 foreach ($rows as $usage) {
457 if (!is_array($usage)) {
458 continue;
459 }
460 $mediaId = (int) ($usage['media_id'] ?? 0);
461 if ($mediaId <= 0) {
462 continue;
463 }
464 $media = $db->select1($this->dd_media, $mediaId, 'active', 0);
465 if (!is_array($media) || (int) ($media['active'] ?? 0) !== 1) {
466 continue;
467 }
468
469 $slot = $this->valid_media_slot($usage['slot'] ?? 'gallery');
470 if ($slot === 'inline') {
471 continue;
472 }
473
474 $usageId = $this->create_media_usage(
475 $db,
476 $mediaId,
477 $targetCid,
478 $targetFolderId,
479 $slot,
480 (string) ($usage['template'] ?? ''),
481 (string) ($usage['caption'] ?? ''),
482 (string) ($usage['settings'] ?? ''),
483 $targetLng
484 );
485 if ($usageId > 0) {
486 $copied++;
487 }
488 }
489
490 return $copied;
491 }
492
493 private function sync_lng_page_media_from_master($db, int $masterCid, int $slaveCid, string $lng): int {
494 $masterCid = (int) $masterCid;
495 $slaveCid = (int) $slaveCid;
496 $lng = strtolower(trim($lng));
497 if ($masterCid <= 0 || $slaveCid <= 0 || $lng === '' || !is_object($db)) {
498 return 0;
499 }
500
501 $slaveRow = $db->select1(dbxContentLng::ddContent($lng), $slaveCid, 'content,folder', 0);
502 if (!is_array($slaveRow)) {
503 return 0;
504 }
505
506 $slaveFolder = (int) ($slaveRow['folder'] ?? 0);
507 $copied = $this->copy_page_media_usage(
508 $db,
509 $masterCid,
510 $slaveCid,
511 $slaveFolder,
512 true,
513 dbxContentLngSync::masterLng(),
514 $lng
515 );
516 $this->sync_inline_media_usage($db, $slaveCid, (string) ($slaveRow['content'] ?? ''), $slaveFolder, null, false, $lng);
517 return $copied;
518 }
519
520 private function apply_lng_sync_media($db, int $masterCid, array $syncResult): int {
521 $master = dbxContentLngSync::masterLng();
522 $copied = 0;
523 foreach (($syncResult['updated'] ?? array()) as $item) {
524 if (!is_array($item) || (($item['entity'] ?? 'page') !== 'page')) {
525 continue;
526 }
527 $lng = strtolower(trim((string) ($item['lng'] ?? '')));
528 $slaveId = (int) ($item['id'] ?? 0);
529 if ($slaveId <= 0 || $lng === '' || $lng === $master) {
530 continue;
531 }
532 $copied += $this->sync_lng_page_media_from_master($db, $masterCid, $slaveId, $lng);
533 }
534 return $copied;
535 }
536
537 private function apply_lng_provision_media($db, string $type, int $masterId, array $result): int {
538 if ($type !== 'page' || !is_object($db)) {
539 return 0;
540 }
541
542 $copied = 0;
543 $items = array_merge(
544 is_array($result['created'] ?? null) ? $result['created'] : array(),
545 is_array($result['updated'] ?? null) ? $result['updated'] : array()
546 );
547 foreach ($items as $item) {
548 if (!is_array($item)) {
549 continue;
550 }
551 $lng = strtolower(trim((string) ($item['lng'] ?? '')));
552 $slaveId = (int) ($item['id'] ?? 0);
553 if ($slaveId <= 0 || $lng === '') {
554 continue;
555 }
556 $copied += $this->sync_lng_page_media_from_master($db, $masterId, $slaveId, $lng);
557 }
558 return $copied;
559 }
560
561 private function flush_deleted_folder_cache($db, int $folderId, string $lng = ''): void {
562 $folderId = (int) $folderId;
563 if ($folderId < 0 || !is_object($db)) {
564 return;
565 }
566
567 $this->load_page_cache_classes();
568
569 $lng = strtolower(trim($lng));
570 $prevLng = null;
571 if ($lng !== '' && $lng !== dbxContentLng::current()) {
572 $prevLng = dbx()->get_system_var('dbx_lng', 'de');
573 dbx()->set_system_var('dbx_lng', $lng);
574 }
575
576 dbxContentPageCache::invalidateFolderTree($db, $folderId);
577 dbxContentPageCache::invalidateAllMenus();
578
579 if ($prevLng !== null) {
580 dbx()->set_system_var('dbx_lng', $prevLng);
581 }
582 }
583
584 private function flush_folder_cache($db, int $folderId): void {
585 $folderId = (int) $folderId;
586 if ($folderId < 0 || !is_object($db)) {
587 return;
588 }
589
590 $this->load_page_cache_classes();
591 dbxContentPageCache::invalidateFolderTree($db, $folderId);
592 dbxContentPageCache::invalidateAllMenus();
593 }
594
595 private function flush_menu_cache(): void {
596 $this->load_page_cache_classes();
597 dbxContentPageCache::invalidateAllMenus();
598 }
599
600 private function flush_media_cache($db, int $content_id = 0, int $folder_id = 0): void {
601 if ($content_id <= 0 && $folder_id <= 0) {
602 return;
603 }
604
605 $this->load_page_cache_classes();
606 if ($content_id > 0) {
607 dbxContentPageCache::invalidateContent($content_id);
608 }
609 if ($folder_id > 0 && is_object($db)) {
610 dbxContentPageCache::invalidateFolderTree($db, $folder_id);
611 }
612 dbxContentPageCache::invalidateAllMenus();
613 }
614
615 private function flush_media_by_media_id($db, int $media_id): void {
616 $media_id = (int) $media_id;
617 if ($media_id <= 0 || !is_object($db)) {
618 return;
619 }
620
621 $usage_rows = $db->select($this->dd_media_usage, 'media_id = ' . $media_id . ' AND active = 1', '*', 'id', 'ASC', '', 0, 0, 0);
622 if (!is_array($usage_rows)) {
623 return;
624 }
625
626 foreach ($usage_rows as $usage) {
627 if (!is_array($usage)) {
628 continue;
629 }
630 $this->flush_media_cache(
631 $db,
632 (int)($usage['content_id'] ?? 0),
633 (int)($usage['folder_id'] ?? 0)
634 );
635 }
636 }
637
638 private function request_json() {
639 $data = array();
640
641 $raw = file_get_contents('php://input');
642 if (is_string($raw) && trim($raw) !== '') {
643 $decoded = json_decode($raw, true);
644 if (is_array($decoded)) {
645 $data = $decoded;
646 }
647 }
648
649 if (!count($data) && !empty($_POST) && is_array($_POST)) {
650 $data = $_POST;
651 }
652
653 return $data;
654 }
655
656 private function normalize_content_media_urls($html) {
657 $html = (string)$html;
658 $html = preg_replace_callback('/<figure\b([^>]*dbx-cms-inline-video-block[^>]*)>[\s\S]*?<\/figure>/i', function($m) {
659 $attrs = $m[1];
660 if (!preg_match('/data-cms-media-id=["\']?([0-9]+)/i', $attrs, $id_match)
661 && !preg_match('/data-cms-media-id=["\']?([0-9]+)/i', $m[0], $id_match)
662 && !preg_match('/dbx_mid=([0-9]+)/i', $m[0], $id_match)) return $m[0];
663 $id = (int)$id_match[1];
664 $placeholder = $this->inline_media_placeholder_html($id);
665 if ($placeholder === '') return $m[0];
666 $attrs = preg_replace('/\scontenteditable=(["\']).*?\1/i', '', $attrs);
667 $attrs = preg_replace('/\sdata-cms-media-id\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $attrs);
668 $attrs .= ' data-cms-media-id="' . $id . '"';
669 if (stripos($attrs, 'data-cms-media-slot=') === false) $attrs .= ' data-cms-media-slot="inline"';
670 return '<figure' . $attrs . '>' . $placeholder . '</figure>';
671 }, $html);
672 /*
673 * Alte CMS-Seiten konnten Videos als freies <video><source ...></video>
674 * enthalten. Der Editor arbeitet inzwischen mit stabilen, über die
675 * Medien-ID gerenderten Platzhaltern. Die Konvertierung hält bestehende
676 * Inhalte beim nächsten Speichern kompatibel.
677 */
678 $html = preg_replace_callback('/<video\b([^>]*)>([\s\S]*?)<\/video>/i', function($m) {
679 $attrs = (string)($m[1] ?? '');
680 $inner = (string)($m[2] ?? '');
681 $source = $attrs . ' ' . $inner;
682 $id = 0;
683 if (preg_match('/data-cms-media-id=["\']?([0-9]+)/i', $source, $id_match)
684 || preg_match('/dbx_mid=([0-9]+)/i', $source, $id_match)) {
685 $id = (int)($id_match[1] ?? 0);
686 }
687 if ($id <= 0) return $m[0];
688
689 $placeholder = $this->inline_media_placeholder_html($id);
690 if ($placeholder === '') return $m[0];
691
692 $video_attrs = ' class="dbx-cms-inline-media dbx-cms-inline-video-block"'
693 . ' data-cms-media-id="' . $id . '" data-cms-media-slot="inline"';
694 foreach (array('autoplay', 'loop', 'muted') as $option) {
695 $enabled = preg_match('/(?:^|\s)' . $option . '(?:\s|=|$)/i', $attrs) === 1;
696 $video_attrs .= ' data-cms-video-' . $option . '="' . ($enabled ? '1' : '0') . '"';
697 }
698 return '<figure' . $video_attrs . '>' . $placeholder . '</figure>';
699 }, $html);
700 $html = preg_replace('/<(video|iframe|source)\b[^>]*data-cms-media-id=["\']?([0-9]+)[^>]*>(?:\s*<\/\1>)?/i', '', $html);
701 $html = preg_replace_callback('/<(img|video|iframe|source)\b([^>]*?)>/i', function($m) {
702 $tag = $m[0];
703 $tag_name = strtolower((string)($m[1] ?? ''));
704 $id = 0;
705 if (preg_match('/dbx_mid=([0-9]+)/i', $tag, $id_match)) {
706 $id = (int)$id_match[1];
707 $tag = preg_replace('/\sdata-cms-media-id\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $tag);
708 $tag = preg_match('/\/\s*>$/', $tag)
709 ? preg_replace('/\s*\/\s*>$/', ' data-cms-media-id="' . $id . '" />', $tag)
710 : preg_replace('/\s*>$/', ' data-cms-media-id="' . $id . '">', $tag);
711 } elseif (preg_match('/data-cms-media-id=["\']?([0-9]+)/i', $tag, $id_match)) {
712 $id = (int)$id_match[1];
713 }
714 if ($tag_name === 'img' && $id > 0 && !$this->inline_media_available($id)) {
715 return $this->inline_media_missing_html($id);
716 }
717 if (stripos($tag, 'data-cms-media-slot=') === false && $id > 0) {
718 $tag = preg_match('/\/\s*>$/', $tag)
719 ? preg_replace('/\s*\/\s*>$/', ' data-cms-media-slot="inline" />', $tag)
720 : preg_replace('/\s*>$/', ' data-cms-media-slot="inline">', $tag);
721 }
722 return $tag;
723 }, $html);
724 $html = preg_replace_callback('/<(p|figure|div)\b([^>]*\bclass\s*=\s*(?:"[^"]*\bdbx-cms-inline-media\b[^"]*"|\'[^\']*\bdbx-cms-inline-media\b[^\']*\')[^>]*)>([\s\S]*?)<\/\1>/i', function($m) {
725 $tag = (string)($m[1] ?? 'p');
726 $attrs = (string)($m[2] ?? '');
727 $inner = (string)($m[3] ?? '');
728 if (stripos($attrs, 'dbx-cms-inline-video-block') !== false) {
729 return $m[0];
730 }
731 if (!preg_match('/<img\b[^>]*\bsrc\s*=\s*(?:"[^"]*dbx_mid=([0-9]+)[^"]*"|\'[^\']*dbx_mid=([0-9]+)[^\']*\')/i', $inner, $id_match)) {
732 return $m[0];
733 }
734 $id = (int)(($id_match[1] ?? 0) ?: ($id_match[2] ?? 0));
735 if ($id <= 0) return $m[0];
736 $attrs = preg_replace('/\sdata-cms-media-id\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $attrs);
737 if (stripos($attrs, 'data-cms-media-slot=') === false) $attrs .= ' data-cms-media-slot="inline"';
738 $inner = preg_replace_callback('/<img\b([^>]*)>/i', function($img) use ($id) {
739 $img_attrs = (string)($img[1] ?? '');
740 if (!preg_match('/\bsrc\s*=\s*(?:"[^"]*dbx_mid=' . $id . '\b[^"]*"|\'[^\']*dbx_mid=' . $id . '\b[^\']*\')/i', $img_attrs)) {
741 return $img[0];
742 }
743 $img_attrs = preg_replace('/\sdata-cms-media-id\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $img_attrs);
744 if (stripos($img_attrs, 'data-cms-media-slot=') === false) $img_attrs .= ' data-cms-media-slot="inline"';
745 return '<img' . $img_attrs . ' data-cms-media-id="' . $id . '">';
746 }, $inner, 1);
747 return '<' . $tag . $attrs . '>' . $inner . '</' . $tag . '>';
748 }, $html);
749 $html = $this->strip_empty_inline_media_wrappers($html);
750 $html = str_replace(
751 array(
752 '?dbx_modul=dbxContent&amp;dbx_run1=media&amp;dbx_mid=',
753 '?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=',
754 'index.phpindex.php?',
755 ),
756 array(
757 'index.php?dbx_modul=dbxContent&amp;dbx_run1=media&amp;dbx_mid=',
758 'index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=',
759 'index.php?',
760 ),
761 $html
762 );
763 return $html;
764 }
765
766 private function content_node_has_visible_value($node): bool {
767 foreach ($node->childNodes as $child) {
768 if ($child instanceof \DOMText) {
769 $value = preg_replace('/[\s\x{00A0}\x{200B}\x{FEFF}]+/u', '', (string)$child->nodeValue);
770 if ($value !== '') return true;
771 continue;
772 }
773
774 if (!($child instanceof \DOMElement)) continue;
775 $tag = strtolower($child->tagName);
776 if (in_array($tag, array('br', 'wbr'), true)) continue;
777 if (in_array($tag, array('img', 'video', 'audio', 'iframe', 'object', 'embed', 'table', 'hr', 'svg', 'canvas', 'input', 'textarea', 'select', 'button'), true)) {
778 return true;
779 }
780 if ($this->content_node_has_visible_value($child)) return true;
781 }
782 return false;
783 }
784
785 private function strip_empty_content_paragraphs_fallback(string $html): string {
786 $pattern = '#<p\b[^>]*>(?:\s|&nbsp;|&#0*160;|&#x0*a0;|<br\b[^>]*>|<wbr\b[^>]*>|<!--[\s\S]*?-->)*</p>#iu';
787 do {
788 $clean = preg_replace($pattern, '', $html);
789 if ($clean === null || $clean === $html) break;
790 $html = $clean;
791 } while (true);
792 return $html;
793 }
794
795 private function safe_content_style(string $style, string $tag, string $className = ''): string {
796 $tag = strtolower(trim($tag));
797 $className = strtolower(trim($className));
798 $isMediaLayout = in_array($tag, array('img', 'video', 'iframe', 'figure'), true)
799 || preg_match('/(?:^|\s)dbx-cms-inline-(?:media|image|video|video-block)(?:\s|$)/', $className);
800 $safe = array();
801
802 foreach (explode(';', $style) as $declaration) {
803 if (strpos($declaration, ':') === false) continue;
804 list($property, $value) = array_map('trim', explode(':', $declaration, 2));
805 $property = strtolower($property);
806 $value = strtolower($value);
807 if ($property === '' || $value === '') continue;
808
809 if ($property === 'text-align' && in_array($value, array('left', 'right', 'center', 'justify', 'start', 'end'), true)) {
810 $safe[$property] = $value;
811 continue;
812 }
813
814 if (!$isMediaLayout) continue;
815 if ($property === 'float' && in_array($value, array('left', 'right', 'none'), true)) {
816 $safe[$property] = $value;
817 continue;
818 }
819 if ($property === 'display' && in_array($value, array('block', 'inline-block'), true)) {
820 $safe[$property] = $value;
821 continue;
822 }
823 if (in_array($property, array('width', 'height', 'max-width', 'max-height'), true)
824 && preg_match('/^(?:auto|0|(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)(?:px|%|vw|vh|rem|em))$/', $value)) {
825 $safe[$property] = $value;
826 continue;
827 }
828 if (in_array($property, array('margin-left', 'margin-right'), true)
829 && preg_match('/^(?:auto|0|(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)(?:px|%|rem|em))$/', $value)) {
830 $safe[$property] = $value;
831 }
832 }
833
834 $result = '';
835 foreach ($safe as $property => $value) {
836 $result .= $property . ': ' . $value . '; ';
837 }
838 return trim($result);
839 }
840
841 private function sanitize_content_html($html) {
842 $html = (string)$html;
843 if ($html === '') {
844 return '';
845 }
846
847 $html = preg_replace('#<(script|style|object|embed|base|meta|link)\b[^>]*>.*?</\1>#is', '', $html);
848 $html = preg_replace('#<(script|style|object|embed|base|meta|link)\b[^>]*\/?>#is', '', $html);
849
850 if (!class_exists('\DOMDocument')) {
851 $html = preg_replace('/\s+on[a-z]+\s*=\s*(["\']).*?\1/is', '', $html);
852 $html = preg_replace('/\s+style\s*=\s*(["\']).*?\1/is', '', $html);
853 $html = preg_replace('/\s+(href|src)\s*=\s*(["\'])\s*javascript:[^"\']*\2/is', '', $html);
854 return $this->strip_empty_content_paragraphs_fallback($html);
855 }
856
857 $previous = libxml_use_internal_errors(true);
858 $doc = new \DOMDocument('1.0', 'UTF-8');
859 $wrapped = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body><div id="dbx_cms_sanitize_root">' . $html . '</div></body></html>';
860 $doc->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
861 libxml_clear_errors();
862 libxml_use_internal_errors($previous);
863
864 $root = $doc->getElementById('dbx_cms_sanitize_root');
865 if (!$root) {
866 return $html;
867 }
868
869 $walker = function($node) use (&$walker) {
870 if ($node instanceof \DOMElement) {
871 $remove = array();
872 $safeStyle = '';
873 foreach ($node->attributes as $attr) {
874 $name = strtolower($attr->name);
875 $value = trim((string)$attr->value);
876 if ($name === 'style') {
877 $safeStyle = $this->safe_content_style(
878 $value,
879 (string)$node->tagName,
880 (string)$node->getAttribute('class')
881 );
882 $remove[] = $attr->name;
883 continue;
884 }
885 if (strpos($name, 'on') === 0) {
886 $remove[] = $attr->name;
887 continue;
888 }
889 if (($name === 'href' || $name === 'src' || $name === 'xlink:href') && preg_match('/^\s*javascript:/i', $value)) {
890 $remove[] = $attr->name;
891 continue;
892 }
893 if ($name === 'data-dbx') {
894 if (stripos($value, 'lib=openWin') === false || stripos($value, 'javascript:') !== false) {
895 $remove[] = $attr->name;
896 }
897 }
898 }
899 foreach ($remove as $name) {
900 $node->removeAttribute($name);
901 }
902 if ($safeStyle !== '') {
903 $node->setAttribute('style', $safeStyle);
904 }
905 }
906
907 foreach (iterator_to_array($node->childNodes) as $child) {
908 $walker($child);
909 }
910 };
911 $walker($root);
912
913 $emptyParagraphs = array();
914 foreach ($root->getElementsByTagName('p') as $paragraph) {
915 if (!$this->content_node_has_visible_value($paragraph)) {
916 $emptyParagraphs[] = $paragraph;
917 }
918 }
919 foreach ($emptyParagraphs as $paragraph) {
920 if ($paragraph->parentNode) $paragraph->parentNode->removeChild($paragraph);
921 }
922
923 $out = '';
924 foreach ($root->childNodes as $child) {
925 $out .= $doc->saveHTML($child);
926 }
927 return $out;
928 }
929
930 private function normalize_and_sanitize_content($html) {
931 return $this->sanitize_content_html($this->normalize_content_media_urls($html));
932 }
933
934 private function inline_media_available($id) {
935 $id = (int)$id;
936 if ($id <= 0) return false;
937 $db = dbx()->get_system_obj('dbxDB');
938 $row = $db->select1($this->dd_media, $id);
939 if (!is_array($row) || (int)($row['active'] ?? 0) !== 1) return false;
940 return $this->media_file_exists($row);
941 }
942
943 private function inline_media_missing_html($id) {
944 $id = (int)$id;
945 if ($id <= 0) return '';
946 $label = 'Mediendatei #' . $id . ' nicht verfuegbar';
947 return '<p class="dbx-cms-inline-media dbx-cms-inline-media-missing-wrap" data-cms-media-id="' . $id . '" data-cms-media-slot="inline" contenteditable="false" tabindex="0" title="Fehlende Mediendatei auswählen, Entf zum Löschen"><span class="dbx-cms-inline-media-missing" aria-hidden="true">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</span></p>';
948 }
949
950 private function inline_media_placeholder_html($id) {
951 $id = (int)$id;
952 if ($id <= 0) return '';
953 $db = dbx()->get_system_obj('dbxDB');
954 $row = $db->select1($this->dd_media, $id);
955 if (!is_array($row) || (int)($row['active'] ?? 0) !== 1) return '';
956 $row = $this->normalize_media_row($row);
957 $title = htmlspecialchars((string)($row['title'] ?? $row['alt'] ?? $row['file_name'] ?? 'Video'), ENT_QUOTES, 'UTF-8');
958 $attr = ' data-cms-media-id="' . $id . '" data-cms-media-slot="inline"';
959 if (!in_array($this->media_type($row), array('video', 'external_video'), true)) return '';
960 $thumb = htmlspecialchars((string)($row['thumb_url'] ?? ''), ENT_QUOTES, 'UTF-8');
961 if ($thumb !== '') return '<img class="dbx-cms-inline-video-thumb" src="' . $thumb . '" alt="' . $title . '" title="' . $title . '"' . $attr . '><span class="dbx-cms-inline-video-play" aria-hidden="true"><i class="bi bi-play-fill"></i></span>';
962 return '<span class="dbx-cms-inline-video-empty"' . $attr . '>' . $title . '</span><span class="dbx-cms-inline-video-play" aria-hidden="true"><i class="bi bi-play-fill"></i></span>';
963 }
964
965 private function inline_media_embed_html(array $row) {
966 $id = (int)($row['id'] ?? 0);
967 if ($id <= 0) return '';
968 $row = $this->normalize_media_row($row);
969 $attr = ' data-cms-media-id="' . $id . '" data-cms-media-slot="inline" contenteditable="false" tabindex="0"';
970 if (in_array($this->media_type($row), array('video', 'external_video'), true)) {
971 $inner = $this->inline_media_placeholder_html($id);
972 return $inner !== '' ? '<figure class="dbx-cms-inline-media dbx-cms-inline-video-block"' . $attr . '>' . $inner . '</figure><p></p>' : '';
973 }
974 $url = htmlspecialchars((string)($row['url'] ?? $this->media_url($row)), ENT_QUOTES, 'UTF-8');
975 if ($url === '') return '';
976 $alt = htmlspecialchars((string)($row['alt'] ?? $row['title'] ?? $row['file_name'] ?? ''), ENT_QUOTES, 'UTF-8');
977 $title = htmlspecialchars((string)($row['title'] ?? $row['alt'] ?? $row['file_name'] ?? ''), ENT_QUOTES, 'UTF-8');
978 return '<p class="dbx-cms-inline-media"' . $attr . '><img class="dbx-cms-inline-image" src="' . $url . '" alt="' . $alt . '" title="' . $title . '"' . $attr . '></p><p></p>';
979 }
980
981 private function bool_int($value, $default = 1) {
982 if ($value === '' || $value === null) return $default;
983 return (int)((string)$value === '1' || $value === 1 || $value === true || $value === 'on');
984 }
985
986 private function clean_text($value, $max = 254) {
987 $value = trim((string)$value);
988 if ($max > 0 && strlen($value) > $max) {
989 $value = substr($value, 0, $max);
990 }
991 return $value;
992 }
993
994 private function slug($text) {
995 return dbxContent_permalink::slug($text);
996 }
997
998 private function page_permalink($db, $folder_id, $title, $permalink, int $excludeId = 0) {
999 $permalink = trim($this->clean_text($permalink, 254));
1000 if ($permalink === '') {
1001 return dbxContent_permalink::build($db, dbxContentLng::ddFolder(), $folder_id, $title, $excludeId);
1002 }
1003 if (!dbxContent_permalink::isValid($permalink)) {
1004 throw new \InvalidArgumentException('Permalink: nur Kleinbuchstaben, Zahlen und einzelne Bindestriche sind erlaubt.');
1005 }
1006 if (dbxContent_permalink::exists($db, dbxContentLng::ddContent(), $permalink, $excludeId)) {
1007 throw new \InvalidArgumentException('Dieser Permalink wird bereits von einer anderen Seite verwendet.');
1008 }
1009 return $permalink;
1010 }
1011
1012 private function page_permalink_exists($db, string $permalink, int $excludeId = 0): bool {
1013 $permalink = dbxContent_permalink::normalize($permalink);
1014 return dbxContent_permalink::exists($db, dbxContentLng::ddContent(), $permalink, $excludeId);
1015 }
1016
1017 private function duplicate_page_permalink($db, int $folderId, string $title, string $permalink): string {
1018 $base = dbxContent_permalink::normalize($this->clean_text($permalink, 254));
1019 if ($base === '') {
1020 $base = dbxContent_permalink::build($db, dbxContentLng::ddFolder(), $folderId, $title);
1021 }
1022 if ($base === '') {
1023 $base = 'seite';
1024 }
1025
1026 $copyNumber = 1;
1027 do {
1028 $suffix = $copyNumber === 1 ? '-kopie' : '-kopie-' . $copyNumber;
1029 $maxBaseLength = max(1, 254 - strlen($suffix));
1030 $candidateBase = rtrim(substr($base, 0, $maxBaseLength), '/-');
1031 if ($candidateBase === '') {
1032 $candidateBase = 'seite';
1033 }
1034 $candidate = dbxContent_permalink::normalize($candidateBase . $suffix);
1035 $copyNumber++;
1036 } while ($this->page_permalink_exists($db, $candidate));
1037
1038 return $candidate;
1039 }
1040
1041 private function resolve_cms_page_id(): int {
1042 $id = (int)dbx()->get_modul_var('cid', 0, 'int');
1043 if ($id <= 0) {
1044 $id = (int)dbx()->get_modul_var('dbx_cid', 0, 'int');
1045 }
1046 if ($id <= 0) {
1047 $id = (int)dbx()->get_modul_var('rid', 0, 'int');
1048 }
1049 if ($id <= 0) {
1050 $id = (int)dbx()->get_modul_var('id', 0, 'int');
1051 }
1052 if ($id > 0) {
1053 return $id;
1054 }
1055
1056 $rawPermalink = trim((string)dbx()->get_request_var('permalink', ''));
1057 $permalink = dbxContent_permalink::isValid($rawPermalink)
1058 ? $rawPermalink
1059 : dbxContent_permalink::canonicalFromLegacy($rawPermalink);
1060 $db = dbx()->get_system_obj('dbxDB');
1061 if ($permalink !== '') {
1062 $rec = $db->select1(dbxContentLng::ddContent(), array('permalink' => $permalink), 'id', 0);
1063 $permalinkId = is_array($rec) ? (int)($rec['id'] ?? 0) : 0;
1064 if ($permalinkId > 0) return $permalinkId;
1065 }
1066
1067 // Eine initiale Seite wird mit einer kleinen Einzelabfrage bestimmt.
1068 // Dafuer muss der vollstaendige Content-Baum nicht aufgebaut werden.
1069 $rows = $db->select(
1070 dbxContentLng::ddContent(),
1071 '',
1072 'id',
1073 'sorter,title,id',
1074 'ASC',
1075 '',
1076 1,
1077 0,
1078 0
1079 );
1080 return is_array($rows) && isset($rows[0]) ? (int)($rows[0]['id'] ?? 0) : 0;
1081 }
1082
1083 private function attach_unreachable_tree_nodes(array $tree): array {
1084 $reachablePageIds = array();
1085 $reachableFolderIds = array();
1086 foreach (is_array($tree['flat'] ?? null) ? $tree['flat'] : array() as $node) {
1087 if (!is_array($node)) {
1088 continue;
1089 }
1090 if (($node['_type'] ?? '') === 'page') {
1091 $reachablePageIds[(int)($node['_id'] ?? 0)] = true;
1092 } elseif (($node['_type'] ?? '') === 'folder') {
1093 $reachableFolderIds[(int)($node['_id'] ?? 0)] = true;
1094 }
1095 }
1096
1097 $items = is_array($tree['items'] ?? null) ? $tree['items'] : array();
1098 $orphanPages = array();
1099 foreach ($items as $row) {
1100 if (!is_array($row)) {
1101 continue;
1102 }
1103 $id = (int)($row['id'] ?? 0);
1104 if ($id > 0 && !isset($reachablePageIds[$id])) {
1105 $orphanPages[] = $row;
1106 }
1107 }
1108 if (!count($orphanPages)) {
1109 return $tree;
1110 }
1111
1112 $children = array();
1113 foreach ($orphanPages as $row) {
1114 $id = (int)($row['id'] ?? 0);
1115 if ($id <= 0) {
1116 continue;
1117 }
1118 $parent = (int)($row['folder'] ?? 0);
1119 $children[] = $row + array(
1120 '_node_id' => 'page-' . $id,
1121 '_type' => 'page',
1122 '_id' => $id,
1123 '_parent' => $parent,
1124 '_title' => (string)($row['title'] ?? ('Seite ' . $id)),
1125 '_rights' => (string)($row['group_read'] ?? ''),
1126 '_children' => array(),
1127 '_level' => 0,
1128 );
1129 }
1130
1131 if (!count($children)) {
1132 return $tree;
1133 }
1134
1135 $orphanFolder = array(
1136 'id' => -999001,
1137 'name' => 'Nicht im Baum',
1138 '_node_id' => 'folder--999001',
1139 '_type' => 'folder',
1140 '_id' => -999001,
1141 '_parent' => 0,
1142 '_title' => 'Nicht im Baum',
1143 '_rights' => '',
1144 '_children' => $children,
1145 '_level' => 0,
1146 );
1147
1148 $tree['nodes'] = array_merge(is_array($tree['nodes'] ?? null) ? $tree['nodes'] : array(), array($orphanFolder));
1149 foreach ($children as $child) {
1150 $tree['flat'][] = $child;
1151 }
1152 $tree['flat'][] = $orphanFolder;
1153
1154 return $tree;
1155 }
1156
1157 private function cms_tree() {
1158 $db = dbx()->get_system_obj('dbxDB');
1159 $this->ensure_cms_schema($db);
1160 $tree = $db->select_tree(dbxContentLng::ddFolder(), dbxContentLng::ddContent(), array(
1161 'folder_order' => 'sorter,name,id',
1162 'item_order' => 'sorter,title,id',
1163 'verify_access' => 0,
1164 ));
1165 $tree = $this->attach_unreachable_tree_nodes($tree);
1166 return $this->decorate_tree($tree);
1167 }
1168
1169 private function clear_cms_tpl_cache() {
1170 if (isset($_SESSION['dbx']['cache']['tpl']['dbxContent_admin']) && is_array($_SESSION['dbx']['cache']['tpl']['dbxContent_admin'])) {
1171 foreach (array('cms-admin', 'cms-admin-frame', 'cms-admin-header', 'cms-admin-left', 'cms-admin-middle', 'cms-admin-right', 'cms-admin-media-panel', 'cms-admin-page-form', 'cms-admin-folder-form', 'cms-admin-settings-panels') as $tpl) {
1172 unset($_SESSION['dbx']['cache']['tpl']['dbxContent_admin'][$tpl]);
1173 }
1174 }
1175 if (isset($_SESSION['dbx']['cache']['tpl']['dbx']) && is_array($_SESSION['dbx']['cache']['tpl']['dbx'])) {
1176 foreach (array('module-bar-cms', 'bar-cms', 'bar-title-cms', 'bar-middle-cms', 'bar-actions-cms') as $tpl) {
1177 unset($_SESSION['dbx']['cache']['tpl']['dbx'][$tpl]);
1178 }
1179 }
1180 }
1181
1189 public function ensure_schema($db) {
1190 return;
1191 }
1192
1193 private function ensure_cms_schema($db) {
1194 return;
1195 }
1196
1197 private function normalize_gallery_row(array $row) {
1198 $defaults = array(
1199 'gallery_template' => 'image-gallery',
1200 'gallery_visible_count' => '3',
1201 'gallery_image_size' => 'original',
1202 'gallery_lightbox_width' => '100vw',
1203 'gallery_overflow' => 'grid',
1204 'gallery_click_behavior' => 'lightbox',
1205 );
1206
1207 foreach ($defaults as $field => $value) {
1208 $current = trim((string)($row[$field] ?? ''));
1209 if ($current === '' || strtolower($current) === 'parent') {
1210 $row[$field] = $value;
1211 }
1212 }
1213
1214 return $row;
1215 }
1216
1217 private function tree_records_by_id($db, string $dd, string $fields): array {
1218 $rows = $db->select($dd, '', $fields, 'id', 'ASC', '', 0, 0, 0);
1219 $out = array();
1220 foreach (is_array($rows) ? $rows : array() as $row) {
1221 $id = (int)($row['id'] ?? 0);
1222 if ($id > 0) $out[$id] = $row;
1223 }
1224 return $out;
1225 }
1226
1227 private function tree_lng_coverage_rows($db): array {
1228 $out = array('folder' => array(), 'page' => array());
1229 foreach (dbxContentLngSync::accessibleLngs() as $lng) {
1230 foreach (array(
1231 'folder' => array(dbxContentLng::ddFolder($lng), 'id,lng_uid,lng_sync,name', 'name'),
1232 'page' => array(dbxContentLng::ddContent($lng), 'id,lng_uid,lng_sync,title', 'title'),
1233 ) as $entity => $definition) {
1234 [$dd, $fields, $titleField] = $definition;
1235 $rows = $db->select($dd, '', $fields, 'id', 'ASC', '', 0, 0, 0);
1236 foreach (is_array($rows) ? $rows : array() as $row) {
1237 $lngUid = trim((string)($row['lng_uid'] ?? ''));
1238 if ($lngUid === '') continue;
1239 $out[$entity][$lngUid][$lng] = array(
1240 'id' => (int)($row['id'] ?? 0),
1241 'title' => (string)($row[$titleField] ?? ''),
1242 'lng_sync' => strtolower(trim((string)($row['lng_sync'] ?? 'auto'))) ?: 'auto',
1243 );
1244 }
1245 }
1246 }
1247 return $out;
1248 }
1249
1250 private function tree_lng_coverage(string $type, string $lngUid, array $coverageRows): array {
1251 $master = dbxContentLngSync::masterLng();
1252 $coverage = array(
1253 'lng_uid' => $lngUid,
1254 'entity' => $type,
1255 'master_lng' => $master,
1256 'current_lng' => dbxContentLng::current(),
1257 'languages' => array(),
1258 );
1259 foreach (dbxContentLngSync::accessibleLngs() as $lng) {
1260 $row = $coverageRows[$type][$lngUid][$lng] ?? null;
1261 $sync = is_array($row) ? (string)($row['lng_sync'] ?? 'auto') : '';
1262 $coverage['languages'][$lng] = array(
1263 'lng' => $lng,
1264 'status' => is_array($row) ? ($lng === $master ? 'master' : ($sync ?: 'auto')) : 'missing',
1265 'id' => is_array($row) ? (int)($row['id'] ?? 0) : 0,
1266 'title' => is_array($row) ? (string)($row['title'] ?? '') : '',
1267 'lng_sync' => $sync,
1268 'is_master' => $lng === $master ? 1 : 0,
1269 );
1270 }
1271 return $coverage;
1272 }
1273
1274 private function attach_lng_coverage(array $node, $db, array &$coverageRows): array {
1275 $type = ($node['_type'] ?? '') === 'folder' ? 'folder' : 'page';
1276 $id = (int)($node['_id'] ?? 0);
1277 $dd = $type === 'folder' ? dbxContentLng::ddFolder() : dbxContentLng::ddContent();
1278 $lngUid = trim((string)($node['lng_uid'] ?? $node['_lng_uid'] ?? ''));
1279
1280 if ($lngUid === '' && $id > 0) {
1281 $lngUid = dbxContentLngSync::ensureRecordUid($db, $dd, $id, $type === 'folder' ? 'f' : 'p');
1282 }
1283
1284 $node['_lng_uid'] = $lngUid;
1285 if ($lngUid !== '') {
1286 $currentLng = dbxContentLng::current();
1287 if (!isset($coverageRows[$type][$lngUid][$currentLng])) {
1288 $coverageRows[$type][$lngUid][$currentLng] = array(
1289 'id' => $id,
1290 'title' => (string)($node['_title'] ?? ''),
1291 'lng_sync' => strtolower(trim((string)($node['lng_sync'] ?? 'auto'))) ?: 'auto',
1292 );
1293 }
1294 $coverage = $this->tree_lng_coverage($type, $lngUid, $coverageRows);
1295 $node['_lng_coverage'] = $coverage;
1296 $node['_lng_badges'] = dbxContentLngSync::badgesHtml($coverage);
1297 }
1298
1299 return $node;
1300 }
1301
1302 private function decorate_tree_nodes(array $nodes, array &$flat, $db, array $folderRows, array $pageRows, array &$coverageRows) {
1303 $out = array();
1304 foreach ($nodes as $node) {
1305 if (!is_array($node)) continue;
1306 if (($node['_type'] ?? '') === 'folder' && (int)($node['_id'] ?? 0) > 0) {
1307 $full = $folderRows[(int)$node['_id']] ?? null;
1308 if (is_array($full)) {
1309 foreach (array('template','group_read','hero_template','hero_image_id','hero_margin_top','hero_height','hero_variant','hero_sticky','hero_scroll_layer') as $key) {
1310 if (array_key_exists($key, $full)) {
1311 $node[$key] = $full[$key];
1312 $node['_' . $key] = $full[$key];
1313 }
1314 }
1315 $node['_template'] = $full['template'] ?? ($node['_template'] ?? '');
1316 $node['_rights'] = $full['group_read'] ?? ($node['_rights'] ?? '');
1317 $node['lng_uid'] = $full['lng_uid'] ?? '';
1318 $node['lng_sync'] = $full['lng_sync'] ?? 'auto';
1319 }
1320 }
1321 if (($node['_type'] ?? '') === 'page' && (int)($node['_id'] ?? 0) > 0) {
1322 $full = $pageRows[(int)$node['_id']] ?? null;
1323 if (is_array($full)) {
1324 $node['lng_uid'] = $full['lng_uid'] ?? '';
1325 $node['lng_sync'] = $full['lng_sync'] ?? 'auto';
1326 }
1327 }
1328 $node = $this->attach_lng_coverage($node, $db, $coverageRows);
1329 $flat[] = $node;
1330 if (isset($node['_children']) && is_array($node['_children'])) {
1331 $node['_children'] = $this->decorate_tree_nodes($node['_children'], $flat, $db, $folderRows, $pageRows, $coverageRows);
1332 }
1333 $out[] = $node;
1334 }
1335 return $out;
1336 }
1337
1338 private function decorate_tree(array $tree) {
1339 $db = dbx()->get_system_obj('dbxDB');
1340 $folderRows = $this->tree_records_by_id(
1341 $db,
1342 dbxContentLng::ddFolder(),
1343 'id,template,group_read,hero_template,hero_image_id,hero_margin_top,hero_height,hero_variant,hero_sticky,hero_scroll_layer,lng_uid,lng_sync'
1344 );
1345 $pageRows = $this->tree_records_by_id($db, dbxContentLng::ddContent(), 'id,lng_uid,lng_sync');
1346 $coverageRows = $this->tree_lng_coverage_rows($db);
1347 $flat = array();
1348 $tree['nodes'] = $this->decorate_tree_nodes(
1349 is_array($tree['nodes'] ?? null) ? $tree['nodes'] : array(),
1350 $flat,
1351 $db,
1352 $folderRows,
1353 $pageRows,
1354 $coverageRows
1355 );
1356 $tree['flat'] = $flat;
1357 return $tree;
1358 }
1359
1360 private function media_url($row, $thumb = false) {
1361 $id = (int)($row['id'] ?? 0);
1362 if ($id <= 0) return '';
1363 $url = 'index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=' . $id;
1364 if ($thumb && !empty($row['thumb_file_path'])) $url .= '&dbx_thumb=1';
1365 $v = (int)($row['size'] ?? 0) . '-' . (int)($row['width'] ?? 0) . 'x' . (int)($row['height'] ?? 0);
1366 if ($thumb) $v .= '-' . (int)($row['thumb_width'] ?? 0) . 'x' . (int)($row['thumb_height'] ?? 0);
1367 $url .= '&_v=' . rawurlencode($v);
1368 return $url;
1369 }
1370
1371 private function external_video_thumb_url($row) {
1372 $provider = strtolower(trim((string)($row['provider'] ?? '')));
1373 $provider_id = trim((string)($row['provider_id'] ?? ''));
1374 if ($provider === 'youtube' && preg_match('~^[A-Za-z0-9_-]{11}$~', $provider_id)) {
1375 return 'https://img.youtube.com/vi/' . $provider_id . '/hqdefault.jpg';
1376 }
1377 return '';
1378 }
1379
1380 private function external_video_json_meta($file) {
1381 $raw = @file_get_contents((string)$file);
1382 if ($raw === false || $raw === '') return array();
1383 $data = json_decode($raw, true);
1384 if (!is_array($data)) return array();
1385 $provider = strtolower(trim((string)($data['provider'] ?? 'youtube')));
1386 $provider_id = trim((string)($data['provider_id'] ?? pathinfo((string)$file, PATHINFO_FILENAME)));
1387 $title = trim((string)($data['title'] ?? ('YouTube ' . $provider_id)));
1388 $external_url = trim((string)($data['external_url'] ?? ''));
1389 $embed_url = trim((string)($data['embed_url'] ?? ''));
1390 if ($embed_url === '' && preg_match('~^[A-Za-z0-9_-]{11}$~', $provider_id)) {
1391 $embed_url = 'https://www.youtube.com/embed/' . $provider_id;
1392 }
1393 return array(
1394 'provider' => $provider,
1395 'provider_id' => $provider_id,
1396 'title' => $title,
1397 'alt' => $title,
1398 'external_url' => $external_url,
1399 'embed_url' => $embed_url,
1400 );
1401 }
1402
1403 private function media_type($row) {
1404 $stored = strtolower(trim((string)($row['media_type'] ?? '')));
1405 if (in_array($stored, array('image','video','external_video','file'), true)) return $stored;
1406 $storage = strtolower(trim((string)($row['storage_type'] ?? '')));
1407 if ($storage === 'external' || trim((string)($row['provider'] ?? '')) !== '' || trim((string)($row['embed_url'] ?? '')) !== '') return 'external_video';
1408 $mime = strtolower((string)($row['mime'] ?? ''));
1409 $name = strtolower((string)($row['file_name'] ?? $row['file_path'] ?? ''));
1410 if (strpos($mime, 'video/') === 0 || preg_match('/\.(mp4|webm|ogv|ogg|mov|m4v)$/i', $name)) return 'video';
1411 if (strpos($mime, 'image/') === 0 || preg_match('/\.(jpg|jpeg|png|gif|webp|svg)$/i', $name)) return 'image';
1412 return 'file';
1413 }
1414
1415 private function media_file_exists($row) {
1416 $row = is_array($row) ? $row : array();
1417 if (strtolower((string)($row['storage_type'] ?? '')) === 'external') return trim((string)($row['embed_url'] ?? $row['external_url'] ?? '')) !== '';
1418 $rel = ltrim(str_replace('\\', '/', (string)($row['file_path'] ?? '')), '/');
1419 if ($rel === '' || strpos($rel, '..') !== false) return false;
1420
1421 $root = rtrim(dbx()->get_file_dir(), '/\\') . '/';
1422 $root = dbx()->os_path($root);
1423 $base = realpath($root);
1424 if (!$base) return false;
1425
1426 $file = $root . $rel;
1427 $file = dbx()->os_path($file);
1428 $real = realpath($file);
1429 return $real && strpos($real, $base) === 0 && is_file($real) && is_readable($real);
1430 }
1431
1432 private function media_thumb_exists($row) {
1433 $row = is_array($row) ? $row : array();
1434 if (strtolower((string)($row['storage_type'] ?? '')) === 'external' && trim((string)($row['thumb_file_path'] ?? '')) === '') return false;
1435 $rel = ltrim(str_replace('\\', '/', (string)($row['thumb_file_path'] ?? '')), '/');
1436 if ($rel === '' || strpos($rel, '..') !== false) return false;
1437
1438 $root = rtrim(dbx()->get_file_dir(), '/\\') . '/';
1439 $file = $root . $rel;
1440 $root = dbx()->os_path($root);
1441 $file = dbx()->os_path($file);
1442
1443 $base = realpath($root);
1444 $real = realpath($file);
1445 return $base && $real && strpos($real, $base) === 0 && is_file($real) && is_readable($real);
1446 }
1447
1448 private function media_thumbnail_supported($row): bool {
1449 $row = is_array($row) ? $row : array();
1450 $type = $this->media_type($row);
1451 if ($type === 'video') return true;
1452 if ($type !== 'image' || !function_exists('imagecreatetruecolor')) return false;
1453 $name = (string)($row['file_name'] ?? $row['file_path'] ?? '');
1454 $mime = strtolower(trim((string)($row['mime'] ?? '')));
1455 // SVG wird im Browser verlustfrei direkt dargestellt. Ein GD-Thumbnail
1456 // ist weder moeglich noch erforderlich und deshalb kein Wartungsfehler.
1457 return $mime !== 'image/svg+xml' && !preg_match('/\.svg$/i', $name);
1458 }
1459
1466 private function media_thumbnail_is_current($row): bool {
1467 $row = is_array($row) ? $row : array();
1468 if (!$this->media_thumb_exists($row)) return false;
1469 if ($this->media_type($row) !== 'image') return true;
1470 if (!$this->media_thumbnail_supported($row)) return true;
1471
1472 $source = $this->source_media_file($row);
1473 $thumb = $this->source_thumb_file($row);
1474 if ($source === '' || $thumb === '') return false;
1475
1476 $source_time = @filemtime($source);
1477 $thumb_time = @filemtime($thumb);
1478 if ($source_time !== false && $thumb_time !== false && $source_time > $thumb_time) return false;
1479
1480 $source_name = (string)($row['file_name'] ?? basename($source));
1481 $source_stem = strtolower((string)pathinfo($source_name, PATHINFO_FILENAME));
1482 $thumb_stem = strtolower((string)pathinfo(basename($thumb), PATHINFO_FILENAME));
1483 if ($source_stem !== '' && strpos($thumb_stem, $source_stem) === false) return false;
1484
1485 $source_size = @getimagesize($source);
1486 $thumb_size = @getimagesize($thumb);
1487 if (!is_array($source_size) || !is_array($thumb_size)) return false;
1488 $source_w = (int)($source_size[0] ?? 0);
1489 $source_h = (int)($source_size[1] ?? 0);
1490 if ($source_w <= 0 || $source_h <= 0) return false;
1491 $scale = min(640 / $source_w, 480 / $source_h, 1);
1492 $expected_w = max(1, (int)round($source_w * $scale));
1493 $expected_h = max(1, (int)round($source_h * $scale));
1494 return abs((int)($thumb_size[0] ?? 0) - $expected_w) <= 1
1495 && abs((int)($thumb_size[1] ?? 0) - $expected_h) <= 1;
1496 }
1497
1498 private function valid_media_slot($slot, $default = 'gallery') {
1499 $slot = strtolower(trim((string)$slot));
1500 $allowed = array('hero','gallery','inline','header','teaser','footer','shop');
1501 return in_array($slot, $allowed, true) ? $slot : $default;
1502 }
1503
1504 private function media_rel_dir($slot) {
1505 return 'media/' . $this->valid_media_slot($slot) . '/';
1506 }
1507
1508 private function video_media_base_folder() {
1509 $root = rtrim(dbx()->get_file_dir(), '/\\') . '/media/';
1510 $root = dbx()->os_path($root);
1511 if (is_dir($root . 'videos')) return 'videos';
1512 if (is_dir($root . 'video')) return 'video';
1513 return 'videos';
1514 }
1515
1516 private function media_standard_bases() {
1517 return array('img' => 'image', $this->video_media_base_folder() => 'video', 'youtube' => 'external_video', 'file' => 'file');
1518 }
1519
1520 private function canonical_media_folder($folder) {
1521 $folder = strtolower(trim(str_replace('\\', '/', (string)$folder), '/'));
1522 if (preg_match('~^youtube/[a-z0-9_-]{11}$~', $folder)) return 'youtube';
1523 return $folder;
1524 }
1525
1526 private function clean_media_folder($folder, $media_type = 'image') {
1527 $media_type = $this->media_type(array('media_type' => $media_type));
1528 $folder = $this->canonical_media_folder($folder);
1529 $folder = strtolower(str_replace('\\', '/', trim((string)$folder)));
1530 $folder = preg_replace('~/+~', '/', $folder);
1531 $folder = trim($folder, '/');
1532 $parts = array();
1533 foreach (explode('/', $folder) as $part) {
1534 $part = preg_replace('/[^A-Za-z0-9_-]+/', '-', $part);
1535 $part = trim($part, '-_');
1536 if ($part !== '' && $part !== '.' && $part !== '..') $parts[] = $part;
1537 }
1538 $folder = implode('/', $parts);
1539
1540 if ($folder === 'module') {
1541 return 'module';
1542 }
1543
1544 if ($media_type === 'video') {
1545 $base = $this->video_media_base_folder();
1546 if ($folder === '' || $folder === 'video' || $folder === 'videos' || $folder === 'img' || $folder === 'img/video') return $base;
1547 if (substr($folder, 0, 10) === 'img/video/') return $base . '/' . substr($folder, 10);
1548 if (substr($folder, 0, 6) === 'video/') return $base . '/' . substr($folder, 6);
1549 if (substr($folder, 0, 7) === 'videos/') return $base . '/' . substr($folder, 7);
1550 return $base;
1551 }
1552 if ($media_type === 'external_video') {
1553 if ($folder === '' || $folder === 'youtube') return 'youtube';
1554 if (substr($folder, 0, 8) !== 'youtube/') return 'youtube';
1555 return $folder;
1556 }
1557 if ($media_type === 'file') {
1558 if ($folder === '' || $folder === 'file') return 'file/allgemein';
1559 if (substr($folder, 0, 5) !== 'file/') return 'file/allgemein';
1560 return $folder;
1561 }
1562 if ($folder === '' || $folder === 'img' || $folder === 'image') return 'img/allgemein';
1563 if (substr($folder, 0, 4) !== 'img/') return 'img/allgemein';
1564 return $folder;
1565 }
1566
1567 private function media_type_from_folder($folder, $fallback = 'image') {
1568 $folder = $this->canonical_media_folder($folder);
1569 if (strpos($folder, 'img/video') === 0) return 'video';
1570 if (strpos($folder, 'videos/') === 0 || $folder === 'videos') return 'video';
1571 if (strpos($folder, 'video/') === 0 || $folder === 'video') return 'video';
1572 if (strpos($folder, 'youtube/') === 0 || $folder === 'youtube') return 'external_video';
1573 if (strpos($folder, 'file/') === 0 || $folder === 'file') return 'file';
1574 return $fallback;
1575 }
1576
1577 private function media_folder_from_path($rel, $media_type = 'image') {
1578 $rel = ltrim(str_replace('\\', '/', (string)$rel), '/');
1579 if (preg_match('~^media/module/[^/]+$~i', $rel)) {
1580 return 'module';
1581 }
1582 if (preg_match('~^media/youtube/[^/]+\.json$~i', $rel)) {
1583 return 'youtube';
1584 }
1585 if (preg_match('~^media/(img|video|youtube|external|file)/([^/]+)/[^/]+$~i', $rel, $m)) {
1586 return $this->clean_media_folder($m[1] . '/' . $m[2], $media_type);
1587 }
1588 if (preg_match('~^media/(img|video|file)/([^/]+)/?$~i', $rel, $m)) {
1589 return $this->clean_media_folder($m[1] . '/' . $m[2], $media_type);
1590 }
1591 return $this->clean_media_folder('', $media_type);
1592 }
1593
1594 private function media_folder_rel_dir($folder, $media_type = 'image') {
1595 return 'media/' . $this->clean_media_folder($folder, $media_type) . '/';
1596 }
1597
1598 private function media_thumb_rel_dir($slot) {
1599 $slot_text = (string)$slot;
1600 $folder_type = 'image';
1601 if (preg_match('~^(youtube|external)(/|$)~', $slot_text)) $folder_type = 'external_video';
1602 elseif (preg_match('~^(videos|video|img/video)(/|$)~', $slot_text)) $folder_type = 'video';
1603 elseif (preg_match('~^file(/|$)~', $slot_text)) $folder_type = 'file';
1604 $folder = $this->clean_media_folder($slot, $folder_type);
1605 return 'media/_thumbs/' . $folder . '/';
1606 }
1607
1608 private function cms_media_dir($slot = '') {
1609 $slot_text = (string)$slot;
1610 $folder_type = 'image';
1611 if (preg_match('~^(youtube|external)(/|$)~', $slot_text)) $folder_type = 'external_video';
1612 elseif (preg_match('~^(videos|video|img/video)(/|$)~', $slot_text)) $folder_type = 'video';
1613 elseif (preg_match('~^file(/|$)~', $slot_text)) $folder_type = 'file';
1614 $rel = $slot === '' ? 'media/' : $this->media_folder_rel_dir($slot, $folder_type);
1615 $dir = rtrim(dbx()->get_file_dir(), '/\\') . '/' . $rel;
1616 return dbx()->os_path($dir);
1617 }
1618
1619 private function cms_media_base_dir($base) {
1620 $base = strtolower(trim(str_replace('\\', '/', (string)$base), '/'));
1621 if (!in_array($base, array('img', 'video', 'videos', 'youtube', 'external', 'file', 'module'), true)) return '';
1622 $rel = 'media/' . $base . '/';
1623 $dir = rtrim(dbx()->get_file_dir(), '/\\') . '/' . $rel;
1624 return dbx()->os_path($dir);
1625 }
1626
1627 private function cms_media_thumb_dir($slot) {
1628 $dir = rtrim(dbx()->get_file_dir(), '/\\') . '/' . $this->media_thumb_rel_dir($slot);
1629 return dbx()->os_path($dir);
1630 }
1631
1632 private function unique_media_name($name, $prefix = '') {
1633 $name = basename((string)$name);
1634 $name = preg_replace('/[^A-Za-z0-9_.-]+/', '-', $name);
1635 $prefix = $prefix !== '' ? rtrim($prefix, '-') . '-' : '';
1636 return date('YmdHis') . '-' . substr(md5($name . microtime(true)), 0, 8) . '-' . $prefix . $name;
1637 }
1638
1639 private function media_slots() {
1640 return array('hero','gallery','inline','header','teaser','footer');
1641 }
1642
1643 private function media_allowed_extensions() {
1644 return array('jpg','jpeg','png','gif','webp','svg','mp4','webm','ogv','ogg','mov','m4v');
1645 }
1646
1647 private function file_from_media_rel($rel) {
1648 $rel = ltrim(str_replace('\\', '/', (string)$rel), '/');
1649 if ($rel === '' || strpos($rel, '..') !== false) return '';
1650 $file = rtrim(dbx()->get_file_dir(), '/\\') . '/' . $rel;
1651 return dbx()->os_path($file);
1652 }
1653
1654 private function relative_file_exists($rel) {
1655 $file = $this->file_from_media_rel($rel);
1656 return $file !== '' && is_file($file) && is_readable($file);
1657 }
1658
1659 private function detect_media_mime($file, $ext) {
1660 $mime = function_exists('mime_content_type') ? (string)@mime_content_type($file) : '';
1661 if ($mime !== '') return $mime;
1662 $ext = strtolower((string)$ext);
1663 if (in_array($ext, array('mp4','webm','ogv','ogg','mov','m4v'), true)) {
1664 return $ext === 'webm' ? 'video/webm' : ($ext === 'mov' ? 'video/quicktime' : 'video/mp4');
1665 }
1666 return $ext === 'svg' ? 'image/svg+xml' : 'image/' . ($ext === 'jpg' ? 'jpeg' : $ext);
1667 }
1668
1669 private function media_file_meta($file, $name) {
1670 $ext = strtolower(pathinfo((string)$name, PATHINFO_EXTENSION));
1671 $mime = $this->detect_media_mime($file, $ext);
1672 $width = 0;
1673 $height = 0;
1674 $img = @getimagesize($file);
1675 if (is_array($img)) {
1676 $width = (int)($img[0] ?? 0);
1677 $height = (int)($img[1] ?? 0);
1678 }
1679 return array(
1680 'mime' => $mime,
1681 'size' => (int)@filesize($file),
1682 'width' => $width,
1683 'height' => $height,
1684 'media_type' => $this->media_type(array('mime' => $mime, 'file_name' => $name)),
1685 );
1686 }
1687
1688 private function is_excluded_cms_media_folder($folder) {
1689 $folder = $this->canonical_media_folder(strtolower(trim(str_replace('\\', '/', (string)$folder), '/')));
1690 return $folder === 'module' || strpos($folder, 'module/') === 0;
1691 }
1692
1693 private function cms_media_exclude_sql() {
1694 return " AND media_folder <> 'module' AND file_path NOT LIKE 'media/module/%'";
1695 }
1696
1697 private function collect_media_files() {
1698 $files = array();
1699 $allowed = $this->media_allowed_extensions();
1700
1701 foreach (array('img','video','youtube','file') as $base_folder) {
1702 $dir = $this->cms_media_base_dir($base_folder);
1703 if (!is_dir($dir) || !is_readable($dir)) continue;
1704 $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS));
1705 foreach ($it as $file) {
1706 if (!$file->isFile() || !$file->isReadable()) continue;
1707 $name = $file->getFilename();
1708 $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
1709 $is_external_base = $base_folder === 'youtube';
1710 if (!in_array($ext, $allowed, true) && !($is_external_base && $ext === 'json')) continue;
1711 $path = str_replace('\\', '/', $file->getPathname());
1712 $root = str_replace('\\', '/', rtrim(dbx()->get_file_dir(), '/\\')) . '/';
1713 if (strpos($path, $root) !== 0) continue;
1714 $rel = substr($path, strlen($root));
1715 $type = $base_folder === 'video' ? 'video' : ($is_external_base ? 'external_video' : ($base_folder === 'file' ? 'file' : 'image'));
1716 if ($ext === 'json' && $is_external_base) $type = 'external_video';
1717 $files[$rel] = array(
1718 'rel' => $rel,
1719 'slot' => 'gallery',
1720 'folder' => $this->media_folder_from_path($rel, $type),
1721 'file' => $file->getPathname(),
1722 'name' => $name,
1723 );
1724 }
1725 }
1726 return $files;
1727 }
1728
1729 private function collect_media_thumb_files() {
1730 $files = array();
1731 $root = dbx()->os_path(rtrim(dbx()->get_file_dir(), '/\\') . '/media/_thumbs/');
1732 if (!is_dir($root) || !is_readable($root)) return $files;
1733 $rootNormalized = str_replace('\\', '/', rtrim($root, '/\\')) . '/';
1734 $it = new \RecursiveIteratorIterator(
1735 new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
1736 \RecursiveIteratorIterator::LEAVES_ONLY
1737 );
1738 foreach ($it as $file) {
1739 if (!$file->isFile() || !$file->isReadable()) continue;
1740 $path = str_replace('\\', '/', $file->getPathname());
1741 if (strpos($path, $rootNormalized) !== 0) continue;
1742 $rel = 'media/_thumbs/' . ltrim(substr($path, strlen($rootNormalized)), '/');
1743 $files[$rel] = array('rel' => $rel, 'file' => $file->getPathname());
1744 }
1745 return $files;
1746 }
1747
1748 private function active_media_usage_map($db) {
1749 $map = array();
1750 if (!is_object($db)) return $map;
1751 $rows = $db->select($this->dd_media_usage, 'active = 1', 'media_id,content_id,folder_id,content_lng,slot', 'id', 'ASC', '', 0, 0, 0);
1752 if (is_array($rows)) {
1753 foreach ($rows as $row) {
1754 if (!is_array($row)) continue;
1755 $media_id = (int)($row['media_id'] ?? 0);
1756 if ($media_id <= 0) continue;
1757 if (!isset($map[$media_id])) $map[$media_id] = array();
1758 $map[$media_id][] = $row;
1759 }
1760 }
1761
1762 foreach ($this->ordered_media_languages() as $lng) {
1763 $content_rows = $db->select(dbxContentLng::ddContent($lng), '', 'id,folder,hero_image_id,seo_image_id,content', 'id');
1764 foreach (is_array($content_rows) ? $content_rows : array() as $row) {
1765 if (!is_array($row)) continue;
1766 $content_id = (int)($row['id'] ?? 0);
1767 $folder_id = (int)($row['folder'] ?? 0);
1768 $direct = array(
1769 'hero' => (int)($row['hero_image_id'] ?? 0),
1770 'seo' => (int)($row['seo_image_id'] ?? 0),
1771 );
1772 foreach ($direct as $slot => $media_id) {
1773 if ($media_id <= 0) continue;
1774 if (!isset($map[$media_id])) $map[$media_id] = array();
1775 $map[$media_id][] = array('media_id' => $media_id, 'content_id' => $content_id, 'folder_id' => $folder_id, 'content_lng' => $lng, 'slot' => $slot);
1776 }
1777 foreach ($this->inline_media_ids($row['content'] ?? '') as $media_id) {
1778 $media_id = (int)$media_id;
1779 if ($media_id <= 0) continue;
1780 if (!isset($map[$media_id])) $map[$media_id] = array();
1781 $map[$media_id][] = array('media_id' => $media_id, 'content_id' => $content_id, 'folder_id' => $folder_id, 'content_lng' => $lng, 'slot' => 'inline');
1782 }
1783 }
1784 $folder_rows = $db->select(dbxContentLng::ddFolder($lng), '', 'id,hero_image_id', 'id');
1785 foreach (is_array($folder_rows) ? $folder_rows : array() as $row) {
1786 $media_id = is_array($row) ? (int)($row['hero_image_id'] ?? 0) : 0;
1787 $folder_id = is_array($row) ? (int)($row['id'] ?? 0) : 0;
1788 if ($media_id <= 0 || $folder_id <= 0) continue;
1789 if (!isset($map[$media_id])) $map[$media_id] = array();
1790 $map[$media_id][] = array('media_id' => $media_id, 'content_id' => 0, 'folder_id' => $folder_id, 'content_lng' => $lng, 'slot' => 'hero');
1791 }
1792 }
1793 return $map;
1794 }
1795
1803 private function orphan_media_usage_rows($db): array {
1804 if (!is_object($db)) return array();
1805
1806 $media_ids = array();
1807 $media_rows = $db->select($this->dd_media, '', 'id', 'id', 'ASC', '', 0, 0, 0);
1808 if (is_array($media_rows)) {
1809 foreach ($media_rows as $row) {
1810 $id = is_array($row) ? (int)($row['id'] ?? 0) : 0;
1811 if ($id > 0) $media_ids[$id] = 1;
1812 }
1813 }
1814
1815 $content_ids = array();
1816 $folder_ids = array();
1817 foreach (dbxContentLngSync::accessibleLngs() as $lng) {
1818 try {
1819 $content_rows = $db->select(dbxContentLng::ddContent((string)$lng), '', 'id', 'id', 'ASC', '', 0, 0, 0);
1820 if (is_array($content_rows)) {
1821 foreach ($content_rows as $row) {
1822 $id = is_array($row) ? (int)($row['id'] ?? 0) : 0;
1823 if ($id > 0) $content_ids[$lng . ':' . $id] = 1;
1824 }
1825 }
1826
1827 $folder_rows = $db->select(dbxContentLng::ddFolder((string)$lng), '', 'id', 'id', 'ASC', '', 0, 0, 0);
1828 if (is_array($folder_rows)) {
1829 foreach ($folder_rows as $row) {
1830 $id = is_array($row) ? (int)($row['id'] ?? 0) : 0;
1831 if ($id > 0) $folder_ids[$lng . ':' . $id] = 1;
1832 }
1833 }
1834 } catch (\Throwable $e) {
1835 dbx()->debug('dbxContent media usage check skipped lng=' . (string)$lng, $e->getMessage());
1836 }
1837 }
1838
1839 $orphans = array();
1840 $usage_rows = $db->select($this->dd_media_usage, '', '*', 'id', 'ASC', '', 0, 0, 0);
1841 if (!is_array($usage_rows)) return $orphans;
1842
1843 foreach ($usage_rows as $row) {
1844 if (!is_array($row)) continue;
1845 $id = (int)($row['id'] ?? 0);
1846 if ($id <= 0) continue;
1847
1848 $media_id = (int)($row['media_id'] ?? 0);
1849 $content_id = (int)($row['content_id'] ?? 0);
1850 $folder_id = (int)($row['folder_id'] ?? 0);
1851 $content_lng = dbxContentMediaUsageScope::language((string)($row['content_lng'] ?? ''));
1852 $reason = '';
1853
1854 if ($media_id <= 0 || !isset($media_ids[$media_id])) {
1855 $reason = 'media_missing';
1856 } elseif ($content_id > 0) {
1857 if (!isset($content_ids[$content_lng . ':' . $content_id])) $reason = 'content_missing';
1858 } elseif ($folder_id > 0) {
1859 if (!isset($folder_ids[$content_lng . ':' . $folder_id])) $reason = 'folder_missing';
1860 } else {
1861 $reason = 'target_missing';
1862 }
1863
1864 if ($reason !== '') {
1865 $orphans[$id] = array('row' => $row, 'reason' => $reason);
1866 }
1867 }
1868
1869 return $orphans;
1870 }
1871
1872 private function cleanup_orphan_media_usage($db): array {
1873 $orphans = $this->orphan_media_usage_rows($db);
1874 $removed = 0;
1875 $reasons = array();
1876 $cache_targets = array();
1877
1878 foreach ($orphans as $id => $item) {
1879 $row = is_array($item['row'] ?? null) ? $item['row'] : array();
1880 $reason = (string)($item['reason'] ?? 'unknown');
1881 $content_id = (int)($row['content_id'] ?? 0);
1882 $folder_id = (int)($row['folder_id'] ?? 0);
1883 $cache_targets[$content_id . ':' . $folder_id] = array($content_id, $folder_id);
1884
1885 // Wartungsbereinigung ohne Trash-/Trace-Kopie: Der Datensatz ist
1886 // bereits ungueltig und soll durch VACUUM wirklich freigegeben werden.
1887 if ((int)$db->delete($this->dd_media_usage, (int)$id, 1, 0) === 1) {
1888 $removed++;
1889 $reasons[$reason] = (int)($reasons[$reason] ?? 0) + 1;
1890 }
1891 }
1892
1893 foreach ($cache_targets as $target) {
1894 $this->flush_media_cache($db, (int)$target[0], (int)$target[1]);
1895 }
1896
1897 return array(
1898 'found' => count($orphans),
1899 'removed' => $removed,
1900 'reasons' => $reasons,
1901 );
1902 }
1903
1904 private function ordered_media_languages(): array {
1905 $languages = array_values(array_unique(array_map('strval', dbxContentLngSync::accessibleLngs())));
1906 $current = strtolower(trim((string)dbx()->get_system_var('dbx_lng', dbxContentLngSync::masterLng())));
1907 $ordered = array();
1908 if ($current !== '' && in_array($current, $languages, true)) $ordered[] = $current;
1909 foreach ($languages as $language) {
1910 $language = strtolower(trim($language));
1911 if ($language !== '' && !in_array($language, $ordered, true)) $ordered[] = $language;
1912 }
1913 return $ordered;
1914 }
1915
1916 private function add_expected_media_usage(array &$expected, int $media_id, int $content_id, int $folder_id, string $slot, string $template = '', string $content_lng = '', string $caption = '', string $settings = ''): void {
1917 if ($media_id <= 0 || ($content_id <= 0 && $folder_id <= 0)) return;
1918 $content_lng = dbxContentMediaUsageScope::language($content_lng);
1919 $key = dbxContentMediaUsageMaintenance::usageKey($media_id, $content_id, $folder_id, $slot, $content_lng);
1920 if (!isset($expected[$key])) {
1921 $expected[$key] = array(
1922 'media_id' => $media_id,
1923 'content_id' => $content_id,
1924 'folder_id' => $folder_id,
1925 'slot' => $slot,
1926 'content_lng' => $content_lng,
1927 'template' => $template,
1928 'caption' => $caption,
1929 'settings' => $settings,
1930 'valid_folders' => array(),
1931 );
1932 }
1933 if ($content_id > 0 && $folder_id > 0) {
1934 $expected[$key]['valid_folders'][$folder_id] = 1;
1935 }
1936 }
1937
1943 private function media_usage_snapshot($db): array {
1944 $media_rows = $db->select($this->dd_media, '', '*', 'id', 'ASC', '', 0, 0, 0);
1945 if (!is_array($media_rows)) $media_rows = array();
1946 $valid_media_ids = array();
1947 foreach ($media_rows as $row) {
1948 if (!is_array($row)) continue;
1949 $id = (int)($row['id'] ?? 0);
1950 if ($id > 0 && (int)($row['active'] ?? 0) === 1 && $this->media_file_exists($row)) {
1951 $valid_media_ids[$id] = 1;
1952 }
1953 }
1954
1955 $expected = array();
1956 $content_folders = array();
1957 $folder_ids = array();
1958 $seo_media_ids = array();
1959 $seo_references = 0;
1960 foreach ($this->ordered_media_languages() as $lng) {
1961 try {
1962 $pages = $db->select(
1963 dbxContentLng::ddContent($lng),
1964 '',
1965 'id,folder,hero_image_id,seo_image_id,content',
1966 'id',
1967 'ASC',
1968 '',
1969 0,
1970 0,
1971 0
1972 );
1973 foreach (is_array($pages) ? $pages : array() as $page) {
1974 if (!is_array($page)) continue;
1975 $content_id = (int)($page['id'] ?? 0);
1976 $folder_id = (int)($page['folder'] ?? 0);
1977 if ($content_id <= 0) continue;
1978 $content_key = $lng . ':' . $content_id;
1979 if (!isset($content_folders[$content_key])) $content_folders[$content_key] = array();
1980 if ($folder_id > 0) $content_folders[$content_key][$folder_id] = 1;
1981
1982 $hero_id = (int)($page['hero_image_id'] ?? 0);
1983 if (isset($valid_media_ids[$hero_id])) {
1984 $this->add_expected_media_usage($expected, $hero_id, $content_id, $folder_id, 'hero', 'image-hero', $lng);
1985 }
1986 $seo_id = (int)($page['seo_image_id'] ?? 0);
1987 if (isset($valid_media_ids[$seo_id])) {
1988 $seo_media_ids[$seo_id] = 1;
1989 $seo_references++;
1990 }
1991 foreach ($this->inline_media_ids((string)($page['content'] ?? '')) as $media_id) {
1992 $media_id = (int)$media_id;
1993 if (isset($valid_media_ids[$media_id])) {
1994 $this->add_expected_media_usage($expected, $media_id, $content_id, $folder_id, 'inline', 'image-inline', $lng);
1995 }
1996 }
1997 }
1998
1999 $folders = $db->select(
2000 dbxContentLng::ddFolder($lng),
2001 '',
2002 'id,hero_image_id',
2003 'id',
2004 'ASC',
2005 '',
2006 0,
2007 0,
2008 0
2009 );
2010 foreach (is_array($folders) ? $folders : array() as $folder) {
2011 if (!is_array($folder)) continue;
2012 $folder_id = (int)($folder['id'] ?? 0);
2013 if ($folder_id <= 0) continue;
2014 $folder_ids[$lng . ':' . $folder_id] = 1;
2015 $hero_id = (int)($folder['hero_image_id'] ?? 0);
2016 if (isset($valid_media_ids[$hero_id])) {
2017 $this->add_expected_media_usage($expected, $hero_id, 0, $folder_id, 'hero', 'image-hero', $lng);
2018 }
2019 }
2020 } catch (\Throwable $e) {
2021 dbx()->debug('dbxContent media usage snapshot skipped lng=' . $lng, $e->getMessage());
2022 }
2023 }
2024
2025 $controlled_slots = array('hero', 'inline');
2026 try {
2027 $master_lng = dbxContentMediaUsageScope::language(dbxContentLngSync::masterLng());
2028 $shop_page = $db->select1(
2029 dbxContentLng::ddContent($master_lng),
2030 array('permalink' => 'shop-medienverwendung'),
2031 'id,folder',
2032 0
2033 );
2034 if (!is_array($shop_page)) {
2035 $shop_page = $db->select1(
2036 dbxContentLng::ddContent($master_lng),
2037 array('permalink' => 'outside/shop-media-usage'),
2038 'id,folder',
2039 0
2040 );
2041 }
2042 $shop_content_id = is_array($shop_page) ? (int)($shop_page['id'] ?? 0) : 0;
2043 $shop_folder_id = is_array($shop_page) ? (int)($shop_page['folder'] ?? 0) : 0;
2044 $shop_rows = $db->select(
2045 'dbxShop|shopProductImage',
2046 'active = 1 AND trash = 0 AND media_id > 0',
2047 'media_id,title,product_id,group_id',
2048 'media_id,id',
2049 'ASC',
2050 '',
2051 0,
2052 0,
2053 0
2054 );
2055 if ($shop_content_id > 0 && is_array($shop_rows)) {
2056 $content_key = $master_lng . ':' . $shop_content_id;
2057 if (!isset($content_folders[$content_key])) $content_folders[$content_key] = array();
2058 if ($shop_folder_id > 0) $content_folders[$content_key][$shop_folder_id] = 1;
2059 $shop_by_media = array();
2060 foreach ($shop_rows as $shop_row) {
2061 if (!is_array($shop_row)) continue;
2062 $media_id = (int)($shop_row['media_id'] ?? 0);
2063 if (!isset($valid_media_ids[$media_id])) continue;
2064 if (!isset($shop_by_media[$media_id])) {
2065 $shop_by_media[$media_id] = array(
2066 'title' => (string)($shop_row['title'] ?? ''),
2067 'product_ids' => array(),
2068 'group_ids' => array(),
2069 );
2070 }
2071 $product_id = (int)($shop_row['product_id'] ?? 0);
2072 $group_id = (int)($shop_row['group_id'] ?? 0);
2073 if ($product_id > 0) $shop_by_media[$media_id]['product_ids'][$product_id] = $product_id;
2074 if ($group_id > 0) $shop_by_media[$media_id]['group_ids'][$group_id] = $group_id;
2075 }
2076 foreach ($shop_by_media as $media_id => $shop_info) {
2077 $settings = json_encode(array(
2078 'source' => 'dbxShop',
2079 'product_ids' => array_values($shop_info['product_ids']),
2080 'group_ids' => array_values($shop_info['group_ids']),
2081 ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
2082 $this->add_expected_media_usage(
2083 $expected,
2084 (int)$media_id,
2085 $shop_content_id,
2086 $shop_folder_id,
2087 'shop',
2088 'image-gallery',
2089 $master_lng,
2090 (string)$shop_info['title'],
2091 $settings ?: '{"source":"dbxShop"}'
2092 );
2093 }
2094 $controlled_slots[] = 'shop';
2095 }
2096 } catch (\Throwable $e) {
2097 dbx()->debug('dbxContent shop media usage snapshot skipped', $e->getMessage());
2098 }
2099
2100 return array(
2101 'media_rows' => $media_rows,
2102 'valid_media_ids' => $valid_media_ids,
2103 'expected' => $expected,
2104 'content_folders' => $content_folders,
2105 'folder_ids' => $folder_ids,
2106 'seo_media_ids' => $seo_media_ids,
2107 'seo_references' => $seo_references,
2108 'controlled_slots' => $controlled_slots,
2109 );
2110 }
2111
2112 private function physical_delete_ids($db, string $dd, array $ids): int {
2113 $ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
2114 $removed = 0;
2115 foreach (array_chunk($ids, 250) as $chunk) {
2116 $where = 'id IN (' . implode(',', $chunk) . ')';
2117 $before = (int)$db->count($dd, $where);
2118 if ($before <= 0) continue;
2119 $db->delete($dd, $where, 1, 0);
2120 $after = (int)$db->count($dd, $where);
2121 $removed += max(0, $before - $after);
2122 }
2123 return $removed;
2124 }
2125
2126 private function reconcile_media_usage($db): array {
2127 $snapshot = $this->media_usage_snapshot($db);
2128 $usage_rows = $db->select($this->dd_media_usage, '', '*', 'id', 'ASC', '', 0, 0, 0);
2129 if (!is_array($usage_rows)) $usage_rows = array();
2130 $plan = dbxContentMediaUsageMaintenance::plan(
2131 $usage_rows,
2132 $snapshot['valid_media_ids'],
2133 $snapshot['expected'],
2134 $snapshot['content_folders'],
2135 $snapshot['folder_ids'],
2136 array('hero', 'gallery', 'inline', 'header', 'teaser', 'footer', 'shop'),
2137 $snapshot['controlled_slots']
2138 );
2139
2140 // SQLite ist bei vielen einzelnen Schreibvorgaengen ohne Transaktion
2141 // unverhaeltnismaessig langsam. Die komplette Korrektur ist eine
2142 // atomare Wartungsoperation und wird deshalb gemeinsam committed.
2143 // Gleichzeitig dient die vorhandene Nutzung als Sorter-Seed, damit
2144 // neue Soll-Zuordnungen keine SELECT-Abfrage pro Datensatz benoetigen.
2145 $sorter_max = array();
2146 foreach ($usage_rows as $row) {
2147 if (!is_array($row) || (int)($row['active'] ?? 0) !== 1) continue;
2148 $sorter_key = (int)($row['content_id'] ?? 0) . ':'
2149 . (int)($row['folder_id'] ?? 0) . ':'
2150 . dbxContentMediaUsageScope::language((string)($row['content_lng'] ?? '')) . ':'
2151 . $this->valid_media_slot($row['slot'] ?? 'gallery');
2152 $sorter_max[$sorter_key] = max(
2153 (int)($sorter_max[$sorter_key] ?? 0),
2154 (int)($row['sorter'] ?? 0)
2155 );
2156 }
2157
2158 $cache_targets = array();
2159 foreach ($usage_rows as $row) {
2160 $id = is_array($row) ? (int)($row['id'] ?? 0) : 0;
2161 if ($id <= 0 || !isset($plan['delete'][$id])) continue;
2162 $content_id = (int)($row['content_id'] ?? 0);
2163 $folder_id = (int)($row['folder_id'] ?? 0);
2164 $cache_targets[$content_id . ':' . $folder_id] = array($content_id, $folder_id);
2165 }
2166 $removed = 0;
2167 $updated = 0;
2168 $added = 0;
2169 $transaction_started = (int)$db->begin($this->dd_media_usage) === 1;
2170 try {
2171 $removed = $this->physical_delete_ids($db, $this->dd_media_usage, array_keys($plan['delete']));
2172
2173 foreach ($plan['update'] as $id => $data) {
2174 if ((int)$db->update($this->dd_media_usage, $data, (int)$id, 0, 1, 1, 0) >= 0) $updated++;
2175 }
2176
2177 foreach ($plan['insert'] as $reference) {
2178 $content_id = (int)($reference['content_id'] ?? 0);
2179 $folder_id = (int)($reference['folder_id'] ?? 0);
2180 $slot = $this->valid_media_slot($reference['slot'] ?? 'gallery');
2181 $content_lng = dbxContentMediaUsageScope::language((string)($reference['content_lng'] ?? ''));
2182 $sorter_key = $content_id . ':' . $folder_id . ':' . $content_lng . ':' . $slot;
2183 $sorter_max[$sorter_key] = (int)($sorter_max[$sorter_key] ?? 0) + 10;
2184 $insert = array(
2185 'active' => 1,
2186 'media_id' => (int)($reference['media_id'] ?? 0),
2187 'content_id' => $content_id,
2188 'folder_id' => $folder_id,
2189 'content_lng' => $content_lng,
2190 'slot' => $slot,
2191 'sorter' => sprintf('%04d', $sorter_max[$sorter_key]),
2192 'template' => (string)($reference['template'] ?? ''),
2193 'caption' => (string)($reference['caption'] ?? ''),
2194 'settings' => (string)($reference['settings'] ?? ''),
2195 );
2196 if ($db->insert($this->dd_media_usage, $insert, 0, 1, 1, 0) === 1) {
2197 $added++;
2198 $cache_targets[$content_id . ':' . $folder_id] = array($content_id, $folder_id);
2199 }
2200 }
2201
2202 if ($transaction_started && (int)$db->commit($this->dd_media_usage) !== 1) {
2203 throw new \RuntimeException('media_usage_commit_failed');
2204 }
2205 } catch (\Throwable $e) {
2206 if ($transaction_started) $db->rollback($this->dd_media_usage);
2207 throw $e;
2208 }
2209
2210 foreach ($cache_targets as $target) {
2211 $this->flush_media_cache($db, (int)$target[0], (int)$target[1]);
2212 }
2213
2214 return array(
2215 'analyzed' => (int)$plan['analyzed'],
2216 'actual_references' => (int)$plan['kept'] + $added + (int)($snapshot['seo_references'] ?? 0),
2217 'kept' => (int)$plan['kept'],
2218 'added' => $added,
2219 'updated' => $updated,
2220 'removed' => $removed,
2221 'planned_removed' => count($plan['delete']),
2222 'reasons' => $plan['reasons'],
2223 );
2224 }
2225
2226 private function cleanup_invalid_structured_media_references($db): array {
2227 $snapshot = $this->media_usage_snapshot($db);
2228 $valid = $snapshot['valid_media_ids'];
2229 $refs = 0;
2230 $pages = 0;
2231 $folders_count = 0;
2232 $previous_lng = (string)dbx()->get_system_var('dbx_lng', dbxContentLngSync::masterLng());
2233
2234 try {
2235 foreach ($this->ordered_media_languages() as $lng) {
2236 dbx()->set_system_var('dbx_lng', $lng);
2237 $content_dd = dbxContentLng::ddContent($lng);
2238 $page_rows = $db->select($content_dd, '', 'id,hero_image_id,seo_image_id', 'id', 'ASC', '', 0, 0, 0);
2239 foreach (is_array($page_rows) ? $page_rows : array() as $row) {
2240 if (!is_array($row)) continue;
2241 $id = (int)($row['id'] ?? 0);
2242 if ($id <= 0) continue;
2243 $update = array();
2244 $hero_id = (int)($row['hero_image_id'] ?? 0);
2245 $seo_id = (int)($row['seo_image_id'] ?? 0);
2246 if ($hero_id > 0 && !isset($valid[$hero_id])) $update['hero_image_id'] = '0';
2247 if ($seo_id > 0 && !isset($valid[$seo_id])) $update['seo_image_id'] = 0;
2248 if (!$update) continue;
2249 if ((int)$db->update($content_dd, $update, $id, 0, 1, 1, 0) >= 0) {
2250 $refs += count($update);
2251 $pages++;
2252 $this->flush_saved_page_cache($db, $id);
2253 }
2254 }
2255
2256 $folder_dd = dbxContentLng::ddFolder($lng);
2257 $folder_rows = $db->select($folder_dd, '', 'id,hero_image_id', 'id', 'ASC', '', 0, 0, 0);
2258 foreach (is_array($folder_rows) ? $folder_rows : array() as $row) {
2259 if (!is_array($row)) continue;
2260 $id = (int)($row['id'] ?? 0);
2261 $hero_id = (int)($row['hero_image_id'] ?? 0);
2262 if ($id <= 0 || $hero_id <= 0 || isset($valid[$hero_id])) continue;
2263 if ((int)$db->update($folder_dd, array('hero_image_id' => 'parent'), $id, 0, 1, 1, 0) >= 0) {
2264 $refs++;
2265 $folders_count++;
2266 }
2267 }
2268 }
2269 } finally {
2270 dbx()->set_system_var('dbx_lng', $previous_lng);
2271 }
2272
2273 return array('refs' => $refs, 'pages' => $pages, 'folders' => $folders_count);
2274 }
2275
2276 private function purge_invalid_media_records($db): array {
2277 $rows = $db->select($this->dd_media, 'active <> 1', '*', 'id', 'ASC', '', 0, 0, 0);
2278 if (!is_array($rows)) $rows = array();
2279 $ids = array();
2280 $deleted_thumbs = 0;
2281 foreach ($rows as $row) {
2282 if (!is_array($row)) continue;
2283 $id = (int)($row['id'] ?? 0);
2284 $path = ltrim(str_replace('\\', '/', (string)($row['file_path'] ?? '')), '/');
2285 if ($id <= 0 || $this->is_excluded_cms_media_folder((string)($row['media_folder'] ?? '')) || strpos($path, 'media/module/') === 0) continue;
2286 $ids[] = $id;
2287 $thumb = $this->source_thumb_file($row);
2288 if ($thumb !== '' && is_file($thumb) && @unlink($thumb)) $deleted_thumbs++;
2289 }
2290 return array(
2291 'found' => count($ids),
2292 'removed' => $this->physical_delete_ids($db, $this->dd_media, $ids),
2293 'deleted_thumbs' => $deleted_thumbs,
2294 );
2295 }
2296
2297 private function find_relocated_media_row(array $existing, string $rel, string $name, array $meta, string $media_folder, array $usage_map = array()) {
2298 $name_l = strtolower(trim($name));
2299 if ($name_l === '') return null;
2300 $size = (int)($meta['size'] ?? 0);
2301 $width = (int)($meta['width'] ?? 0);
2302 $height = (int)($meta['height'] ?? 0);
2303 $media_folder = $this->canonical_media_folder($media_folder);
2304 $best = null;
2305 $best_score = 0;
2306 foreach ($existing as $row) {
2307 if (!is_array($row)) continue;
2308 $id = (int)($row['id'] ?? 0);
2309 $storage = trim((string)($row['storage_type'] ?? ''));
2310 if ($storage === 'external' || (string)($row['media_type'] ?? '') === 'external_video') continue;
2311 if (strtolower((string)($row['file_name'] ?? '')) !== $name_l) continue;
2312 $existing_path = ltrim(str_replace('\\', '/', (string)($row['file_path'] ?? '')), '/');
2313 if ($existing_path === $rel) continue;
2314 if ($existing_path !== '' && $this->relative_file_exists($existing_path)) continue;
2315 $active = (int)($row['active'] ?? 0) === 1;
2316 $used = $id > 0 && !empty($usage_map[$id]);
2317 if (!$active && !$used) continue;
2318 $score = 1;
2319 if ($used) $score += 10;
2320 if ($active) $score += 3;
2321 if ($size > 0 && (int)($row['size'] ?? 0) === $size) $score += 2;
2322 if ($width > 0 && $height > 0 && (int)($row['width'] ?? 0) === $width && (int)($row['height'] ?? 0) === $height) $score += 2;
2323 $old_folder = $this->canonical_media_folder(trim((string)($row['media_folder'] ?? '')));
2324 if ($old_folder !== '' && $old_folder === $media_folder) $score += 1;
2325 if ($score > $best_score) {
2326 $best_score = $score;
2327 $best = $row;
2328 }
2329 }
2330 return $best;
2331 }
2332
2333 private function sync_cms_media_files($db) {
2334 $existing = $db->select($this->dd_media, '', '*', 'id');
2335 if (!is_array($existing)) $existing = array();
2336 $usage_map = $this->active_media_usage_map($db);
2337
2338 $by_path = array();
2339 foreach ($existing as $row) {
2340 if (!is_array($row)) continue;
2341 $path = ltrim(str_replace('\\', '/', (string)($row['file_path'] ?? '')), '/');
2342 if ($path !== '') $by_path[$path] = $row;
2343 }
2344
2345 $seen = array();
2346 $relocated_ids = array();
2347 foreach ($this->collect_media_files() as $rel => $file_info) {
2348 $file = (string)($file_info['file'] ?? '');
2349 if ($file === '' || !is_file($file) || !is_readable($file)) continue;
2350 $name = basename($file);
2351 $slot = $this->valid_media_slot($file_info['slot'] ?? $this->slot_from_media_rel($rel));
2352 $seen[$rel] = 1;
2353 $meta = $this->media_file_meta($file, $name);
2354 $media_type = $meta['media_type'];
2355 if ($media_type === 'file' && preg_match('~^media/youtube/~', $rel)) $media_type = 'external_video';
2356 $media_folder = $this->media_folder_from_path($rel, $media_type);
2357 $storage_type = $media_type === 'external_video' ? 'external' : 'local';
2358 $external_meta = $media_type === 'external_video' ? $this->external_video_json_meta($file) : array();
2359
2360 $existing_key = $rel;
2361 if (!isset($by_path[$existing_key])) {
2362 }
2363
2364 $row = isset($by_path[$existing_key]) ? $by_path[$existing_key] : null;
2365 if (!is_array($row)) {
2366 $row = $this->find_relocated_media_row($existing, $rel, $name, $meta, $media_folder, $usage_map);
2367 }
2368 if (is_array($row)) {
2369 $id = (int)($row['id'] ?? 0);
2370 if ($id <= 0) continue;
2371
2372 $needs_thumb = $storage_type === 'local' && (empty($row['thumb_file_path']) || !$this->media_thumb_exists($row));
2373 $thumb = $needs_thumb ? $this->create_media_thumbnail($file, $media_folder, $name, $meta['mime']) : array();
2374 $update = array(
2375 'active' => 1,
2376 'mime' => $meta['mime'],
2377 'size' => $meta['size'],
2378 'width' => $meta['width'],
2379 'height' => $meta['height'],
2380 'media_type' => $media_type,
2381 'storage_type' => $storage_type,
2382 'media_folder' => $media_folder,
2383 'file_name' => $name,
2384 'file_path' => $rel,
2385 );
2386 if ($external_meta) $update = array_merge($update, $external_meta);
2387 if ($thumb) $update = array_merge($update, $thumb);
2388 $db->update($this->dd_media, $update, $id);
2389 if (($row['file_path'] ?? '') !== $rel) $this->flush_media_by_media_id($db, $id);
2390 $by_path[$rel] = array_merge($row, $update);
2391 if (($row['file_path'] ?? '') !== $rel) $relocated_ids[$id] = 1;
2392 continue;
2393 }
2394
2395 $thumb = $storage_type === 'local' ? $this->create_media_thumbnail($file, $media_folder, $name, $meta['mime']) : array();
2396 $title = pathinfo($name, PATHINFO_FILENAME);
2397 if (!empty($external_meta['title'])) $title = (string)$external_meta['title'];
2398 $insert = array(
2399 'active' => 1,
2400 'content_id' => 0,
2401 'folder_id' => 0,
2402 'slot' => $slot,
2403 'usage' => $slot,
2404 'sorter' => $this->next_media_sorter($db, 0, $slot),
2405 'template' => '',
2406 'title' => $title,
2407 'alt' => $title,
2408 'file_name' => $name,
2409 'file_path' => $rel,
2410 'mime' => $meta['mime'],
2411 'size' => $meta['size'],
2412 'width' => $meta['width'],
2413 'height' => $meta['height'],
2414 'media_type' => $media_type,
2415 'storage_type' => $storage_type,
2416 'media_folder' => $media_folder,
2417 );
2418 if ($external_meta) $insert = array_merge($insert, $external_meta);
2419 if ($thumb) $insert = array_merge($insert, $thumb);
2420 $id = ($db->insert($this->dd_media, $insert) === 1) ? $db->get_insert_id() : 0;
2421 }
2422
2423 foreach ($existing as $row) {
2424 if (!is_array($row)) continue;
2425 $id = (int)($row['id'] ?? 0);
2426 $path = ltrim(str_replace('\\', '/', (string)($row['file_path'] ?? '')), '/');
2427 if ($id <= 0 || (int)($row['active'] ?? 0) !== 1) continue;
2428 if (isset($relocated_ids[$id])) continue;
2429 if (preg_match('~^media/(hero|gallery|inline|header|teaser|footer)/~', $path) && !isset($seen[$path])) {
2430 $db->update($this->dd_media, array('active' => 0), $id);
2431 continue;
2432 }
2433 if (!isset($seen[$path]) && !$this->relative_file_exists($path)) {
2434 $db->update($this->dd_media, array('active' => 0), $id);
2435 }
2436 if ((int)($row['active'] ?? 0) === 1 && ($this->is_excluded_cms_media_folder((string)($row['media_folder'] ?? '')) || preg_match('~^media/module/~', $path))) {
2437 $db->update($this->dd_media, array('active' => 0), $id);
2438 }
2439 }
2440 }
2441
2442 private function normalize_media_row($row) {
2443 $row = is_array($row) ? $row : array();
2444 $file_path = ltrim(str_replace('\\', '/', (string)($row['file_path'] ?? '')), '/');
2445 $row['url'] = $this->media_url($row);
2446 $row['thumb_url'] = $this->media_url($row, true);
2447 $row['media_type'] = $this->media_type($row);
2448 $row['storage_type'] = trim((string)($row['storage_type'] ?? '')) ?: ($row['media_type'] === 'external_video' ? 'external' : 'local');
2449 $row['media_folder'] = $this->canonical_media_folder(trim((string)($row['media_folder'] ?? '')) ?: $this->media_folder_from_path((string)($row['file_path'] ?? ''), $row['media_type']));
2450 if ($row['media_type'] === 'external_video') {
2451 $row['url'] = (string)($row['embed_url'] ?? $row['external_url'] ?? '');
2452 $row['thumb_url'] = $this->external_video_thumb_url($row);
2453 }
2454 return $row;
2455 }
2456
2457 private function normalize_usage_row($row) {
2458 $row = is_array($row) ? $row : array();
2459 $row['id'] = (int)($row['id'] ?? 0);
2460 $row['media_id'] = (int)($row['media_id'] ?? 0);
2461 $row['content_id'] = (int)($row['content_id'] ?? 0);
2462 $row['folder_id'] = (int)($row['folder_id'] ?? 0);
2463 $row['content_lng'] = dbxContentMediaUsageScope::language((string)($row['content_lng'] ?? ''));
2464 $row['slot'] = $this->valid_media_slot($row['slot'] ?? 'gallery');
2465 return $row;
2466 }
2467
2468 private function usage_where($media_id = 0, $content_id = 0, $folder_id = 0, $slot = '', $content_lng = '') {
2469 $where = 'active = 1';
2470 if ((int)$media_id > 0) $where .= ' AND media_id = ' . (int)$media_id;
2471 if ((int)$content_id > 0) $where .= ' AND content_id = ' . (int)$content_id;
2472 if ((int)$folder_id > 0) $where .= ' AND folder_id = ' . (int)$folder_id;
2473 if ((string)$slot !== '') $where .= " AND slot = '" . str_replace("'", "''", $this->valid_media_slot($slot)) . "'";
2474 if ((int)$content_id > 0 || (int)$folder_id > 0) {
2475 $where = dbxContentMediaUsageScope::withLanguage($where, $content_lng);
2476 }
2477 return $where;
2478 }
2479
2480 private function next_media_usage_sorter($db, $content_id, $folder_id, $slot, $content_lng = '') {
2481 $where = $this->usage_where(0, $content_id, $folder_id, $slot, $content_lng);
2482 $rows = $db->select($this->dd_media_usage, $where, '*', 'sorter,id', 'DESC', '', 1, 0, 0);
2483 $max = 0;
2484 if (is_array($rows) && isset($rows[0]) && is_array($rows[0])) $max = (int)($rows[0]['sorter'] ?? 0);
2485 return sprintf('%04d', $max + 10);
2486 }
2487
2488 private function create_media_usage($db, $media_id, $content_id, $folder_id, $slot, $template = '', $caption = '', $settings = '', $content_lng = '') {
2489 $media_id = (int)$media_id;
2490 $content_id = (int)$content_id;
2491 $folder_id = (int)$folder_id;
2492 $slot = $this->valid_media_slot($slot);
2493 $content_lng = dbxContentMediaUsageScope::language($content_lng);
2494 if ($media_id <= 0 || ($content_id <= 0 && $folder_id <= 0)) return 0;
2495 $existingWhere = 'media_id = ' . $media_id
2496 . ' AND content_id = ' . $content_id
2497 . ' AND folder_id = ' . $folder_id
2498 . " AND slot = '" . str_replace("'", "''", $slot) . "'";
2499 $existingWhere = dbxContentMediaUsageScope::withLanguage($existingWhere, $content_lng);
2500 $existing = $db->select($this->dd_media_usage, $existingWhere, '*', 'active DESC,id DESC', 'ASC', '', 1, 0, 0);
2501 if (is_array($existing) && isset($existing[0]['id']) && (int)($existing[0]['active'] ?? 0) === 1) {
2502 return (int)$existing[0]['id'];
2503 }
2504 if ($slot === 'hero') {
2505 if ($content_id > 0) $db->update($this->dd_media_usage, array('active' => 0), dbxContentMediaUsageScope::withLanguage("content_id = " . $content_id . " AND slot = 'hero' AND active = 1", $content_lng), 0, 1, 1, 0);
2506 elseif ($folder_id > 0) $db->update($this->dd_media_usage, array('active' => 0), dbxContentMediaUsageScope::withLanguage("folder_id = " . $folder_id . " AND slot = 'hero' AND active = 1", $content_lng), 0, 1, 1, 0);
2507 }
2508 if (is_array($existing) && isset($existing[0]['id'])) {
2509 $existingId = (int)$existing[0]['id'];
2510 $db->update($this->dd_media_usage, array(
2511 'active' => 1,
2512 'template' => $this->clean_text($template, 80),
2513 'caption' => $this->clean_text($caption, 0),
2514 'settings' => $this->clean_text($settings, 0),
2515 ), $existingId, 0, 1, 1, 0);
2516 return $existingId;
2517 }
2518 return ($db->insert($this->dd_media_usage, array(
2519 'active' => 1,
2520 'media_id' => $media_id,
2521 'content_id' => $content_id,
2522 'folder_id' => $folder_id,
2523 'content_lng' => $content_lng,
2524 'slot' => $slot,
2525 'sorter' => $this->next_media_usage_sorter($db, $content_id, $folder_id, $slot, $content_lng),
2526 'template' => $this->clean_text($template, 80),
2527 'caption' => $this->clean_text($caption, 0),
2528 'settings' => $this->clean_text($settings, 0),
2529 )) === 1) ? $db->get_insert_id() : 0;
2530 }
2531
2532 private function get_media_process_state($token) {
2533 $token = preg_replace('/[^A-Za-z0-9_-]+/', '', (string)$token);
2534 if ($token === '') return array();
2535 return $_SESSION['dbx']['dbxContent_admin']['media_process'][$token] ?? array();
2536 }
2537
2538 private function set_media_process_state($token, array $state) {
2539 $token = preg_replace('/[^A-Za-z0-9_-]+/', '', (string)$token);
2540 if ($token === '') return;
2541 if (!isset($_SESSION['dbx']) || !is_array($_SESSION['dbx'])) $_SESSION['dbx'] = array();
2542 if (!isset($_SESSION['dbx']['dbxContent_admin']) || !is_array($_SESSION['dbx']['dbxContent_admin'])) $_SESSION['dbx']['dbxContent_admin'] = array();
2543 if (!isset($_SESSION['dbx']['dbxContent_admin']['media_process']) || !is_array($_SESSION['dbx']['dbxContent_admin']['media_process'])) $_SESSION['dbx']['dbxContent_admin']['media_process'] = array();
2544 $_SESSION['dbx']['dbxContent_admin']['media_process'][$token] = $state;
2545 }
2546
2547 private function slot_from_media_rel($rel, $fallback = 'gallery') {
2548 $rel = ltrim(str_replace('\\', '/', (string)$rel), '/');
2549 if (preg_match('~^media/(hero|gallery|inline|header|teaser|footer)/~', $rel, $m)) {
2550 return $this->valid_media_slot($m[1], $fallback);
2551 }
2552 return $this->valid_media_slot($fallback);
2553 }
2554
2555 private function build_media_process_state($db, $token) {
2556 $rows = $db->select($this->dd_media, '', '*', 'id');
2557 if (!is_array($rows)) $rows = array();
2558
2559 $by_path = array();
2560 $referenced_thumbs = array();
2561 $tasks = array();
2562 $needs_content_cleanup = false;
2563
2564 foreach ($rows as $row) {
2565 if (!is_array($row)) continue;
2566 $id = (int)($row['id'] ?? 0);
2567 $path = ltrim(str_replace('\\', '/', (string)($row['file_path'] ?? '')), '/');
2568 if ($path !== '') $by_path[$path] = $id;
2569 $thumb = ltrim(str_replace('\\', '/', (string)($row['thumb_file_path'] ?? '')), '/');
2570 if ($thumb !== '') $referenced_thumbs[$thumb] = 1;
2571 $is_external = strtolower((string)($row['storage_type'] ?? '')) === 'external'
2572 || $this->media_type($row) === 'external_video';
2573 $is_excluded = $this->is_excluded_cms_media_folder((string)($row['media_folder'] ?? ''))
2574 || strpos($path, 'media/module/') === 0;
2575 $is_legacy_path = (bool)preg_match('~^media/(hero|gallery|inline|header|teaser|footer)/~', $path);
2576 $file_exists = $this->media_file_exists($row);
2577 $needs_record_check = $is_legacy_path
2578 || (!$is_external && !$is_excluded && (((int)($row['active'] ?? 0) !== 1 && $file_exists) || ((int)($row['active'] ?? 0) === 1 && !$file_exists)))
2579 || ($is_external && (int)($row['active'] ?? 0) === 1 && !$file_exists);
2580 if ($id > 0 && $needs_record_check) {
2581 $tasks[] = array('type' => 'record', 'id' => $id);
2582 if (!$file_exists) $needs_content_cleanup = true;
2583 }
2584 if ($id > 0
2585 && !$needs_record_check
2586 && (int)($row['active'] ?? 0) === 1
2587 && $this->media_file_exists($row)
2588 && $this->media_thumbnail_supported($row)
2589 && preg_match('~^media/(img|video|file|module)/~', $path)
2590 && !$this->media_thumbnail_is_current($row)) {
2591 $tasks[] = array('type' => 'thumb', 'id' => $id);
2592 }
2593 if ($id > 0 && (int)($row['active'] ?? 0) !== 1) $needs_content_cleanup = true;
2594 }
2595
2596 foreach ($this->collect_media_files() as $rel => $file) {
2597 if (!isset($by_path[$rel])) {
2598 $tasks[] = array('type' => 'import', 'rel' => $rel, 'slot' => $file['slot']);
2599 }
2600 }
2601
2602 foreach ($this->collect_media_thumb_files() as $rel => $file) {
2603 if (!isset($referenced_thumbs[$rel])) {
2604 $tasks[] = array('type' => 'delete_thumb', 'rel' => $rel);
2605 }
2606 }
2607
2608 if ($needs_content_cleanup || $this->content_inline_cleanup_needed($db)) {
2609 $tasks[] = array('type' => 'content_cleanup');
2610 }
2611
2612 if ($this->content_inline_placeholder_repair_needed($db)) {
2613 $tasks[] = array('type' => 'content_placeholder_repair');
2614 }
2615
2616 $tasks[] = array('type' => 'structured_reference_cleanup');
2617 $tasks[] = array('type' => 'usage_reconcile');
2618 $tasks[] = array('type' => 'media_record_purge');
2619
2620 $cache_content_ids = $this->content_media_reference_ids($db);
2621 if ($cache_content_ids) {
2622 $tasks[] = array('type' => 'content_cache_flush', 'ids' => $cache_content_ids);
2623 }
2624
2625 // Bei SQLite optimiert dies die komplette dbxMedia-Datenbank mit
2626 // VACUUM, bei MySQL die Tabelle mit OPTIMIZE TABLE.
2627 $tasks[] = array('type' => 'database_optimize');
2628
2629 return array(
2630 'proc_key' => $token,
2631 'proc_type' => 'media_maintenance',
2632 'status' => empty($tasks) ? 'finished' : 'running',
2633 'phase' => 'media_prepare',
2634 'message' => empty($tasks) ? 'Keine Wartung notwendig.' : 'Medienwartung vorbereitet.',
2635 'tasks' => $tasks,
2636 'pos' => 0,
2637 'total' => count($tasks),
2638 'created_thumbs' => 0,
2639 'imported_media' => 0,
2640 'relinked_media' => 0,
2641 'deleted_thumbs' => 0,
2642 'deactivated_media' => 0,
2643 'cleaned_inline_refs' => 0,
2644 'cleaned_content_pages' => 0,
2645 'repaired_inline_refs' => 0,
2646 'flushed_content_pages' => 0,
2647 'removed_orphan_usage' => 0,
2648 'usage_rows_analyzed' => 0,
2649 'actual_media_references' => 0,
2650 'usage_rows_added' => 0,
2651 'usage_rows_updated' => 0,
2652 'usage_rows_removed' => 0,
2653 'usage_inactive_removed' => 0,
2654 'usage_stale_removed' => 0,
2655 'usage_duplicate_removed' => 0,
2656 'cleaned_structured_refs' => 0,
2657 'purged_media_records' => 0,
2658 'database_optimized' => 0,
2659 'errors' => 0,
2660 'percent' => empty($tasks) ? 100 : 0,
2661 'step_percent' => empty($tasks) ? 100 : 0,
2662 'updated_at' => date('H:i:s'),
2663 );
2664 }
2665
2666 private function update_media_process_percent(array &$state) {
2667 $total = max(1, (int)($state['total'] ?? 0));
2668 $pos = max(0, min($total, (int)($state['pos'] ?? 0)));
2669 $percent = (int)floor(($pos / $total) * 100);
2670 if (($state['status'] ?? '') === 'finished') $percent = 100;
2671 $state['percent'] = max(0, min(100, $percent));
2672 $state['step_percent'] = $state['percent'];
2673 }
2674
2675 private function media_process_message(array $state, $current = '') {
2676 $parts = array();
2677 if ((int)($state['created_thumbs'] ?? 0) > 0) $parts[] = 'Thumbs +' . (int)$state['created_thumbs'];
2678 if ((int)($state['imported_media'] ?? 0) > 0) $parts[] = 'Import +' . (int)$state['imported_media'];
2679 if ((int)($state['relinked_media'] ?? 0) > 0) $parts[] = 'Zuordnung repariert ' . (int)$state['relinked_media'];
2680 if ((int)($state['deleted_thumbs'] ?? 0) > 0) $parts[] = 'Thumbs weg ' . (int)$state['deleted_thumbs'];
2681 if ((int)($state['deactivated_media'] ?? 0) > 0) $parts[] = 'Medien aus ' . (int)$state['deactivated_media'];
2682 if ((int)($state['cleaned_inline_refs'] ?? 0) > 0) $parts[] = 'Content bereinigt ' . (int)$state['cleaned_inline_refs'];
2683 if ((int)($state['repaired_inline_refs'] ?? 0) > 0) $parts[] = 'Inline repariert ' . (int)$state['repaired_inline_refs'];
2684 if ((int)($state['flushed_content_pages'] ?? 0) > 0) $parts[] = 'Cache aktualisiert ' . (int)$state['flushed_content_pages'];
2685 if ((int)($state['usage_rows_added'] ?? 0) > 0) $parts[] = 'Nutzung ergaenzt ' . (int)$state['usage_rows_added'];
2686 if ((int)($state['usage_rows_updated'] ?? 0) > 0) $parts[] = 'Nutzung korrigiert ' . (int)$state['usage_rows_updated'];
2687 if ((int)($state['usage_rows_removed'] ?? 0) > 0) $parts[] = 'Ungueltige Zuordnungen entfernt ' . (int)$state['usage_rows_removed'];
2688 if ((int)($state['cleaned_structured_refs'] ?? 0) > 0) $parts[] = 'Defekte Inhaltsverweise bereinigt ' . (int)$state['cleaned_structured_refs'];
2689 if ((int)($state['purged_media_records'] ?? 0) > 0) $parts[] = 'Ungueltige Medien-Datensaetze entfernt ' . (int)$state['purged_media_records'];
2690 if ((int)($state['database_optimized'] ?? 0) > 0) $parts[] = 'Datenbanken optimiert ' . (int)$state['database_optimized'];
2691 if ((int)($state['errors'] ?? 0) > 0) $parts[] = 'Fehler ' . (int)$state['errors'];
2692 $msg = empty($parts) ? 'Keine Aenderungen.' : implode(' | ', $parts);
2693 if ($current !== '') $msg .= ' | ' . $current;
2694 return $msg;
2695 }
2696
2697 private function content_inline_cleanup_needed($db) {
2698 foreach ($this->ordered_media_languages() as $lng) {
2699 $rows = $db->select(dbxContentLng::ddContent($lng), "content LIKE '%dbx_mid=%' OR content LIKE '%data-cms-media-id=%'", 'id,content', 'id');
2700 if (!is_array($rows)) continue;
2701 foreach ($rows as $row) {
2702 if (!is_array($row)) continue;
2703 foreach ($this->inline_media_ids($row['content'] ?? '') as $id) {
2704 $media = $db->select1($this->dd_media, $id);
2705 if (!is_array($media) || (int)($media['active'] ?? 0) !== 1 || !$this->media_file_exists($media)) return true;
2706 }
2707 }
2708 }
2709 return false;
2710 }
2711
2712 private function content_media_reference_ids($db) {
2713 $ids = array();
2714 $rows = $db->select(dbxContentLng::ddContent(), "content LIKE '%dbx_mid=%' OR content LIKE '%data-cms-media-id=%'", 'id,content', 'id');
2715 if (!is_array($rows)) return $ids;
2716 foreach ($rows as $row) {
2717 if (!is_array($row)) continue;
2718 $content_id = (int)($row['id'] ?? 0);
2719 if ($content_id <= 0) continue;
2720 if (!$this->inline_media_ids($row['content'] ?? '')) continue;
2721 $ids[$content_id] = $content_id;
2722 }
2723 return array_values($ids);
2724 }
2725
2726 private function content_inline_placeholder_repair_needed($db) {
2727 foreach (dbxContentLngSync::accessibleLngs() as $lng) {
2728 $rows = $db->select(dbxContentLng::ddContent((string)$lng), "content LIKE '%dbx-cms-inline-media-missing%'", 'id,content', 'id');
2729 if (!is_array($rows)) continue;
2730 foreach ($rows as $row) {
2731 if (!is_array($row)) continue;
2732 if (preg_match('/\bdbx-cms-inline-media-missing\b/i', (string)($row['content'] ?? ''))) return true;
2733 }
2734 }
2735 return false;
2736 }
2737
2738 private function repair_content_inline_placeholders($db) {
2739 $pages = 0;
2740 $refs = 0;
2741 $prev_lng = dbxContentLng::current();
2742
2743 foreach (dbxContentLngSync::accessibleLngs() as $lng) {
2744 $lng = (string)$lng;
2745 dbx()->set_system_var('dbx_lng', $lng);
2746 $dd = dbxContentLng::ddContent($lng);
2747 $rows = $db->select($dd, "content LIKE '%dbx-cms-inline-media-missing%'", 'id,folder,content', 'id');
2748 if (!is_array($rows)) continue;
2749
2750 foreach ($rows as $row) {
2751 if (!is_array($row)) continue;
2752 $content_id = (int)($row['id'] ?? 0);
2753 if ($content_id <= 0) continue;
2754 $html = (string)($row['content'] ?? '');
2755 if (stripos($html, 'dbx-cms-inline-media-missing') === false) continue;
2756
2757 $changed = 0;
2758 $clean = preg_replace_callback(
2759 '/<(p|figure|div)\b([^>]*(?:\bdbx-cms-inline-media-missing-wrap\b|\bdbx-cms-inline-media\b|\bdata-cms-media-id=)[^>]*)>([\s\S]*?\bdbx-cms-inline-media-missing\b[\s\S]*?)<\/\1>/i',
2760 function($m) use ($db, &$changed) {
2761 $block = (string)($m[0] ?? '');
2762 if (!preg_match('/\bdata-cms-media-id=["\']?([0-9]+)/i', $block, $id_match)) return $block;
2763 $id = (int)$id_match[1];
2764 if ($id <= 0) return $block;
2765 $media = $db->select1($this->dd_media, $id);
2766 if (!is_array($media) || (int)($media['active'] ?? 0) !== 1 || !$this->media_file_exists($media)) return $block;
2767 $replacement = $this->inline_media_embed_html($media);
2768 if ($replacement === '') return $block;
2769 $changed++;
2770 return $replacement;
2771 },
2772 $html
2773 );
2774 if ($clean === null || $clean === $html || $changed <= 0) continue;
2775 $ok = $db->update($dd, array('content' => $clean), $content_id, 0, 0, 0, 0);
2776 if ($ok < 0) continue;
2777 $this->sync_inline_media_usage($db, $content_id, $clean, (int)($row['folder'] ?? 0));
2778 $this->flush_saved_page_cache($db, $content_id);
2779 $pages++;
2780 $refs += $changed;
2781 }
2782 }
2783
2784 dbx()->set_system_var('dbx_lng', $prev_lng);
2785
2786 return array('pages' => $pages, 'refs' => $refs);
2787 }
2788
2789 private function remove_inline_media_ids_from_html($html, array $ids, &$removed = 0) {
2790 $html = (string)$html;
2791 $removed = 0;
2792 $ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
2793 if (!$ids || trim($html) === '') return $html;
2794
2795 if (class_exists('\DOMDocument')) {
2796 $doc = new \DOMDocument('1.0', 'UTF-8');
2797 $prev = libxml_use_internal_errors(true);
2798 $wrapped = '<div id="dbx-cms-clean-root">' . $html . '</div>';
2799 $ok = $doc->loadHTML('<?xml encoding="utf-8" ?>' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
2800 libxml_clear_errors();
2801 libxml_use_internal_errors($prev);
2802 if ($ok) {
2803 $xpath = new \DOMXPath($doc);
2804 $root_nodes = $xpath->query("//*[@id='dbx-cms-clean-root']");
2805 $root = $root_nodes && $root_nodes->length ? $root_nodes->item(0) : null;
2806 foreach ($ids as $id) {
2807 $query = "//*[@data-cms-media-id='" . $id . "' or contains(@src,'dbx_mid=" . $id . "') or contains(@href,'dbx_mid=" . $id . "')]";
2808 $nodes = $xpath->query($query);
2809 $targets = array();
2810 if ($nodes) {
2811 foreach ($nodes as $node) {
2812 $target = $node;
2813 for ($p = $node; $p && $p !== $root; $p = $p->parentNode) {
2814 if ($p->nodeType !== XML_ELEMENT_NODE) continue;
2815 $tag = strtolower($p->nodeName);
2816 $class = ' ' . (string)$p->getAttribute('class') . ' ';
2817 if ($tag === 'figure' || strpos($class, ' dbx-cms-inline-media ') !== false) {
2818 $target = $p;
2819 }
2820 }
2821 if ($target && $target->parentNode) $targets[spl_object_hash($target)] = $target;
2822 }
2823 }
2824 foreach ($targets as $target) {
2825 if ($target->parentNode) {
2826 $target->parentNode->removeChild($target);
2827 $removed++;
2828 }
2829 }
2830 }
2831 if ($root) {
2832 $out = '';
2833 foreach (iterator_to_array($root->childNodes) as $child) {
2834 $out .= $doc->saveHTML($child);
2835 }
2836 return $out;
2837 }
2838 }
2839 }
2840
2841 foreach ($ids as $id) {
2842 $before = $html;
2843 $id_re = preg_quote((string)$id, '~');
2844 $html = preg_replace('~<(p|figure|div)\b[^>]*(?:data-cms-media-id=["\']?' . $id_re . '\b|class=["\'][^"\']*dbx-cms-inline-media[^"\']*["\'][^>]*>.*?(?:dbx_mid=' . $id_re . '\b|data-cms-media-id=["\']?' . $id_re . '\b))[\s\S]*?</\1>~i', '', $html);
2845 $html = preg_replace('~<(img|video|iframe|source)\b[^>]*(?:dbx_mid=' . $id_re . '\b|data-cms-media-id=["\']?' . $id_re . '\b)[^>]*>(?:</\1>)?~i', '', $html);
2846 if ($html !== $before) $removed++;
2847 }
2848 return $html;
2849 }
2850
2851 private function clean_content_inline_media($db) {
2852 $pages = 0;
2853 $refs = 0;
2854 $previous_lng = (string)dbx()->get_system_var('dbx_lng', dbxContentLngSync::masterLng());
2855 try {
2856 foreach ($this->ordered_media_languages() as $lng) {
2857 dbx()->set_system_var('dbx_lng', $lng);
2858 $content_dd = dbxContentLng::ddContent($lng);
2859 $rows = $db->select($content_dd, "content LIKE '%dbx_mid=%' OR content LIKE '%data-cms-media-id=%'", 'id,folder,content', 'id');
2860 if (!is_array($rows)) continue;
2861 foreach ($rows as $row) {
2862 if (!is_array($row)) continue;
2863 $content_id = (int)($row['id'] ?? 0);
2864 if ($content_id <= 0) continue;
2865 $bad = array();
2866 foreach ($this->inline_media_ids($row['content'] ?? '') as $id) {
2867 $media = $db->select1($this->dd_media, $id);
2868 if (!is_array($media) || (int)($media['active'] ?? 0) !== 1 || !$this->media_file_exists($media)) $bad[] = $id;
2869 }
2870 if (!$bad) continue;
2871 $removed = 0;
2872 $clean = $this->remove_inline_media_ids_from_html((string)($row['content'] ?? ''), $bad, $removed);
2873 if ($clean !== (string)($row['content'] ?? '')) {
2874 $db->update($content_dd, array('content' => $clean), $content_id);
2875 $this->sync_inline_media_usage($db, $content_id, $clean, (int)($row['folder'] ?? 0));
2876 $this->flush_saved_page_cache($db, $content_id);
2877 $pages++;
2878 $refs += max($removed, count($bad));
2879 }
2880 }
2881 }
2882 } finally {
2883 dbx()->set_system_var('dbx_lng', $previous_lng);
2884 }
2885 return array('pages' => $pages, 'refs' => $refs);
2886 }
2887
2888 private function import_media_file($db, $rel, $slot, &$relinked = null) {
2889 $relinked = false;
2890 $rel = ltrim(str_replace('\\', '/', (string)$rel), '/');
2891 $slot = $this->valid_media_slot($slot);
2892 $file = $this->file_from_media_rel($rel);
2893 if ($file === '' || !is_file($file) || !is_readable($file)) return 0;
2894
2895 $name = basename($file);
2896 $meta = $this->media_file_meta($file, $name);
2897 $media_folder = $this->media_folder_from_path($rel, $meta['media_type']);
2898 $existing = $db->select($this->dd_media, '', '*', 'id');
2899 if (!is_array($existing)) $existing = array();
2900 $relocated = $this->find_relocated_media_row($existing, $rel, $name, $meta, $media_folder, $this->active_media_usage_map($db));
2901 if (is_array($relocated)) {
2902 $id = (int)($relocated['id'] ?? 0);
2903 if ($id > 0) {
2904 $thumb = (empty($relocated['thumb_file_path']) || !$this->media_thumb_exists($relocated))
2905 ? $this->create_media_thumbnail($file, $media_folder, $name, $meta['mime'])
2906 : array();
2907 $update = array(
2908 'active' => 1,
2909 'file_path' => $rel,
2910 'file_name' => $name,
2911 'media_folder' => $media_folder,
2912 'mime' => $meta['mime'],
2913 'size' => $meta['size'],
2914 'width' => $meta['width'],
2915 'height' => $meta['height'],
2916 'media_type' => $meta['media_type'],
2917 'storage_type' => 'local',
2918 );
2919 if ($thumb) $update = array_merge($update, $thumb);
2920 $db->update($this->dd_media, $update, $id);
2921 $this->flush_media_by_media_id($db, $id);
2922 $relinked = true;
2923 return $id;
2924 }
2925 }
2926
2927 $title = pathinfo($name, PATHINFO_FILENAME);
2928 $insert = array(
2929 'active' => 1,
2930 'content_id' => 0,
2931 'folder_id' => 0,
2932 'slot' => $slot,
2933 'usage' => $slot,
2934 'sorter' => $this->next_media_sorter($db, 0, $slot),
2935 'template' => '',
2936 'title' => $title,
2937 'alt' => $title,
2938 'file_name' => $name,
2939 'file_path' => $rel,
2940 'mime' => $meta['mime'],
2941 'size' => $meta['size'],
2942 'width' => $meta['width'],
2943 'height' => $meta['height'],
2944 'media_type' => $meta['media_type'],
2945 'media_folder' => $media_folder,
2946 'storage_type' => 'local',
2947 );
2948 $thumb = $this->create_media_thumbnail($file, $media_folder, $name, $meta['mime']);
2949 if ($thumb) $insert = array_merge($insert, $thumb);
2950 return ($db->insert($this->dd_media, $insert) === 1) ? $db->get_insert_id() : 0;
2951 }
2952
2953 private function run_media_process_task($db, array &$state, array $task) {
2954 $type = (string)($task['type'] ?? '');
2955
2956 if ($type === 'delete_thumb') {
2957 $rel = ltrim(str_replace('\\', '/', (string)($task['rel'] ?? '')), '/');
2958 $file = $this->file_from_media_rel($rel);
2959 if ($file !== '' && is_file($file) && @unlink($file)) {
2960 $state['deleted_thumbs'] = (int)($state['deleted_thumbs'] ?? 0) + 1;
2961 }
2962 $state['phase'] = 'media_cleanup';
2963 return basename($rel);
2964 }
2965
2966 if ($type === 'thumb') {
2967 $id = (int)($task['id'] ?? 0);
2968 $row = $id > 0 ? $db->select1($this->dd_media, $id) : array();
2969 if (!is_array($row)) return '#' . $id;
2970 $rel = ltrim(str_replace('\\', '/', (string)($row['file_path'] ?? '')), '/');
2971 if (!$this->media_thumbnail_supported($row)) {
2972 $state['phase'] = 'media_thumbs';
2973 return basename($rel);
2974 }
2975 $file = $this->file_from_media_rel($rel);
2976 if ($file === '' || !is_file($file) || !is_readable($file)) {
2977 $state['errors'] = (int)($state['errors'] ?? 0) + 1;
2978 return basename($rel);
2979 }
2980 $name = basename($file);
2981 $meta = $this->media_file_meta($file, $name);
2982 $media_folder = $this->media_folder_from_path($rel, $meta['media_type']);
2983 $old_thumb = $this->source_thumb_file($row);
2984 $thumb = $this->create_media_thumbnail($file, $media_folder, $name, $meta['mime']);
2985 if ($thumb) {
2986 $db->update($this->dd_media, $thumb, $id);
2987 $state['created_thumbs'] = (int)($state['created_thumbs'] ?? 0) + 1;
2988 $new_thumb = $this->file_from_media_rel((string)($thumb['thumb_file_path'] ?? ''));
2989 if ($old_thumb !== '' && $old_thumb !== $new_thumb && is_file($old_thumb) && @unlink($old_thumb)) {
2990 $state['deleted_thumbs'] = (int)($state['deleted_thumbs'] ?? 0) + 1;
2991 }
2992 } else {
2993 $state['errors'] = (int)($state['errors'] ?? 0) + 1;
2994 }
2995 $state['phase'] = 'media_thumbs';
2996 return basename($rel);
2997 }
2998
2999 if ($type === 'import') {
3000 $rel = ltrim(str_replace('\\', '/', (string)($task['rel'] ?? '')), '/');
3001 $slot = $this->valid_media_slot($task['slot'] ?? $this->slot_from_media_rel($rel));
3002 $relinked = false;
3003 $id = $this->import_media_file($db, $rel, $slot, $relinked);
3004 if ($id > 0) {
3005 if ($relinked) {
3006 $state['relinked_media'] = (int)($state['relinked_media'] ?? 0) + 1;
3007 } else {
3008 $state['imported_media'] = (int)($state['imported_media'] ?? 0) + 1;
3009 }
3010 $row = $db->select1($this->dd_media, $id);
3011 if (is_array($row) && !empty($row['thumb_file_path'])) {
3012 $state['created_thumbs'] = (int)($state['created_thumbs'] ?? 0) + 1;
3013 }
3014 } else {
3015 $state['errors'] = (int)($state['errors'] ?? 0) + 1;
3016 }
3017 $state['phase'] = 'media_import';
3018 return basename($rel);
3019 }
3020
3021 if ($type === 'content_cleanup') {
3022 $done = $this->clean_content_inline_media($db);
3023 $state['cleaned_inline_refs'] = (int)($state['cleaned_inline_refs'] ?? 0) + (int)($done['refs'] ?? 0);
3024 $state['cleaned_content_pages'] = (int)($state['cleaned_content_pages'] ?? 0) + (int)($done['pages'] ?? 0);
3025 $state['phase'] = 'content_cleanup';
3026 return 'Content';
3027 }
3028
3029 if ($type === 'content_placeholder_repair') {
3030 $done = $this->repair_content_inline_placeholders($db);
3031 $state['repaired_inline_refs'] = (int)($state['repaired_inline_refs'] ?? 0) + (int)($done['refs'] ?? 0);
3032 $state['cleaned_content_pages'] = (int)($state['cleaned_content_pages'] ?? 0) + (int)($done['pages'] ?? 0);
3033 $state['phase'] = 'content_repair';
3034 return 'Inline';
3035 }
3036
3037 if ($type === 'content_cache_flush') {
3038 $ids = is_array($task['ids'] ?? null) ? $task['ids'] : array();
3039 $count = 0;
3040 foreach ($ids as $content_id) {
3041 $content_id = (int)$content_id;
3042 if ($content_id <= 0) continue;
3043 $this->flush_saved_page_cache($db, $content_id);
3044 $count++;
3045 }
3046 $state['flushed_content_pages'] = (int)($state['flushed_content_pages'] ?? 0) + $count;
3047 $state['phase'] = 'content_cache';
3048 return 'Cache';
3049 }
3050
3051 if ($type === 'structured_reference_cleanup') {
3052 $done = $this->cleanup_invalid_structured_media_references($db);
3053 $state['cleaned_structured_refs'] = (int)($state['cleaned_structured_refs'] ?? 0) + (int)($done['refs'] ?? 0);
3054 $state['cleaned_content_pages'] = (int)($state['cleaned_content_pages'] ?? 0) + (int)($done['pages'] ?? 0);
3055 $state['phase'] = 'media_reference_cleanup';
3056 return 'Inhaltsverweise';
3057 }
3058
3059 if ($type === 'usage_reconcile') {
3060 $done = $this->reconcile_media_usage($db);
3061 $state['usage_rows_analyzed'] = (int)($state['usage_rows_analyzed'] ?? 0) + (int)($done['analyzed'] ?? 0);
3062 $state['actual_media_references'] = (int)($done['actual_references'] ?? 0);
3063 $state['usage_rows_added'] = (int)($state['usage_rows_added'] ?? 0) + (int)($done['added'] ?? 0);
3064 $state['usage_rows_updated'] = (int)($state['usage_rows_updated'] ?? 0) + (int)($done['updated'] ?? 0);
3065 $state['usage_rows_removed'] = (int)($state['usage_rows_removed'] ?? 0) + (int)($done['removed'] ?? 0);
3066 $state['removed_orphan_usage'] = (int)($state['removed_orphan_usage'] ?? 0) + (int)($done['removed'] ?? 0);
3067 $reasons = is_array($done['reasons'] ?? null) ? $done['reasons'] : array();
3068 $state['usage_inactive_removed'] = (int)($state['usage_inactive_removed'] ?? 0) + (int)($reasons['inactive'] ?? 0);
3069 $state['usage_duplicate_removed'] = (int)($state['usage_duplicate_removed'] ?? 0) + (int)($reasons['duplicate'] ?? 0);
3070 $state['usage_stale_removed'] = (int)($state['usage_stale_removed'] ?? 0)
3071 + max(0, (int)($done['removed'] ?? 0) - (int)($reasons['inactive'] ?? 0) - (int)($reasons['duplicate'] ?? 0));
3072 if ((int)($done['removed'] ?? 0) < (int)($done['planned_removed'] ?? 0)) {
3073 $state['errors'] = (int)($state['errors'] ?? 0) + ((int)$done['planned_removed'] - (int)$done['removed']);
3074 }
3075 $state['phase'] = 'media_usage_reconcile';
3076 return 'Nutzungsanalyse';
3077 }
3078
3079 if ($type === 'media_record_purge') {
3080 $done = $this->purge_invalid_media_records($db);
3081 $state['purged_media_records'] = (int)($state['purged_media_records'] ?? 0) + (int)($done['removed'] ?? 0);
3082 $state['deleted_thumbs'] = (int)($state['deleted_thumbs'] ?? 0) + (int)($done['deleted_thumbs'] ?? 0);
3083 if ((int)($done['removed'] ?? 0) < (int)($done['found'] ?? 0)) {
3084 $state['errors'] = (int)($state['errors'] ?? 0) + ((int)$done['found'] - (int)$done['removed']);
3085 }
3086 $state['phase'] = 'media_record_purge';
3087 return 'Medien-Datenbank';
3088 }
3089
3090 if ($type === 'usage_cleanup') {
3091 $done = $this->cleanup_orphan_media_usage($db);
3092 $state['removed_orphan_usage'] = (int)($state['removed_orphan_usage'] ?? 0) + (int)($done['removed'] ?? 0);
3093 if ((int)($done['removed'] ?? 0) < (int)($done['found'] ?? 0)) {
3094 $state['errors'] = (int)($state['errors'] ?? 0) + ((int)$done['found'] - (int)$done['removed']);
3095 }
3096 $state['phase'] = 'media_usage_cleanup';
3097 return 'Zuordnungen';
3098 }
3099
3100 if ($type === 'database_optimize') {
3101 $optimized = 0;
3102 foreach (array($this->dd_media_usage, dbxContentLng::ddContent()) as $dd) {
3103 if ((int)$db->optimize_tab($dd) === 1) $optimized++;
3104 else $state['errors'] = (int)($state['errors'] ?? 0) + 1;
3105 }
3106 $state['database_optimized'] = $optimized;
3107 $state['phase'] = 'database_optimize';
3108 return 'Datenbanken';
3109 }
3110
3111 if ($type === 'record') {
3112 $id = (int)($task['id'] ?? 0);
3113 $row = $id > 0 ? $db->select1($this->dd_media, $id) : array();
3114 if (!is_array($row)) return '#' . $id;
3115
3116 if (strtolower((string)($row['storage_type'] ?? '')) === 'external' || $this->media_type($row) === 'external_video') {
3117 if (!$this->media_file_exists($row) && (int)($row['active'] ?? 0) !== 0) {
3118 $db->update($this->dd_media, array('active' => 0), $id);
3119 $state['deactivated_media'] = (int)($state['deactivated_media'] ?? 0) + 1;
3120 }
3121 $state['phase'] = 'media_cleanup';
3122 return (string)($row['title'] ?? ('#' . $id));
3123 }
3124
3125 $rel = ltrim(str_replace('\\', '/', (string)($row['file_path'] ?? '')), '/');
3126 $file = $this->file_from_media_rel($rel);
3127 if ($file === '' || !is_file($file) || !is_readable($file)) {
3128 $thumb = $this->source_thumb_file($row);
3129 if ($thumb !== '' && @unlink($thumb)) {
3130 $state['deleted_thumbs'] = (int)($state['deleted_thumbs'] ?? 0) + 1;
3131 }
3132 if ((int)($row['active'] ?? 0) !== 0 || !empty($row['thumb_file_path'])) {
3133 $db->update($this->dd_media, array(
3134 'active' => 0,
3135 'thumb_file_path' => '',
3136 'thumb_width' => 0,
3137 'thumb_height' => 0,
3138 ), $id);
3139 $state['deactivated_media'] = (int)($state['deactivated_media'] ?? 0) + 1;
3140 }
3141 $state['phase'] = 'media_cleanup';
3142 return basename($rel);
3143 }
3144
3145 $slot = $this->slot_from_media_rel($rel, (string)($row['slot'] ?? 'gallery'));
3146 $name = basename($file);
3147 $meta = $this->media_file_meta($file, $name);
3148 $update = array();
3149 if ((int)($row['active'] ?? 0) !== 1
3150 || (string)($row['slot'] ?? '') !== $slot
3151 || (string)($row['mime'] ?? '') !== $meta['mime']
3152 || (int)($row['size'] ?? 0) !== $meta['size']
3153 || (string)($row['media_type'] ?? '') !== $meta['media_type']) {
3154 $update = array(
3155 'active' => 1,
3156 'slot' => $slot,
3157 'usage' => $slot,
3158 'mime' => $meta['mime'],
3159 'size' => $meta['size'],
3160 'width' => $meta['width'],
3161 'height' => $meta['height'],
3162 'media_type' => $meta['media_type'],
3163 );
3164 }
3165
3166 if (!$this->media_thumbnail_is_current($row)) {
3167 $old_thumb = $this->source_thumb_file($row);
3168 $thumb = $this->create_media_thumbnail($file, $slot, $name, $meta['mime']);
3169 if ($thumb) {
3170 $update = array_merge($update, $thumb);
3171 $state['created_thumbs'] = (int)($state['created_thumbs'] ?? 0) + 1;
3172 $new_thumb = $this->file_from_media_rel((string)($thumb['thumb_file_path'] ?? ''));
3173 if ($old_thumb !== '' && $old_thumb !== $new_thumb && is_file($old_thumb) && @unlink($old_thumb)) {
3174 $state['deleted_thumbs'] = (int)($state['deleted_thumbs'] ?? 0) + 1;
3175 }
3176 }
3177 }
3178
3179 if (!empty($update)) $db->update($this->dd_media, $update, $id);
3180 $state['phase'] = 'media_thumbs';
3181 return basename($rel);
3182 }
3183
3184 return '';
3185 }
3186
3187 private function tick_media_process($db, array &$state) {
3188 if (($state['status'] ?? '') !== 'running') return;
3189 $started = microtime(true);
3190 $limit = 8;
3191 $processed = 0;
3192 $current = '';
3193 $total = (int)($state['total'] ?? 0);
3194
3195 while ((int)($state['pos'] ?? 0) < $total && $processed < $limit && (microtime(true) - $started) < 3.2) {
3196 $idx = (int)$state['pos'];
3197 $task = is_array($state['tasks'][$idx] ?? null) ? $state['tasks'][$idx] : array();
3198 $current = $this->run_media_process_task($db, $state, $task);
3199 $state['pos'] = $idx + 1;
3200 $processed++;
3201 }
3202
3203 if ((int)($state['pos'] ?? 0) >= $total) {
3204 $state['status'] = 'finished';
3205 $state['phase'] = 'media_done';
3206 $state['tasks'] = array();
3207 $current = '';
3208 }
3209
3210 $this->update_media_process_percent($state);
3211 $state['message'] = $this->media_process_message($state, $current);
3212 if (($state['status'] ?? '') === 'finished') $state['message'] = 'Fertig. ' . $state['message'];
3213 $state['updated_at'] = date('H:i:s');
3214 }
3215
3216 private function render_media_process(array $state, $next_url) {
3217 $status = (string)($state['status'] ?? 'running');
3218 $percent = max(0, min(100, (int)($state['percent'] ?? 0)));
3219 $step_percent = max(0, min(100, (int)($state['step_percent'] ?? $percent)));
3220 $status_labels = array(
3221 'running' => 'Laeuft',
3222 'paused' => 'Angehalten',
3223 'canceled' => 'Abgebrochen',
3224 'finished' => 'Fertig',
3225 'error' => 'Fehler',
3226 );
3227 $status_class = $status === 'finished' ? 'bg-success' : ($status === 'error' || $status === 'canceled' ? 'bg-danger' : ($status === 'paused' ? 'bg-warning text-dark' : 'bg-primary'));
3228 $status_icon = $status === 'finished' ? 'bi bi-check-lg' : ($status === 'error' || $status === 'canceled' ? 'bi bi-exclamation-triangle' : ($status === 'paused' ? 'bi bi-pause-fill' : 'bi bi-play-fill'));
3229 $token = (string)($state['proc_key'] ?? '');
3230 $restart_url = $this->append_url_params($next_url, array('reset' => 1, 'proc_cmd' => 'restart'));
3231 $cancel_url = $this->append_url_params($next_url, array('proc_cmd' => 'cancel'));
3232 $resume_url = $this->append_url_params($next_url, array('proc_cmd' => 'resume'));
3233 $pause_url = $this->append_url_params($next_url, array('proc_cmd' => 'pause'));
3234 $autostart = ($status === 'running' && $next_url !== '') ? 1 : 0;
3235 $target_id = 'dbx_cms_media_process_' . substr(md5($token ?: 'media'), 0, 14);
3236 $report = '<div class="dbx-cms-media-maintenance-report" aria-label="Ergebnis der Medienwartung">'
3237 . '<div><strong>' . (int)($state['usage_rows_analyzed'] ?? 0) . '</strong><span>DB-Zuordnungen geprueft</span></div>'
3238 . '<div><strong>' . (int)($state['actual_media_references'] ?? 0) . '</strong><span>Echte Verwendungen erkannt</span></div>'
3239 . '<div><strong>' . (int)($state['usage_rows_added'] ?? 0) . '</strong><span>Zuordnungen ergaenzt</span></div>'
3240 . '<div><strong>' . (int)($state['usage_rows_updated'] ?? 0) . '</strong><span>Zuordnungen korrigiert</span></div>'
3241 . '<div><strong>' . (int)($state['usage_rows_removed'] ?? 0) . '</strong><span>Alt-/Fehleintraege entfernt</span></div>'
3242 . '<div><strong>' . (int)($state['purged_media_records'] ?? 0) . '</strong><span>Medien-Datensaetze entfernt</span></div>'
3243 . '<div><strong>' . (int)($state['cleaned_structured_refs'] ?? 0) . '</strong><span>Defekte Inhaltsverweise entfernt</span></div>'
3244 . '<div><strong>' . (int)($state['database_optimized'] ?? 0) . '</strong><span>Datenbanken optimiert</span></div>'
3245 . '</div>';
3246
3247 return '<div id="' . dbx()->esc($target_id) . '"'
3248 . ' class="container-fluid dbx-process dbx-cms-media-process"'
3249 . ' data-dbx="lib=process|id=' . dbx()->esc($target_id) . '|url=' . dbx()->esc($next_url) . '|interval=900|autostart=' . $autostart . '"'
3250 . ' data-process-status="' . dbx()->esc($status) . '"'
3251 . ' data-process-percent="' . $percent . '"'
3252 . ' data-process-step-percent="' . $step_percent . '"'
3253 . ' data-process-next-url="' . dbx()->esc($next_url) . '"'
3254 . ' data-process-pause-url="' . dbx()->esc($pause_url) . '"'
3255 . ' data-process-resume-url="' . dbx()->esc($resume_url) . '"'
3256 . ' data-process-cancel-url="' . dbx()->esc($cancel_url) . '"'
3257 . ' data-process-restart-url="' . dbx()->esc($restart_url) . '"'
3258 . ' data-process-autostart="' . $autostart . '"'
3259 . ' data-process-interval="900">'
3260 . '<div class="dbx-process-header">'
3261 . '<h3 class="dbx-process-title">Medienwartung</h3>'
3262 . '<span class="dbx-process-status badge ' . dbx()->esc($status_class) . '"><i class="' . dbx()->esc($status_icon) . '"></i> ' . dbx()->esc($status_labels[$status] ?? $status) . '</span>'
3263 . '</div>'
3264 . '<div class="dbx-process-grid">'
3265 . '<div class="dbx-process-progress"><div class="dbx-process-progress-head"><span class="dbx-process-progress-label">Gesamt</span><span class="dbx-process-progress-value" data-process-percent="overall">' . $percent . '%</span></div><div class="dbx-process-bar" role="progressbar" aria-valuenow="' . $percent . '" aria-valuemin="0" aria-valuemax="100"><div class="dbx-process-bar-fill" data-process-bar="overall" style="width:' . $percent . '%;"></div></div></div>'
3266 . '<div class="dbx-process-progress"><div class="dbx-process-progress-head"><span class="dbx-process-progress-label">Medien, Zuordnungen und Datenbank</span><span class="dbx-process-progress-value" data-process-percent="step">' . $step_percent . '%</span></div><div class="dbx-process-bar" role="progressbar" aria-valuenow="' . $step_percent . '" aria-valuemin="0" aria-valuemax="100"><div class="dbx-process-bar-fill" data-process-bar="step" style="width:' . $step_percent . '%;"></div></div></div>'
3267 . '<div class="dbx-process-message" data-process-message>' . dbx()->esc((string)($state['message'] ?? '')) . '</div>'
3268 . '<div class="dbx-process-meta"><span>Eintraege: ' . (int)($state['pos'] ?? 0) . ' / ' . (int)($state['total'] ?? 0) . '</span><span>Aktualisiert: ' . dbx()->esc((string)($state['updated_at'] ?? '')) . '</span></div>'
3269 . '</div>'
3270 . $report
3271 . '<div class="dbx-process-actions">'
3272 . '<button type="button" class="btn btn-warning btn-sm" data-process-action="pause" data-process-visible="running" title="Anhalten"><i class="bi bi-pause-fill"></i></button>'
3273 . '<button type="button" class="btn btn-primary btn-sm" data-process-action="resume" data-process-visible="paused" title="Weiter"><i class="bi bi-play-fill"></i></button>'
3274 . '<button type="button" class="btn btn-secondary btn-sm" data-process-action="restart" data-process-visible="paused,canceled,error,finished" title="Neu starten"><i class="bi bi-arrow-clockwise"></i></button>'
3275 . '<button type="button" class="btn btn-danger btn-sm" data-process-action="cancel" data-process-visible="running,paused" title="Abbrechen"><i class="bi bi-x-lg"></i></button>'
3276 . '</div>'
3277 . '</div>';
3278 }
3279
3280 private function append_url_params($url, $params = array()) {
3281 foreach ($params as $key => $value) {
3282 $url .= (strpos($url, '?') === false ? '?' : '&') . rawurlencode((string)$key) . '=' . rawurlencode((string)$value);
3283 }
3284 return $url;
3285 }
3286
3287 private function media_process() {
3288 $db = dbx()->get_system_obj('dbxDB');
3289 $this->ensure_cms_schema($db);
3290
3291 $token = preg_replace('/[^A-Za-z0-9_-]+/', '', (string)($_GET['proc_key'] ?? ''));
3292 if ($token === '') $token = substr(md5(session_id() . microtime(true)), 0, 16);
3293 $reset = (int)($_GET['reset'] ?? 0);
3294 $cmd = strtolower(preg_replace('/[^a-z_]+/', '', (string)($_GET['proc_cmd'] ?? '')));
3295
3296 if ($reset || $cmd === 'restart') {
3297 $state = $this->build_media_process_state($db, $token);
3298 } else {
3299 $state = $this->get_media_process_state($token);
3300 if (empty($state)) $state = $this->build_media_process_state($db, $token);
3301 }
3302
3303 if ($cmd === 'pause' && ($state['status'] ?? '') === 'running') {
3304 $state['status'] = 'paused';
3305 $state['message'] = 'Medienwartung angehalten.';
3306 } elseif ($cmd === 'resume' && ($state['status'] ?? '') === 'paused') {
3307 $state['status'] = 'running';
3308 $state['message'] = 'Medienwartung fortgesetzt.';
3309 } elseif ($cmd === 'cancel') {
3310 $state['status'] = 'canceled';
3311 $state['message'] = 'Medienwartung abgebrochen.';
3312 }
3313
3314 $this->tick_media_process($db, $state);
3315 $this->set_media_process_state($token, $state);
3316
3317 $next = $this->base_url('cms_media_process', array('proc_key' => $token));
3318 return $this->render_media_process($state, $next);
3319 }
3320
3321 private function first_upload_file() {
3322 if (empty($_FILES) || !is_array($_FILES)) return array();
3323
3324 foreach ($_FILES as $file) {
3325 if (!is_array($file)) continue;
3326
3327 if (isset($file['tmp_name']) && !is_array($file['tmp_name'])) {
3328 return $file;
3329 }
3330
3331 if (!isset($file['tmp_name']) || !is_array($file['tmp_name'])) continue;
3332
3333 $idx = array_key_first($file['tmp_name']);
3334 if ($idx === null) continue;
3335
3336 return array(
3337 'name' => is_array($file['name'] ?? null) ? ($file['name'][$idx] ?? '') : ($file['name'] ?? ''),
3338 'type' => is_array($file['type'] ?? null) ? ($file['type'][$idx] ?? '') : ($file['type'] ?? ''),
3339 'tmp_name' => $file['tmp_name'][$idx] ?? '',
3340 'error' => is_array($file['error'] ?? null) ? ($file['error'][$idx] ?? UPLOAD_ERR_NO_FILE) : ($file['error'] ?? UPLOAD_ERR_NO_FILE),
3341 'size' => is_array($file['size'] ?? null) ? ($file['size'][$idx] ?? 0) : ($file['size'] ?? 0),
3342 );
3343 }
3344
3345 return array();
3346 }
3347
3348 private function load_gd_image($file, $mime) {
3349 if (!extension_loaded('gd')) return false;
3350 $mime = strtolower((string)$mime);
3351 if ((strpos($mime, 'jpeg') !== false || preg_match('/\.jpe?g$/i', $file)) && function_exists('imagecreatefromjpeg')) return @imagecreatefromjpeg($file);
3352 if ((strpos($mime, 'png') !== false || preg_match('/\.png$/i', $file)) && function_exists('imagecreatefrompng')) return @imagecreatefrompng($file);
3353 if (strpos($mime, 'webp') !== false || preg_match('/\.webp$/i', $file)) return function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($file) : false;
3354 if ((strpos($mime, 'gif') !== false || preg_match('/\.gif$/i', $file)) && function_exists('imagecreatefromgif')) return @imagecreatefromgif($file);
3355 return false;
3356 }
3357
3358 private function save_gd_image($img, $file, $mime) {
3359 if (!extension_loaded('gd')) return false;
3360 $mime = strtolower((string)$mime);
3361 if (strpos($mime, 'png') !== false || preg_match('/\.png$/i', $file)) {
3362 if (!function_exists('imagepng')) return false;
3363 imagealphablending($img, false);
3364 imagesavealpha($img, true);
3365 return @imagepng($img, $file, 6);
3366 }
3367 if ((strpos($mime, 'webp') !== false || preg_match('/\.webp$/i', $file)) && function_exists('imagewebp')) {
3368 return @imagewebp($img, $file, 86);
3369 }
3370 if (!function_exists('imagejpeg')) return false;
3371 return @imagejpeg($img, $file, 88);
3372 }
3373
3374 private function convert_media_image_to_webp($file, $name, $mime = '') {
3375 if (!extension_loaded('gd') || !function_exists('imagewebp')) return false;
3376 if ($file === '' || !is_file($file) || !is_readable($file)) return false;
3377 if (preg_match('/\.(webp|gif|svg)$/i', (string)$name)) return false;
3378
3379 $mime_l = strtolower((string)$mime);
3380 $ext = strtolower(pathinfo((string)$name, PATHINFO_EXTENSION));
3381 $is_supported = in_array($ext, array('jpg','jpeg','png'), true)
3382 || strpos($mime_l, 'jpeg') !== false
3383 || strpos($mime_l, 'png') !== false;
3384 if (!$is_supported) return false;
3385
3386 $image = $this->load_gd_image($file, $mime);
3387 if (!$image) return false;
3388
3389 imagepalettetotruecolor($image);
3390 imagealphablending($image, true);
3391 imagesavealpha($image, true);
3392
3393 $webp_name = preg_replace('/\.[^.]+$/', '', basename((string)$name)) . '.webp';
3394 $webp_file = rtrim(dirname($file), '/\\') . DIRECTORY_SEPARATOR . $webp_name;
3395 if (is_file($webp_file)) {
3396 $webp_name = $this->unique_media_name($webp_name);
3397 $webp_file = rtrim(dirname($file), '/\\') . DIRECTORY_SEPARATOR . $webp_name;
3398 }
3399
3400 $ok = @imagewebp($image, $webp_file, 86);
3401 imagedestroy($image);
3402 if (!$ok || !is_file($webp_file)) return false;
3403
3404 @unlink($file);
3405 return array(
3406 'file' => $webp_file,
3407 'name' => $webp_name,
3408 'mime' => 'image/webp',
3409 );
3410 }
3411
3412 private function resize_image_to_max($file, $mime, $max_long_side = 2560) {
3413 $max_long_side = max(800, min(6000, (int)$max_long_side));
3414 if ($file === '' || preg_match('/\.(svg|gif)$/i', (string)$file)) return false;
3415
3416 $size = @getimagesize($file);
3417 if (!is_array($size)) return false;
3418
3419 $src_w = (int)($size[0] ?? 0);
3420 $src_h = (int)($size[1] ?? 0);
3421 if ($src_w <= 0 || $src_h <= 0 || max($src_w, $src_h) <= $max_long_side) return false;
3422
3423 $op = array('action' => 'resize');
3424 if ($src_w >= $src_h) {
3425 $op['width'] = $max_long_side;
3426 $op['height'] = 0;
3427 } else {
3428 $op['width'] = 0;
3429 $op['height'] = $max_long_side;
3430 }
3431
3432 return $this->edit_image_file($file, $mime, $op);
3433 }
3434
3435 private function create_media_thumbnail($src, $slot, $name, $mime = '') {
3436 $type = $this->media_type(array('mime' => $mime, 'file_name' => $name));
3437 if ($type === 'video') return $this->create_video_thumbnail($src, $slot, $name);
3438 if ($type !== 'image' || preg_match('/\.svg$/i', (string)$name) || !function_exists('imagecreatetruecolor')) return array();
3439
3440 $image = $this->load_gd_image($src, $mime);
3441 if (!$image) return array();
3442
3443 $src_w = imagesx($image);
3444 $src_h = imagesy($image);
3445 if ($src_w <= 0 || $src_h <= 0) {
3446 imagedestroy($image);
3447 return array();
3448 }
3449
3450 $max_w = 640;
3451 $max_h = 480;
3452 $scale = min($max_w / $src_w, $max_h / $src_h, 1);
3453 $dst_w = max(1, (int)round($src_w * $scale));
3454 $dst_h = max(1, (int)round($src_h * $scale));
3455 $thumb = imagecreatetruecolor($dst_w, $dst_h);
3456 imagealphablending($thumb, false);
3457 imagesavealpha($thumb, true);
3458 imagefill($thumb, 0, 0, imagecolorallocatealpha($thumb, 255, 255, 255, 127));
3459 imagecopyresampled($thumb, $image, 0, 0, 0, 0, $dst_w, $dst_h, $src_w, $src_h);
3460
3461 $dir = $this->cms_media_thumb_dir($slot);
3462 if (!is_dir($dir)) @mkdir($dir, 0777, true);
3463 $thumb_ext = function_exists('imagewebp') ? 'webp' : 'jpg';
3464 $thumb_name = preg_replace('/\.[^.]+$/', '', basename((string)$name)) . '.' . $thumb_ext;
3465 $thumb_name = $this->unique_media_name($thumb_name, 'thumb');
3466 $dst = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $thumb_name;
3467 $ok = $thumb_ext === 'webp' ? @imagewebp($thumb, $dst, 82) : @imagejpeg($thumb, $dst, 82);
3468 imagedestroy($thumb);
3469 imagedestroy($image);
3470
3471 if (!$ok) return array();
3472 return array(
3473 'thumb_file_path' => $this->media_thumb_rel_dir($slot) . $thumb_name,
3474 'thumb_width' => $dst_w,
3475 'thumb_height' => $dst_h,
3476 );
3477 }
3478
3479 private function find_executable_path(string $command): string {
3480 static $cache = array();
3481
3482 $command = trim($command);
3483 if ($command === '' || !preg_match('/^[a-zA-Z0-9_.-]+$/', $command)) {
3484 return '';
3485 }
3486
3487 if (array_key_exists($command, $cache)) {
3488 return $cache[$command];
3489 }
3490
3491 $lookup = stripos(PHP_OS_FAMILY, 'Windows') === 0
3492 ? 'where ' . $command . ' 2>NUL'
3493 : 'command -v ' . $command . ' 2>/dev/null';
3494 $result = trim((string)@shell_exec($lookup));
3495 $path = $result !== '' ? strtok($result, "\r\n") : '';
3496 $cache[$command] = is_string($path) ? trim($path) : '';
3497
3498 return $cache[$command];
3499 }
3500
3501 private function create_video_thumbnail($src, $slot, $name) {
3502 $ffmpeg = $this->find_executable_path('ffmpeg');
3503
3504 $dir = $this->cms_media_thumb_dir($slot);
3505 if (!is_dir($dir)) @mkdir($dir, 0777, true);
3506 $thumb_name = $this->unique_media_name(preg_replace('/\.[^.]+$/', '', basename((string)$name)) . '.jpg', 'video-thumb');
3507 $dst = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $thumb_name;
3508
3509 if ($ffmpeg !== '') {
3510 $cmd = '"' . $ffmpeg . '" -y -ss 00:00:01 -i "' . $src . '" -frames:v 1 -vf "scale=640:-1" "' . $dst . '" 2>NUL';
3511 @shell_exec($cmd);
3512 }
3513
3514 if (!is_file($dst) || !filesize($dst)) {
3515 $this->create_video_placeholder_thumbnail($dst, $name);
3516 }
3517
3518 if (!is_file($dst) || !filesize($dst)) return array();
3519 $size = @getimagesize($dst);
3520 return array(
3521 'thumb_file_path' => $this->media_thumb_rel_dir($slot) . $thumb_name,
3522 'thumb_width' => is_array($size) ? (int)$size[0] : 0,
3523 'thumb_height' => is_array($size) ? (int)$size[1] : 0,
3524 );
3525 }
3526
3527 private function create_video_placeholder_thumbnail($dst, $name) {
3528 if (!function_exists('imagecreatetruecolor')) return false;
3529 $w = 640;
3530 $h = 360;
3531 $img = imagecreatetruecolor($w, $h);
3532 $bg = imagecolorallocate($img, 30, 43, 60);
3533 $panel = imagecolorallocate($img, 52, 72, 96);
3534 $white = imagecolorallocate($img, 255, 255, 255);
3535 $muted = imagecolorallocate($img, 188, 202, 220);
3536 imagefill($img, 0, 0, $bg);
3537 imagefilledrectangle($img, 20, 20, $w - 20, $h - 20, $panel);
3538 $cx = (int)($w / 2);
3539 $cy = (int)($h / 2) - 12;
3540 imagefilledpolygon($img, array($cx - 38, $cy - 48, $cx - 38, $cy + 48, $cx + 52, $cy), 3, $white);
3541 $label = substr(basename((string)$name), 0, 54);
3542 imagestring($img, 3, 28, $h - 42, $label, $muted);
3543 $ok = @imagejpeg($img, $dst, 82);
3544 imagedestroy($img);
3545 return $ok;
3546 }
3547
3548 private function source_thumb_file($row) {
3549 $rel = ltrim(str_replace('\\', '/', (string)($row['thumb_file_path'] ?? '')), '/');
3550 if ($rel === '' || strpos($rel, '..') !== false) return '';
3551 $file = rtrim(dbx()->get_file_dir(), '/\\') . '/' . $rel;
3552 $file = dbx()->os_path($file);
3553 $base = realpath(rtrim(dbx()->get_file_dir(), '/\\'));
3554 $real = realpath($file);
3555 if (!$base || !$real || strpos($real, $base) !== 0 || !is_file($real) || !is_readable($real)) return '';
3556 return $real;
3557 }
3558
3559 private function render_cms() {
3560 $this->clear_cms_tpl_cache();
3561 $oTPL = dbx()->get_system_obj('dbxTPL');
3562 $db = dbx()->get_system_obj('dbxDB');
3563 $this->ensure_cms_schema($db);
3564 $texts = $this->cms_texts();
3565 // Der eigentliche Content-Baum wird ueber cms_tree erst auf Anforderung
3566 // geladen. Fuer die Kopfleiste reichen drei kleine COUNT-Abfragen.
3567 $page_count = (int)$db->count(dbxContentLng::ddContent(), '');
3568 $folder_count = (int)$db->count(dbxContentLng::ddFolder(), '');
3569 $active_count = $db->count(dbxContentLng::ddContent(), 'activ = 1');
3570 if ($page_count < 0) $page_count = 0;
3571 if ($folder_count < 0) $folder_count = 0;
3572 if ($active_count < 0) $active_count = 0;
3573
3574 $cms_cid = $this->resolve_cms_page_id();
3575 $cms_fid = (int)dbx()->get_modul_var('fid', 0, 'int');
3576 if ($cms_fid <= 0) {
3577 $cms_fid = (int)dbx()->get_modul_var('dbx_fid', 0, 'int');
3578 }
3579
3580 $data = array(
3581 'i' => dbx()->next_id(),
3582 'cms_cid' => (string)$cms_cid,
3583 'cms_fid' => (string)$cms_fid,
3584 'title' => $texts->get_fd_message('bar_title'),
3585 'subtitle' => $texts->get_fd_message('bar_subtitle'),
3586 'bar_title' => $texts->get_fd_message('bar_title'),
3587 'bar_subtitle' => $texts->get_fd_message('bar_subtitle'),
3588 'right_panel_hint' => $texts->get_fd_message('right_panel_hint'),
3589 'inline_media_title' => $texts->get_fd_message('inline_media_title'),
3590 'inline_media_assign_title' => $texts->get_fd_message('inline_media_assign_title'),
3591 'media_inline_empty' => $texts->get_fd_message('media_inline_empty'),
3592 'selection_label' => $texts->get_fd_message('selection_label'),
3593 'right_show' => $texts->get_fd_message('right_show'),
3594 'right_hide' => $texts->get_fd_message('right_hide'),
3595 'cms_messages' => dbx()->esc($this->cms_js_messages()),
3596 'count_pages' => (string)$page_count,
3597 'count_folders' => (string)$folder_count,
3598 'count_active' => (string)$active_count,
3599 'tree_url' => dbx()->esc($this->base_url('cms_tree')),
3600 'page_url' => dbx()->esc($this->base_url('cms_page')),
3601 'save_url' => dbx()->esc($this->base_url('cms_save')),
3602 'delete_page_url' => dbx()->esc($this->base_url('cms_delete_page')),
3603 'new_page_url' => dbx()->esc($this->base_url('cms_new_page')),
3604 'duplicate_page_url' => dbx()->esc($this->base_url('cms_duplicate_page')),
3605 'new_folder_url' => dbx()->esc($this->base_url('cms_new_folder')),
3606 'media_url' => dbx()->esc($this->base_url('cms_media')),
3607 'media_process_url' => dbx()->esc($this->base_url('cms_media_process')),
3608 'media_folders_url' => dbx()->esc($this->base_url('cms_media_folders')),
3609 'media_folder_create_url' => dbx()->esc($this->base_url('cms_media_folder_create')),
3610 'media_folder_delete_url' => dbx()->esc($this->base_url('cms_media_folder_delete')),
3611 'media_folder_rename_url' => dbx()->esc($this->base_url('cms_media_folder_rename')),
3612 'media_move_url' => dbx()->esc($this->base_url('cms_media_move')),
3613 'media_unused_url' => dbx()->esc($this->base_url('cms_media_unused')),
3614 'upload_url' => dbx()->esc($this->base_url('cms_upload')),
3615 'external_video_url' => dbx()->esc($this->base_url('cms_external_video')),
3616 'upload_max_bytes' => (string)$this->upload_max_bytes(),
3617 'remove_media_url' => dbx()->esc($this->base_url('cms_remove_media')),
3618 'delete_media_url' => dbx()->esc($this->base_url('cms_delete_media')),
3619 'edit_media_url' => dbx()->esc($this->base_url('cms_edit_media')),
3620 'set_media_slot_url' => dbx()->esc($this->base_url('cms_set_media_slot')),
3621 'assign_media_url' => dbx()->esc($this->base_url('cms_assign_media')),
3622 'sort_media_url' => dbx()->esc($this->base_url('cms_sort_media')),
3623 'mod_catalog_url' => dbx()->esc($this->base_url('cms_mod_catalog')),
3624 'mod_modules_url' => dbx()->esc($this->base_url('cms_mod_modules')),
3625 'folder_save_url' => dbx()->esc($this->base_url('cms_save_folder')),
3626 'folder_delete_url' => dbx()->esc($this->base_url('cms_delete_folder')),
3627 'move_node_url' => dbx()->esc($this->base_url('cms_move_node')),
3628 'lng_coverage_url' => dbx()->esc($this->base_url('cms_lng_coverage')),
3629 'lng_preview_url' => dbx()->esc($this->base_url('cms_lng_preview')),
3630 'lng_provision_url' => dbx()->esc($this->base_url('cms_lng_provision')),
3631 'lng_provision_tree_url' => dbx()->esc($this->base_url('cms_lng_provision_tree')),
3632 'lng_reset_sync_url' => dbx()->esc($this->base_url('cms_lng_reset_sync')),
3633 'lng_delete_preview_url' => dbx()->esc($this->base_url('cms_lng_delete_preview')),
3634 'current_lng' => dbxContentLng::current(),
3635 'master_lng' => dbxContentLngSync::masterLng(),
3636 'lng_bar' => dbxContentLngSync::renderLngBar(),
3637 'template_options' => $this->template_options(),
3638 'rights_options' => $this->rights_options(),
3639 'media_template_options' => $this->media_template_options(),
3640 'hero_template_options' => $this->hero_template_options(),
3641 'hero_variant_options' => $this->hero_variant_options(),
3642 'gallery_template_options' => $this->gallery_template_options(),
3643 'gallery_overflow_options' => $this->gallery_overflow_options(),
3644 'gallery_click_options' => $this->gallery_click_options(),
3645 'page_form' => $this->render_page_form(),
3646 'folder_form' => $this->render_folder_form(),
3647 'settings_form' => $this->render_settings_form(),
3648 'media_browser_forms' => $this->media_forms()->renderTemplates(
3649 $this->base_url('cms_upload'),
3650 'cms-media-upload',
3651 $this->base_url('cms_external_video'),
3652 'cms-external-video'
3653 ),
3654 'dbx_search' => $oTPL->get_tpl('dbx|search', dbx()->search_defaults(array(
3655 'title' => $texts->get_fd_message('tree_search'),
3656 'extra_attrs' => 'data-cms-search',
3657 'data_role' => '',
3658 ))),
3659 );
3660
3661 $data = array_merge(
3662 dbx()->get_include_obj('dbxAdminHelp', 'dbxAdmin')->vars('content'),
3663 $data
3664 );
3665 $data['bar_class'] = 'dbx-module-bar dbx-cms-head';
3666 $data['bar_title_class'] = 'dbx-module-bar-titleblock';
3667 $data['bar_actions_class'] = 'dbx-module-bar-actions flex-wrap';
3668 if (trim((string)($data['bar_subtitle'] ?? '')) === '' && trim((string)($data['subtitle'] ?? '')) !== '') {
3669 $data['bar_subtitle'] = (string)$data['subtitle'];
3670 }
3671
3672 return $oTPL->get_tpl('dbxContent_admin|cms-admin', $data);
3673 }
3674
3675 private function mod_class_files($modul) {
3676 $modul = preg_replace('/[^A-Za-z0-9_]+/', '', (string)$modul);
3677 if ($modul === '') return array();
3678 $base = dbx()->os_path(dbx()->get_base_dir() . 'dbx/modules/' . $modul . '/');
3679 $files = array();
3680 $main = $base . $modul . '.class.php';
3681 if (is_file($main)) $files[] = $main;
3682 $inc = $base . 'include' . DIRECTORY_SEPARATOR;
3683 if (is_dir($inc)) {
3684 foreach (glob($inc . '*.class.php') ?: array() as $path) {
3685 if (is_file($path)) $files[] = $path;
3686 }
3687 }
3688 return $files;
3689 }
3690
3691 private function mod_scan_runs($modul) {
3692 $run1 = array();
3693 $run2 = array();
3694 $uses_run2 = false;
3695 foreach ($this->mod_class_files($modul) as $file) {
3696 $src = @file_get_contents($file);
3697 if (!is_string($src) || $src === '') continue;
3698 if (preg_match("/get_modul_var\s*\‍(\s*['\"]dbx_run2['\"]/", $src)) {
3699 $uses_run2 = true;
3700 }
3701 if (preg_match_all("/case\s+['\"]([^'\"]+)['\"]\s*:/", $src, $matches)) {
3702 foreach ($matches[1] as $case) {
3703 $case = trim((string)$case);
3704 if ($case === '' || $case === 'default') continue;
3705 $run1[$case] = true;
3706 }
3707 }
3708 if (preg_match_all('/\$(?:run|action|work)\s*===?\s*[\'"]([^\'"]+)[\'"]/', $src, $ifs)) {
3709 foreach ($ifs[1] as $case) {
3710 $case = trim((string)$case);
3711 if ($case === '') continue;
3712 $run1[$case] = true;
3713 }
3714 }
3715 if (preg_match_all("/get_modul_var\s*\‍(\s*['\"]dbx_run1['\"][^)]*['\"]([a-zA-Z0-9_]+)['\"]/", $src, $defaults)) {
3716 foreach ($defaults[1] as $case) {
3717 $case = trim((string)$case);
3718 if ($case === '' || $case === 'parameter') continue;
3719 $run1[$case] = true;
3720 }
3721 }
3722 if (preg_match_all("/get_modul_var\s*\‍(\s*['\"]dbx_run2['\"][^)]*['\"]([^'\"]+)['\"]/", $src, $defaults)) {
3723 foreach ($defaults[1] as $val) {
3724 $val = trim((string)$val);
3725 if ($val !== '') $run2[$val] = true;
3726 }
3727 }
3728 }
3729 $run1_list = array_keys($run1);
3730 usort($run1_list, function($a, $b) {
3731 return strlen($b) <=> strlen($a);
3732 });
3733 return array(
3734 'run1' => array_values(array_keys($run1)),
3735 'run2' => array_values(array_keys($run2)),
3736 'uses_run2' => $uses_run2,
3737 'run1_sorted' => $run1_list,
3738 );
3739 }
3740
3741 private function mod_modules_json() {
3742 $base = dbx()->os_path(dbx()->get_base_dir() . 'dbx/modules/dbxAdmin/include/');
3743 require_once $base . 'dbxModuleImages.class.php';
3744 $images = new \dbx\dbxAdmin\dbxModuleImages();
3745
3746 $dir = dbx()->os_path(dbx()->get_base_dir() . 'dbx/modules/');
3747 $items = array();
3748 if (is_dir($dir)) {
3749 foreach (glob($dir . '*', GLOB_ONLYDIR) ?: array() as $path) {
3750 $name = basename(str_replace('\\', '/', $path));
3751 if (!preg_match('/^[A-Za-z][A-Za-z0-9_]*$/', $name)) continue;
3752 $class_file = dbx()->os_path($path . DIRECTORY_SEPARATOR . $name . '.class.php');
3753 if (!is_file($class_file)) continue;
3754 $scan = $this->mod_scan_runs($name);
3755 $count = $images->imageCount($name);
3756 if ($count <= 0) {
3757 continue;
3758 }
3759 $items[] = array(
3760 'id' => $name,
3761 'label' => $name,
3762 'image_count' => $count,
3763 'uses_run2' => !empty($scan['uses_run2']) ? 1 : 0,
3764 'run1_actions' => array_slice($scan['run1'], 0, 24),
3765 );
3766 }
3767 }
3768 usort($items, function($a, $b) {
3769 return strcmp((string)($a['label'] ?? ''), (string)($b['label'] ?? ''));
3770 });
3771 $this->cms_json_response(array('ok' => 1, 'items' => $items));
3772 }
3773
3774 private function mod_catalog_json() {
3775 $base = dbx()->os_path(dbx()->get_base_dir() . 'dbx/modules/dbxAdmin/include/');
3776 require_once $base . 'dbxModuleImages.class.php';
3777 $images = new \dbx\dbxAdmin\dbxModuleImages();
3778
3779 $modul = preg_replace('/[^A-Za-z0-9_]+/', '', (string)dbx()->get_modul_var('modul', '', 'parameter'));
3780 if ($modul === '') {
3781 $this->cms_json_response(array('ok' => 0, 'msg' => 'Modul fehlt.', 'items' => array()));
3782 return;
3783 }
3784 $class_file = dbx()->os_path(dbx()->get_base_dir() . 'dbx/modules/' . $modul . '/' . $modul . '.class.php');
3785 if (!is_file($class_file)) {
3786 $this->cms_json_response(array('ok' => 0, 'msg' => 'Modul nicht gefunden.', 'items' => array()));
3787 return;
3788 }
3789
3790 $out = $images->catalogForModul($modul);
3791 $scan = $this->mod_scan_runs($modul);
3792
3793 $this->cms_json_response(array(
3794 'ok' => 1,
3795 'modul' => $modul,
3796 'uses_run2' => !empty($scan['uses_run2']) ? 1 : 0,
3797 'run1_actions' => $scan['run1'],
3798 'items' => is_array($out) ? $out : array(),
3799 ));
3800 }
3801
3802 private function tree_json() {
3803 $this->cms_json_response(array('ok' => 1) + $this->cms_tree());
3804 }
3805
3806 private function lng_coverage_json() {
3807 $type = trim((string) dbx()->get_modul_var('type', 'page'));
3808 $type = $type === 'folder' ? 'folder' : 'page';
3809 $id = (int) dbx()->get_modul_var('id', 0, 'int');
3810 $lngUid = trim((string) dbx()->get_modul_var('lng_uid', ''));
3811 $db = dbx()->get_system_obj('dbxDB');
3812 $this->ensure_cms_schema($db);
3813
3814 if ($lngUid === '' && $id > 0) {
3815 $dd = $type === 'folder' ? dbxContentLng::ddFolder() : dbxContentLng::ddContent();
3816 $lngUid = dbxContentLngSync::ensureRecordUid($db, $dd, $id, $type === 'folder' ? 'f' : 'p');
3817 }
3818
3819 $coverage = dbxContentLngSync::coverageForUid($db, $type, $lngUid);
3820 $this->cms_json_response(array('ok' => 1, 'coverage' => $coverage));
3821 }
3822
3823 private function lng_preview_json() {
3824 if (!dbxContentLngSync::isMasterLng()) {
3825 $this->cms_json_response(array('ok' => 0, 'msg' => 'Vorschau nur in der Master-Sprache moeglich.'));
3826 }
3827
3828 $payload = $this->request_json();
3829 $type = (($payload['type'] ?? 'page') === 'folder') ? 'folder' : 'page';
3830 $id = (int) ($payload['id'] ?? 0);
3831 $lngs = is_array($payload['lngs'] ?? null) ? $payload['lngs'] : array();
3832 $db = dbx()->get_system_obj('dbxDB');
3833 $this->ensure_cms_schema($db);
3834
3835 dbxContentTranslate::clearWarnings();
3836 $preview = dbxContentLngSync::previewProvision($db, $type, $id, $lngs);
3837 $this->cms_json_response(array(
3838 'ok' => 1,
3839 'preview' => $preview,
3840 'provider' => dbxContentTranslate::provider(),
3841 'translate_warnings' => dbxContentTranslate::warnings(),
3842 ));
3843 }
3844
3845 private function lng_provision_json() {
3846 if (!dbxContentLngSync::isMasterLng()) {
3847 $this->cms_json_response(array('ok' => 0, 'msg' => 'Uebertragung nur in der Master-Sprache moeglich.'));
3848 }
3849
3850 $payload = $this->request_json();
3851 $type = (($payload['type'] ?? 'page') === 'folder') ? 'folder' : 'page';
3852 $id = (int) ($payload['id'] ?? 0);
3853 $items = is_array($payload['items'] ?? null) ? $payload['items'] : array();
3854 $db = dbx()->get_system_obj('dbxDB');
3855 $this->ensure_cms_schema($db);
3856
3857 dbxContentTranslate::clearWarnings();
3858 $result = dbxContentLngSync::provisionFromPreview($db, $type, $id, $items);
3859 if ((int) ($result['ok'] ?? 0) === 1) {
3860 $result['media_copied'] = $this->apply_lng_provision_media($db, $type, $id, $result);
3861 $this->flush_menu_cache();
3862 }
3863 $result['translate_warnings'] = dbxContentTranslate::warnings();
3864 $this->cms_json_response(array('ok' => (int) ($result['ok'] ?? 0), 'result' => $result));
3865 }
3866
3867 private function lng_provision_tree_json() {
3868 if (!dbxContentLngSync::isMasterLng()) {
3869 $this->cms_json_response(array('ok' => 0, 'msg' => 'Unterbaum-Uebertragung nur in der Master-Sprache moeglich.'));
3870 }
3871
3872 $payload = $this->request_json();
3873 $id = (int) ($payload['id'] ?? 0);
3874 $lngs = is_array($payload['lngs'] ?? null) ? $payload['lngs'] : array();
3875 if ($id <= 0) {
3876 $this->cms_json_response(array('ok' => 0, 'msg' => 'Kein Ordner gewaehlt.'));
3877 }
3878
3879 $db = dbx()->get_system_obj('dbxDB');
3880 $this->ensure_cms_schema($db);
3881 dbxContentTranslate::clearWarnings();
3882 $result = dbxContentLngSync::provisionFolderTree($db, $id, $lngs);
3883 if ((int) ($result['ok'] ?? 0) === 1) {
3884 $mediaCopied = 0;
3885 foreach (is_array($result['pages'] ?? null) ? $result['pages'] : array() as $pageItem) {
3886 if (!is_array($pageItem)) {
3887 continue;
3888 }
3889 $masterPageId = (int) ($pageItem['master_id'] ?? 0);
3890 $prov = is_array($pageItem['result'] ?? null) ? $pageItem['result'] : array();
3891 if ($masterPageId > 0) {
3892 $mediaCopied += $this->apply_lng_provision_media($db, 'page', $masterPageId, $prov);
3893 }
3894 }
3895 $result['media_copied'] = $mediaCopied;
3896 $this->flush_menu_cache();
3897 }
3898 $result['translate_warnings'] = dbxContentTranslate::warnings();
3899 $this->cms_json_response(array('ok' => (int) ($result['ok'] ?? 0), 'result' => $result));
3900 }
3901
3902 private function lng_reset_sync_json() {
3903 if (!dbxContentLngSync::isMasterLng()) {
3904 $this->cms_json_response(array('ok' => 0, 'msg' => 'Auto-Sync nur in der Master-Sprache setzbar.'));
3905 }
3906
3907 $payload = $this->request_json();
3908 $type = (($payload['type'] ?? 'page') === 'folder') ? 'folder' : 'page';
3909 $id = (int) ($payload['id'] ?? 0);
3910 $lngs = is_array($payload['lngs'] ?? null) ? $payload['lngs'] : array();
3911 if ($id <= 0) {
3912 $this->cms_json_response(array('ok' => 0, 'msg' => 'Kein Datensatz gewaehlt.'));
3913 }
3914
3915 $db = dbx()->get_system_obj('dbxDB');
3916 $this->ensure_cms_schema($db);
3917 $result = dbxContentLngSync::resetSyncToAuto($db, $type, $id, $lngs);
3918 $this->cms_json_response(array('ok' => count($result['updated'] ?? array()) ? 1 : 0, 'result' => $result));
3919 }
3920
3921 private function lng_delete_preview_json() {
3922 if (!dbxContentLngSync::isMasterLng()) {
3923 $this->cms_json_response(array('ok' => 0, 'msg' => 'Mehrsprachige Loesch-Vorschau nur in der Master-Sprache moeglich.'));
3924 }
3925
3926 $payload = $this->request_json();
3927 $type = (($payload['type'] ?? 'page') === 'folder') ? 'folder' : 'page';
3928 $id = (int) ($payload['id'] ?? 0);
3929 if ($id <= 0) {
3930 $this->cms_json_response(array('ok' => 0, 'msg' => 'Kein Datensatz gewaehlt.'));
3931 }
3932
3933 $db = dbx()->get_system_obj('dbxDB');
3934 $this->ensure_cms_schema($db);
3935 $preview = dbxContentLngSync::previewDelete($db, $type, $id);
3936 $this->cms_json_response(array('ok' => 1, 'preview' => $preview));
3937 }
3938
3939 private function normalize_delete_lngs(array $payload): array {
3940 $current = dbxContentLng::current();
3941 $raw = $payload['delete_lngs'] ?? null;
3942 if (!is_array($raw) || !count($raw)) {
3943 return array($current);
3944 }
3945 if (!dbxContentLngSync::isMasterLng()) {
3946 return array($current);
3947 }
3948
3949 $allowed = dbxContentLngSync::accessibleLngs();
3950 $out = array();
3951 foreach ($raw as $lng) {
3952 $lng = strtolower(trim((string) $lng));
3953 if ($lng !== '' && in_array($lng, $allowed, true) && !in_array($lng, $out, true)) {
3954 $out[] = $lng;
3955 }
3956 }
3957
3958 return count($out) ? $out : array($current);
3959 }
3960
3961 private function delete_page_in_lngs($db, int $id, array $deleteLngs): array {
3962 $targets = dbxContentLngSync::resolveDeleteIds($db, 'page', $id, $deleteLngs);
3963 $deleted = array();
3964 $errors = array();
3965
3966 foreach ($targets as $target) {
3967 if (!is_array($target)) {
3968 continue;
3969 }
3970 $lng = (string) ($target['lng'] ?? '');
3971 $targetId = (int) ($target['id'] ?? 0);
3972 if ($targetId <= 0) {
3973 continue;
3974 }
3975
3976 $targetDd = dbxContentLng::ddContent($lng);
3977 $ok = $db->delete($targetDd, 'id = ' . $targetId, 1, 0);
3978 if ($ok >= 0) {
3979 $db->update($this->dd_media, array('active' => 1, 'content_id' => 0, 'folder_id' => 0), 'content_id = ' . $targetId, 0, 1, 1, 0);
3980 $db->update(
3981 $this->dd_media_usage,
3982 array('active' => 0),
3983 dbxContentMediaUsageScope::withLanguage('content_id = ' . $targetId . ' AND active = 1', $lng),
3984 0,
3985 1,
3986 1,
3987 0
3988 );
3989 $this->flush_deleted_page_cache($targetId, $lng);
3990 $deleted[] = array('lng' => $lng, 'id' => $targetId);
3991 } else {
3992 $errors[] = 'Loeschen in ' . strtoupper($lng) . ' fehlgeschlagen.';
3993 }
3994 }
3995
3996 return array(
3997 'ok' => count($deleted) ? 1 : 0,
3998 'deleted' => $deleted,
3999 'errors' => $errors,
4000 );
4001 }
4002
4003 public function delete_page_record(int $id): array {
4004 if ($id <= 0) {
4005 return array('ok' => 0, 'deleted' => array(), 'errors' => array('Keine Seite gewaehlt.'));
4006 }
4007
4008 $db = dbx()->get_system_obj('dbxDB');
4009 $this->ensure_cms_schema($db);
4010 $result = $this->delete_page_in_lngs($db, $id, array(dbxContentLng::current()));
4011
4012 if ((int)($result['ok'] ?? 0) === 1) {
4013 $this->flush_menu_cache();
4014 }
4015
4016 return $result;
4017 }
4018
4019 public function delete_folder_record(int $id): array {
4020 if ($id <= 0) {
4021 return array('ok' => 0, 'deleted' => array(), 'errors' => array('Kein Ordner gewaehlt.'));
4022 }
4023
4024 $db = dbx()->get_system_obj('dbxDB');
4025 $this->ensure_cms_schema($db);
4026 $result = $this->delete_folder_in_lngs($db, $id, array(dbxContentLng::current()));
4027
4028 if ((int)($result['ok'] ?? 0) === 1) {
4029 $this->flush_menu_cache();
4030 }
4031
4032 return $result;
4033 }
4034
4035 public function delete_media_record(int $id): array {
4036 if ($id <= 0) {
4037 return array('ok' => 0, 'errors' => array('Keine Medien-ID erhalten.'));
4038 }
4039
4040 $db = dbx()->get_system_obj('dbxDB');
4041 $this->ensure_cms_schema($db);
4042 $used = $db->select(
4043 $this->dd_media_usage,
4044 'media_id = ' . $id . ' AND active = 1',
4045 'id',
4046 'id',
4047 'ASC',
4048 '',
4049 1,
4050 0,
4051 0
4052 );
4053 if (is_array($used) && isset($used[0])) {
4054 return array('ok' => 0, 'errors' => array('Medium wird noch verwendet.'));
4055 }
4056
4057 foreach (dbxContentLngSync::accessibleLngs() as $lng) {
4058 $contentDd = dbxContentLng::ddContent((string)$lng);
4059 $folderDd = dbxContentLng::ddFolder((string)$lng);
4060 if ($db->count($contentDd, 'hero_image_id = ' . $id) > 0 || $db->count($folderDd, 'hero_image_id = ' . $id) > 0) {
4061 return array('ok' => 0, 'errors' => array('Medium wird noch verwendet.'));
4062 }
4063 $pages = $db->select($contentDd, '', 'content', 'id', 'ASC', '', 0, 0, 0);
4064 foreach (is_array($pages) ? $pages : array() as $page) {
4065 if (preg_match('/data-cms-media-id=["\']?' . $id . '(?:["\'\s>]|$)/i', (string)($page['content'] ?? ''))) {
4066 return array('ok' => 0, 'errors' => array('Medium wird noch verwendet.'));
4067 }
4068 }
4069 }
4070
4071 $row = $db->select1($this->dd_media, $id);
4072 if (!is_array($row) || (int)($row['active'] ?? 0) !== 1) {
4073 return array('ok' => 0, 'errors' => array('Medium nicht gefunden.'));
4074 }
4075
4076 if (strtolower((string)($row['storage_type'] ?? 'local')) === 'local') {
4077 $file = $this->source_media_file($row);
4078 if ($file !== '') {
4079 @unlink($file);
4080 }
4081 } else {
4082 $file = $this->source_media_file($row);
4083 if ($file !== '' && preg_match('/\.json$/i', $file)) {
4084 @unlink($file);
4085 }
4086 }
4087 $thumb = $this->source_thumb_file($row);
4088 if ($thumb !== '') {
4089 @unlink($thumb);
4090 }
4091
4092 $ok = $db->update($this->dd_media, array('active' => 0), $id);
4093 return array(
4094 'ok' => $ok >= 0 ? 1 : 0,
4095 'errors' => $ok >= 0 ? array() : array('Medium konnte nicht geloescht werden.'),
4096 );
4097 }
4098
4099 private function delete_folder_in_lngs($db, int $id, array $deleteLngs): array {
4100 $targets = dbxContentLngSync::resolveDeleteIds($db, 'folder', $id, $deleteLngs);
4101 $deleted = array();
4102 $errors = array();
4103
4104 foreach ($targets as $target) {
4105 if (!is_array($target)) {
4106 continue;
4107 }
4108 $lng = (string) ($target['lng'] ?? '');
4109 $targetId = (int) ($target['id'] ?? 0);
4110 if ($targetId <= 0) {
4111 continue;
4112 }
4113
4114 $check = dbxContentLngSync::folderDeletable($db, $lng, $targetId);
4115 if ((int) ($check['deletable'] ?? 0) !== 1) {
4116 $reason = trim((string) ($check['reason'] ?? ''));
4117 $errors[] = strtoupper($lng) . ($reason !== '' ? ': ' . $reason : ': Ordner kann nicht geloescht werden.');
4118 continue;
4119 }
4120
4121 $targetDd = dbxContentLng::ddFolder($lng);
4122 $ok = $db->delete($targetDd, 'id = ' . $targetId, 1, 0);
4123 if ($ok >= 0) {
4124 $db->update(
4125 $this->dd_media_usage,
4126 array('active' => 0),
4127 dbxContentMediaUsageScope::withLanguage('folder_id = ' . $targetId . ' AND content_id = 0 AND active = 1', $lng),
4128 0,
4129 1,
4130 1,
4131 0
4132 );
4133 $this->flush_deleted_folder_cache($db, $targetId, $lng);
4134 $deleted[] = array('lng' => $lng, 'id' => $targetId);
4135 } else {
4136 $errors[] = 'Loeschen in ' . strtoupper($lng) . ' fehlgeschlagen.';
4137 }
4138 }
4139
4140 return array(
4141 'ok' => count($deleted) ? 1 : 0,
4142 'deleted' => $deleted,
4143 'errors' => $errors,
4144 );
4145 }
4146
4147 private function page_json() {
4148 $id = (int)dbx()->get_modul_var('id', 0, 'int');
4149 if ($id <= 0) {
4150 $id = $this->resolve_cms_page_id();
4151 }
4152 $db = dbx()->get_system_obj('dbxDB');
4153 $this->ensure_cms_schema($db);
4154 $row = $id > 0 ? $db->select1(dbxContentLng::ddContent(), $id) : $db->empty_record(dbxContentLng::ddContent())[0];
4155 if (is_array($row) && isset($row['content'])) {
4156 $row['content'] = $this->normalize_content_media_urls($row['content']);
4157 }
4158 if (is_array($row)) {
4159 $row = $this->normalize_gallery_row($row);
4160 }
4161 if ($id > 0 && is_array($row)) {
4162 $this->sync_inline_media_usage($db, $id, (string)($row['content'] ?? ''), (int)($row['folder'] ?? 0));
4163 }
4164 $media = $id > 0 ? $this->media_usage_rows_for_context($db, $id, 0) : array();
4165 $this->cms_json_response(array(
4166 'ok' => 1,
4167 'row' => $row,
4168 'media' => $media,
4169 'hero_preview_media' => $this->hero_preview_media($db, $row),
4170 'hero_parent_preview_media' => $this->inherited_hero_preview_media($db, (int)($row['folder'] ?? 0)),
4171 'seo_preview_media' => $this->seo_preview_media($db, $row),
4172 ));
4173 }
4174
4175 private function hero_preview_media($db, $row) {
4176 if (!is_array($row)) return array();
4177 $hero_id = (int)($row['hero_image_id'] ?? 0);
4178 if ($hero_id <= 0) return array();
4179 $hero = $db->select1($this->dd_media, $hero_id);
4180 if (!is_array($hero) || (int)($hero['active'] ?? 0) !== 1) return array();
4181 if (!$this->media_file_exists($hero)) return array();
4182 return $this->normalize_media_row($hero);
4183 }
4184
4185 private function inherited_hero_preview_media($db, $folder_id) {
4186 $folder_id = (int)$folder_id;
4187 $seen = array();
4188 while ($folder_id > 0 && !isset($seen[$folder_id])) {
4189 $seen[$folder_id] = 1;
4190 $folder = $db->select1(dbxContentLng::ddFolder(), $folder_id, '*', 0);
4191 if (!is_array($folder)) return array();
4192 $hero_template = trim((string)($folder['hero_template'] ?? 'parent'));
4193 if ($this->is_no_hero_template($hero_template)) return array();
4194 $hero_value = trim((string)($folder['hero_image_id'] ?? 'parent'));
4195 $hero_id = (int)$hero_value;
4196 if ($hero_id > 0) {
4197 $hero = $db->select1($this->dd_media, $hero_id);
4198 if (!is_array($hero) || (int)($hero['active'] ?? 0) !== 1 || !$this->media_file_exists($hero)) return array();
4199 $hero = $this->normalize_media_row($hero);
4200 $hero['parent_folder_id'] = $folder_id;
4201 $hero['parent_folder_name'] = (string)($folder['name'] ?? '');
4202 return $hero;
4203 }
4204 if ($hero_value === '0' || strtolower($hero_value) === 'none') return array();
4205 $folder_id = (int)($folder['parent_id'] ?? 0);
4206 }
4207 return array();
4208 }
4209
4210 private function seo_preview_media($db, $row) {
4211 if (!is_array($row)) return array();
4212 $seo_id = (int)($row['seo_image_id'] ?? 0);
4213 if ($seo_id <= 0) return array();
4214 $media = $db->select1($this->dd_media, $seo_id);
4215 if (!is_array($media) || (int)($media['active'] ?? 0) !== 1) return array();
4216 if (!$this->media_file_exists($media)) return array();
4217 return $this->normalize_media_row($media);
4218 }
4219
4220 private function save_json() {
4221 $texts = $this->cms_texts();
4222 $payload = $this->request_json();
4223 $id = (int)($payload['id'] ?? 0);
4224 $db = dbx()->get_system_obj('dbxDB');
4225 $this->ensure_cms_schema($db);
4226
4227 $title = $this->clean_text($payload['title'] ?? '', 254);
4228 $folder = (int)($payload['folder'] ?? 0);
4229 $keywordsProvided = array_key_exists('keywords', $payload);
4230 try {
4231 $permalink = $this->page_permalink($db, $folder, $title, $payload['permalink'] ?? '', $id);
4232 } catch (\InvalidArgumentException $e) {
4233 $this->cms_json_response(array(
4234 'ok' => 0,
4235 'success' => false,
4236 'field' => 'permalink',
4237 'msg' => $e->getMessage(),
4238 ));
4239 }
4240 $data = array(
4241 'activ' => $this->bool_int($payload['activ'] ?? 1, 1),
4242 'folder' => $folder,
4243 'title' => $title,
4244 'menu_title' => $this->clean_text($payload['menu_title'] ?? '', 96),
4245 'permalink' => $permalink,
4246 'description' => $this->clean_text($payload['description'] ?? '', 254),
4247 'template' => $this->clean_text($payload['template'] ?? 'parent', 254),
4248 'hero_template' => $this->clean_text($payload['hero_template'] ?? 'parent', 80),
4249 'hero_image_id' => $this->clean_text($payload['hero_image_id'] ?? 'parent', 32),
4250 'hero_margin_top' => $this->clean_text($payload['hero_margin_top'] ?? 'parent', 32),
4251 'hero_height' => $this->clean_text($payload['hero_height'] ?? 'parent', 32),
4252 'hero_variant' => $this->clean_text($payload['hero_variant'] ?? 'parent', 32),
4253 'hero_sticky' => $this->clean_text($payload['hero_sticky'] ?? 'parent', 32),
4254 'hero_scroll_layer' => $this->clean_text($payload['hero_scroll_layer'] ?? 'parent', 32),
4255 'gallery_template' => 'image-gallery',
4256 'gallery_visible_count' => '3',
4257 'gallery_image_size' => $this->clean_text($payload['gallery_image_size'] ?? 'original', 32),
4258 'gallery_lightbox_width' => $this->clean_text($payload['gallery_lightbox_width'] ?? '100vw', 32),
4259 'gallery_overflow' => $this->clean_text($payload['gallery_overflow'] ?? 'grid', 32),
4260 'gallery_click_behavior' => $this->clean_text($payload['gallery_click_behavior'] ?? 'lightbox', 32),
4261 'content' => $this->normalize_and_sanitize_content($payload['content'] ?? ''),
4262 );
4263 $data = $this->normalize_hero_payload($data);
4264
4265 if ($id > 0) {
4266 $existingSeo = $db->select1(dbxContentLng::ddContent(), $id, 'keywords,meta_robots,seo_title,seo_image_id', 0);
4267 $data['keywords'] = $keywordsProvided
4268 ? $this->clean_text($payload['keywords'] ?? '', 254)
4269 : (is_array($existingSeo) ? (string)($existingSeo['keywords'] ?? '') : '');
4270 $data['meta_robots'] = is_array($existingSeo) ? (string)($existingSeo['meta_robots'] ?? 'index,follow') : 'index,follow';
4271 $data['seo_title'] = is_array($existingSeo) ? (string)($existingSeo['seo_title'] ?? '') : '';
4272 $data['seo_image_id'] = is_array($existingSeo) ? max(0, (int)($existingSeo['seo_image_id'] ?? 0)) : 0;
4273 } else {
4274 $data['keywords'] = $keywordsProvided ? $this->clean_text($payload['keywords'] ?? '', 254) : '';
4275 $data['meta_robots'] = 'index,follow';
4276 $data['seo_title'] = '';
4277 $data['seo_image_id'] = 0;
4278 }
4279
4280 if ($id > 0) {
4281 $ok = $db->update(dbxContentLng::ddContent(), $data, $id);
4282 $saved_id = $id;
4283 } else {
4284 $ok = $db->insert(dbxContentLng::ddContent(), $data);
4285 $saved_id = ($ok === 1) ? $db->get_insert_id() : 0;
4286 }
4287
4288 if ($ok === 1) {
4289 $this->sync_saved_hero_media_usage($db, $saved_id, 0, $data['hero_image_id']);
4290 dbxContentLngSync::afterPageSave($db, $saved_id, $id <= 0);
4291 $syncResult = array('updated' => array(), 'skipped' => array(), 'errors' => array());
4292 if ($id > 0 && dbxContentLngSync::isMasterLng()) {
4293 dbxContentTranslate::clearWarnings();
4294 $syncResult = dbxContentLngSync::syncSlavesFromMaster($db, 'page', $saved_id);
4295 $syncResult['media_copied'] = $this->apply_lng_sync_media($db, $saved_id, $syncResult);
4296 $syncResult['translate_warnings'] = dbxContentTranslate::warnings();
4297 $this->flush_lng_sync_cache($db, $syncResult);
4298 }
4299 $inline_provided = array_key_exists('inline_media_ids', $payload) && is_array($payload['inline_media_ids']);
4300 $inline_sync = $this->sync_inline_media_usage(
4301 $db,
4302 $saved_id,
4303 $data['content'],
4304 $data['folder'],
4305 $inline_provided ? $payload['inline_media_ids'] : null,
4306 $inline_provided
4307 );
4308 $this->flush_saved_page_cache($db, $saved_id);
4309 $media = $this->media_usage_rows_for_context($db, $saved_id, 0);
4310 $saved_row = $db->select1(dbxContentLng::ddContent(), $saved_id);
4311 if (is_array($saved_row)) {
4312 if (isset($saved_row['content'])) {
4313 $saved_row['content'] = $this->normalize_content_media_urls($saved_row['content']);
4314 }
4315 $saved_row = $this->normalize_gallery_row($saved_row);
4316 }
4317 $this->cms_json_response(array(
4318 'ok' => 1,
4319 'success' => true,
4320 'id' => $saved_id,
4321 'row' => $saved_row,
4322 'media' => $media,
4323 'inline_media_sync' => $inline_sync,
4324 'hero_preview_media' => $this->hero_preview_media($db, $saved_row),
4325 'hero_parent_preview_media' => $this->inherited_hero_preview_media($db, (int)($saved_row['folder'] ?? 0)),
4326 'seo_preview_media' => $this->seo_preview_media($db, $saved_row),
4327 'lng_forked' => dbxContentLngSync::isMasterLng() ? 0 : 1,
4328 'lng_synced' => count($syncResult['updated'] ?? array()),
4329 'lng_media_copied' => (int) ($syncResult['media_copied'] ?? 0),
4330 'lng_translate_provider' => dbxContentTranslate::provider(),
4331 'lng_sync_targets' => dbxContentLngSync::isMasterLng() ? dbxContentLngSync::slaveLngs() : array(),
4332 'lng_sync_updated' => is_array($syncResult['updated'] ?? null) ? $syncResult['updated'] : array(),
4333 'lng_sync_skipped' => is_array($syncResult['skipped'] ?? null) ? $syncResult['skipped'] : array(),
4334 'lng_sync_errors' => is_array($syncResult['errors'] ?? null) ? $syncResult['errors'] : array(),
4335 'translate_warnings' => is_array($syncResult['translate_warnings'] ?? null) ? $syncResult['translate_warnings'] : array(),
4336 'open_lng_provision' => $this->lng_provision_open_flag($db, 'page', $saved_id),
4337 ));
4338 }
4339
4340 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => $texts->get_fd_message('page_save_error'), 'db' => $ok));
4341 }
4342
4343 private function new_page_json() {
4344 $texts = $this->cms_texts();
4345 $folder = (int)dbx()->get_modul_var('folder', 0, 'int');
4346 $db = dbx()->get_system_obj('dbxDB');
4347 $this->ensure_cms_schema($db);
4348 $title = $texts->format_fd_message('new_page_title', array('time' => date('H:i')));
4349 $sorter = $this->next_content_sorter($db, $folder);
4350 $id = 0;
4351 if ($db->insert(dbxContentLng::ddContent(), array(
4352 'activ' => 1,
4353 'folder' => $folder,
4354 'title' => $title,
4355 'menu_title' => '',
4356 'permalink' => dbxContent_permalink::build($db, dbxContentLng::ddFolder(), $folder, $title),
4357 'template' => 'parent',
4358 'hero_template' => 'parent',
4359 'hero_image_id' => 'parent',
4360 'hero_margin_top' => 'parent',
4361 'hero_height' => 'parent',
4362 'hero_variant' => 'parent',
4363 'hero_sticky' => 'parent',
4364 'hero_scroll_layer' => 'parent',
4365 'gallery_template' => 'image-gallery',
4366 'gallery_visible_count' => '3',
4367 'gallery_image_size' => 'original',
4368 'gallery_lightbox_width' => '100vw',
4369 'gallery_overflow' => 'grid',
4370 'gallery_click_behavior' => 'lightbox',
4371 'description' => '',
4372 'keywords' => '',
4373 'meta_robots' => 'index,follow',
4374 'seo_title' => '',
4375 'seo_image_id' => 0,
4376 'sorter' => $sorter,
4377 'content' => '<p>' . $texts->get_fd_message('new_page_content') . '</p>',
4378 ), 0, 1, 0, 1) === 1) {
4379 $id = $db->get_insert_id();
4380 }
4381 if ($id > 0) {
4382 dbxContentLngSync::afterPageSave($db, $id, true);
4383 $this->flush_menu_cache();
4384 $this->cms_json_response(array(
4385 'ok' => 1,
4386 'success' => true,
4387 'id' => $id,
4388 'row' => $db->select1(dbxContentLng::ddContent(), $id),
4389 'open_lng_provision' => $this->lng_provision_open_flag($db, 'page', $id),
4390 ));
4391 }
4392 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => $texts->get_fd_message('page_create_error'), 'db' => $id));
4393 }
4394
4395 private function duplicate_page_json() {
4396 $texts = $this->cms_texts();
4397 $payload = $this->request_json();
4398 $sourceId = (int)($payload['id'] ?? dbx()->get_modul_var('id', 0, 'int'));
4399 if ($sourceId <= 0) {
4400 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => $texts->get_fd_message('page_select_first')));
4401 }
4402
4403 $db = dbx()->get_system_obj('dbxDB');
4404 $this->ensure_cms_schema($db);
4405 $contentDd = dbxContentLng::ddContent();
4406 if ($db->begin($contentDd) !== 1) {
4407 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => $texts->get_fd_message('page_duplicate_lock_error')));
4408 }
4409
4410 $source = $db->select1($contentDd, $sourceId, '*', 0);
4411 if (!is_array($source) || (int)($source['id'] ?? 0) !== $sourceId) {
4412 $db->rollback($contentDd);
4413 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => $texts->get_fd_message('page_not_found')));
4414 }
4415
4416 $copy = $source;
4417 foreach (array(
4418 'id', 'create_date', 'create_uid', 'update_date', 'update_uid', 'owner',
4419 'hits', 'xvote', 'vote', 'vote1', 'vote2', 'vote3', 'vote4', 'vote5', 'lastuservote'
4420 ) as $field) {
4421 unset($copy[$field]);
4422 }
4423
4424 $folderId = (int)($source['folder'] ?? 0);
4425 $title = (string)($source['title'] ?? 'Unbenannte Seite');
4426 $copy['sorter'] = $this->next_content_sorter($db, $folderId);
4427 $copy['permalink'] = $this->duplicate_page_permalink(
4428 $db,
4429 $folderId,
4430 $title,
4431 (string)($source['permalink'] ?? '')
4432 );
4433 $copy['lng_uid'] = '';
4434 $copy['lng_sync'] = dbxContentLngSync::isMasterLng() ? 'auto' : 'manual';
4435 $copy['lng_rev'] = 1;
4436 $copy['lng_synced_rev'] = 0;
4437
4438 $inserted = $db->insert($contentDd, $copy, 0, 1, 0, 1);
4439 $newId = $inserted === 1 ? (int)$db->get_insert_id() : 0;
4440 if ($newId <= 0) {
4441 $db->rollback($contentDd);
4442 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => $texts->get_fd_message('page_duplicate_error'), 'db' => $inserted));
4443 }
4444
4445 dbxContentLngSync::afterPageSave($db, $newId, true);
4446 if ($db->commit($contentDd) !== 1) {
4447 $db->rollback($contentDd);
4448 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => $texts->get_fd_message('page_duplicate_commit_error')));
4449 }
4450
4451 $mediaCopied = $this->copy_page_media_usage(
4452 $db,
4453 $sourceId,
4454 $newId,
4455 $folderId,
4456 true,
4457 dbxContentLng::current(),
4458 dbxContentLng::current()
4459 );
4460 $inlineSync = $this->sync_inline_media_usage(
4461 $db,
4462 $newId,
4463 (string)($source['content'] ?? ''),
4464 $folderId
4465 );
4466 $this->flush_saved_page_cache($db, $newId);
4467
4468 $row = $db->select1($contentDd, $newId);
4469 if (is_array($row) && isset($row['content'])) {
4470 $row['content'] = $this->normalize_content_media_urls($row['content']);
4471 $row = $this->normalize_gallery_row($row);
4472 }
4473
4474 $this->cms_json_response(array(
4475 'ok' => 1,
4476 'success' => true,
4477 'id' => $newId,
4478 'source_id' => $sourceId,
4479 'row' => $row,
4480 'permalink' => (string)($row['permalink'] ?? $copy['permalink']),
4481 'media_copied' => $mediaCopied,
4482 'inline_media_sync' => $inlineSync,
4483 'open_lng_provision' => $this->lng_provision_open_flag($db, 'page', $newId),
4484 'msg' => $texts->get_fd_message('page_duplicated'),
4485 ));
4486 }
4487
4488 private function next_content_sorter($db, $folder_id) {
4489 $folder_id = (int)$folder_id;
4490 $rows = $db->select(dbxContentLng::ddContent(), 'folder = ' . $folder_id, '*', 'sorter,id', 'DESC', '', 1, 0, 0);
4491 $max = 0;
4492 if (is_array($rows) && isset($rows[0]) && is_array($rows[0])) {
4493 $max = (int)($rows[0]['sorter'] ?? 0);
4494 }
4495 return sprintf('%04d', $max + 10);
4496 }
4497
4498 private function new_folder_json() {
4499 $texts = $this->cms_texts();
4500 $parent = (int)dbx()->get_modul_var('parent', 0, 'int');
4501 $db = dbx()->get_system_obj('dbxDB');
4502 $this->ensure_cms_schema($db);
4503 $sorter = $this->next_folder_sorter($db, $parent);
4504 $id = 0;
4505 if ($db->insert(dbxContentLng::ddFolder(), array(
4506 'name' => $texts->format_fd_message('new_folder_title', array('time' => date('H:i'))),
4507 'parent_id' => $parent,
4508 'sorter' => $sorter,
4509 'group_read' => $parent > 0 ? 'parent' : '*',
4510 'template' => $parent > 0 ? 'parent' : 'c-content',
4511 'hero_template' => $parent > 0 ? 'parent' : 'image-hero',
4512 'hero_image_id' => 'parent',
4513 'hero_margin_top' => 'parent',
4514 'hero_height' => 'parent',
4515 'hero_variant' => 'parent',
4516 'hero_sticky' => 'parent',
4517 'hero_scroll_layer' => 'parent',
4518 )) === 1) {
4519 $id = $db->get_insert_id();
4520 }
4521 if ($id > 0) {
4522 dbxContentLngSync::afterFolderSave($db, $id, true);
4523 $this->flush_menu_cache();
4524 $this->cms_json_response(array(
4525 'ok' => 1,
4526 'success' => true,
4527 'id' => $id,
4528 'row' => $db->select1(dbxContentLng::ddFolder(), $id),
4529 'open_lng_provision' => $this->lng_provision_open_flag($db, 'folder', $id),
4530 ));
4531 }
4532 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => $texts->get_fd_message('folder_create_error')));
4533 }
4534
4535 private function save_folder_json() {
4536 $texts = $this->cms_texts();
4537 $payload = $this->request_json();
4538 $id = (int)($payload['id'] ?? 0);
4539 $db = dbx()->get_system_obj('dbxDB');
4540 $this->ensure_cms_schema($db);
4541
4542 $name = $this->clean_text($payload['name'] ?? '', 120);
4543 $parent_id = (int)($payload['parent_id'] ?? 0);
4544 if ($name === '') {
4545 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => $texts->get_fd_message('folder_name_required')));
4546 }
4547 if ($parent_id < 0) $parent_id = 0;
4548 if ($id > 0 && $parent_id === $id) {
4549 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => $texts->get_fd_message('folder_self_parent')));
4550 }
4551 if ($id > 0 && $this->folder_is_descendant($db, $parent_id, $id)) {
4552 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => $texts->get_fd_message('folder_descendant_parent')));
4553 }
4554
4555 $rights = $this->clean_text($payload['group_read'] ?? '', 512);
4556 if ($rights === '') $rights = $parent_id > 0 ? 'parent' : '*';
4557
4558 $old = $id > 0 ? $db->select1(dbxContentLng::ddFolder(), $id, '*', 0) : array();
4559 $data = array(
4560 'name' => $name,
4561 'parent_id' => $parent_id,
4562 'group_read' => $rights,
4563 'template' => $this->clean_text($payload['template'] ?? 'parent', 254),
4564 'hero_template' => $this->clean_text($payload['hero_template'] ?? 'parent', 80),
4565 'hero_image_id' => $this->clean_text($payload['hero_image_id'] ?? 'parent', 32),
4566 'hero_margin_top' => $this->clean_text($payload['hero_margin_top'] ?? 'parent', 32),
4567 'hero_height' => $this->clean_text($payload['hero_height'] ?? 'parent', 32),
4568 'hero_variant' => $this->clean_text($payload['hero_variant'] ?? 'parent', 32),
4569 'hero_sticky' => $this->clean_text($payload['hero_sticky'] ?? 'parent', 32),
4570 'hero_scroll_layer' => $this->clean_text($payload['hero_scroll_layer'] ?? 'parent', 32),
4571 );
4572 $data = $this->normalize_hero_payload($data);
4573 if ($id <= 0 || (is_array($old) && (int)($old['parent_id'] ?? 0) !== $parent_id)) {
4574 $data['sorter'] = $this->next_folder_sorter($db, $parent_id);
4575 }
4576
4577 if ($id > 0) {
4578 $ok = $db->update(dbxContentLng::ddFolder(), $data, $id);
4579 $saved_id = $id;
4580 } else {
4581 $ok = $db->insert(dbxContentLng::ddFolder(), $data);
4582 $saved_id = ($ok === 1) ? $db->get_insert_id() : 0;
4583 }
4584
4585 if ($ok === 1 && $saved_id > 0) {
4586 $this->sync_saved_hero_media_usage($db, 0, $saved_id, $data['hero_image_id']);
4587 dbxContentLngSync::afterFolderSave($db, $saved_id, $id <= 0);
4588 $syncResult = array('updated' => array(), 'skipped' => array(), 'errors' => array());
4589 if ($id > 0 && dbxContentLngSync::isMasterLng()) {
4590 dbxContentTranslate::clearWarnings();
4591 $syncResult = dbxContentLngSync::syncSlavesFromMaster($db, 'folder', $saved_id);
4592 $syncResult['translate_warnings'] = dbxContentTranslate::warnings();
4593 $this->flush_lng_sync_cache($db, $syncResult);
4594 }
4595 $this->flush_folder_cache($db, $saved_id);
4596 if (is_array($old) && (int)($old['parent_id'] ?? 0) !== $parent_id) {
4597 $this->flush_folder_cache($db, (int)($old['parent_id'] ?? 0));
4598 }
4599 $this->cms_json_response(array(
4600 'ok' => 1,
4601 'success' => true,
4602 'id' => $saved_id,
4603 'row' => $db->select1(dbxContentLng::ddFolder(), $saved_id),
4604 'lng_forked' => dbxContentLngSync::isMasterLng() ? 0 : 1,
4605 'lng_synced' => count($syncResult['updated'] ?? array()),
4606 'lng_translate_provider' => dbxContentTranslate::provider(),
4607 'lng_sync_targets' => dbxContentLngSync::isMasterLng() ? dbxContentLngSync::slaveLngs() : array(),
4608 'lng_sync_updated' => is_array($syncResult['updated'] ?? null) ? $syncResult['updated'] : array(),
4609 'lng_sync_skipped' => is_array($syncResult['skipped'] ?? null) ? $syncResult['skipped'] : array(),
4610 'lng_sync_errors' => is_array($syncResult['errors'] ?? null) ? $syncResult['errors'] : array(),
4611 'translate_warnings' => is_array($syncResult['translate_warnings'] ?? null) ? $syncResult['translate_warnings'] : array(),
4612 'open_lng_provision' => $this->lng_provision_open_flag($db, 'folder', $saved_id),
4613 ));
4614 }
4615 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => $texts->get_fd_message('folder_save_error')));
4616 }
4617
4618 private function delete_folder_json() {
4619 $texts = $this->cms_texts();
4620 $payload = $this->request_json();
4621 $id = (int)($payload['id'] ?? 0);
4622 if ($id <= 0) {
4623 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => $texts->get_fd_message('folder_none_selected')));
4624 }
4625
4626 $db = dbx()->get_system_obj('dbxDB');
4627 $deleteLngs = $this->normalize_delete_lngs($payload);
4628 $result = $this->delete_folder_in_lngs($db, $id, $deleteLngs);
4629
4630 if ((int)($result['ok'] ?? 0) === 1) {
4631 $this->cms_json_response(array(
4632 'ok' => 1,
4633 'success' => true,
4634 'id' => $id,
4635 'deleted' => $result['deleted'] ?? array(),
4636 'warnings' => $result['errors'] ?? array(),
4637 ));
4638 }
4639
4640 $msg = count($result['errors'] ?? array()) ? implode(' ', $result['errors']) : $texts->get_fd_message('folder_delete_error');
4641 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => $msg));
4642 }
4643
4644 private function delete_page_json() {
4645 $texts = $this->cms_texts();
4646 $payload = $this->request_json();
4647 $id = (int)($payload['id'] ?? dbx()->get_modul_var('id', 0, 'int'));
4648 if ($id <= 0) {
4649 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => $texts->get_fd_message('page_none_selected')));
4650 }
4651
4652 $db = dbx()->get_system_obj('dbxDB');
4653 $deleteLngs = $this->normalize_delete_lngs($payload);
4654 $result = $this->delete_page_in_lngs($db, $id, $deleteLngs);
4655
4656 if ((int)($result['ok'] ?? 0) === 1) {
4657 $this->flush_menu_cache();
4658 $this->cms_json_response(array(
4659 'ok' => 1,
4660 'success' => true,
4661 'id' => $id,
4662 'deleted' => $result['deleted'] ?? array(),
4663 'warnings' => $result['errors'] ?? array(),
4664 ));
4665 }
4666
4667 $msg = count($result['errors'] ?? array()) ? implode(' ', $result['errors']) : $texts->get_fd_message('page_delete_error');
4668 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => $msg));
4669 }
4670
4671 private function move_node_json() {
4672 $payload = $this->request_json();
4673 $type = $this->clean_text($payload['type'] ?? '', 16);
4674 $id = (int)($payload['id'] ?? 0);
4675 $target = (int)($payload['target_folder'] ?? 0);
4676 $before_id = (int)($payload['before_id'] ?? 0);
4677 $after_id = (int)($payload['after_id'] ?? 0);
4678
4679 if ($id <= 0 || $target < 0 || !in_array($type, array('folder', 'page'), true)) {
4680 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Ungueltige Tree-Verschiebung.'));
4681 }
4682
4683 if ($type === 'folder' && $id === $target) {
4684 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Ordner kann nicht in sich selbst verschoben werden.'));
4685 }
4686
4687 $db = dbx()->get_system_obj('dbxDB');
4688 if ($type === 'folder') {
4689 if ($this->folder_is_descendant($db, $target, $id)) {
4690 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Ordner kann nicht in einen eigenen Unterordner verschoben werden.'));
4691 }
4692 $data = array('parent_id' => $target);
4693 if ($before_id > 0 || $after_id > 0) {
4694 $data['sorter'] = $this->next_folder_sorter($db, $target);
4695 } else {
4696 $current = $db->select1(dbxContentLng::ddFolder(), $id, '*', 0);
4697 if (is_array($current) && (int)($current['parent_id'] ?? 0) !== $target) {
4698 $data['sorter'] = $this->next_folder_sorter($db, $target);
4699 }
4700 }
4701 $ok = $db->update(dbxContentLng::ddFolder(), $data, $id);
4702 if ($ok >= 0 && ($before_id > 0 || $after_id > 0)) {
4703 $this->reorder_folders($db, $target, $id, $before_id, $after_id);
4704 }
4705 } else {
4706 $data = array('folder' => $target);
4707 if ($before_id <= 0 && $after_id <= 0) {
4708 $current = $db->select1(dbxContentLng::ddContent(), $id, '*', 0);
4709 if (is_array($current) && (int)($current['folder'] ?? 0) !== $target) {
4710 $data['sorter'] = $this->next_content_sorter($db, $target);
4711 }
4712 }
4713 $ok = $db->update(dbxContentLng::ddContent(), $data, $id);
4714 if ($ok >= 0 && ($before_id > 0 || $after_id > 0)) {
4715 $this->reorder_content_pages($db, $target, $id, $before_id, $after_id);
4716 }
4717 }
4718
4719 if ($ok >= 0) {
4720 if ($type === 'page') {
4721 $this->flush_saved_page_cache($db, $id);
4722 } else {
4723 $this->flush_folder_cache($db, $id);
4724 $this->flush_folder_cache($db, $target);
4725 }
4726 $this->cms_json_response(array('ok' => 1, 'success' => true, 'id' => $id, 'type' => $type, 'target_folder' => $target));
4727 }
4728
4729 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Tree-Eintrag konnte nicht verschoben werden.'));
4730 }
4731
4732 private function folder_is_descendant($db, $folder_id, $ancestor_id) {
4733 $folder_id = (int)$folder_id;
4734 $ancestor_id = (int)$ancestor_id;
4735 $guard = 0;
4736 while ($folder_id > 0 && $guard < 100) {
4737 if ($folder_id === $ancestor_id) return true;
4738 $row = $db->select1(dbxContentLng::ddFolder(), $folder_id);
4739 if (!is_array($row)) return false;
4740 $folder_id = (int)($row['parent_id'] ?? 0);
4741 $guard++;
4742 }
4743 return false;
4744 }
4745
4746 private function next_folder_sorter($db, $parent_id) {
4747 $parent_id = (int)$parent_id;
4748 $rows = $db->select(dbxContentLng::ddFolder(), 'parent_id = ' . $parent_id, '*', 'sorter,id', 'DESC', '', 1, 0, 0);
4749 $max = 0;
4750 if (is_array($rows) && isset($rows[0]) && is_array($rows[0])) {
4751 $max = (int)($rows[0]['sorter'] ?? 0);
4752 }
4753 return sprintf('%04d', $max + 10);
4754 }
4755
4756 private function reorder_folders($db, $parent_id, $moved_id, $before_id = 0, $after_id = 0) {
4757 $parent_id = (int)$parent_id;
4758 $moved_id = (int)$moved_id;
4759 $rows = $db->select(dbxContentLng::ddFolder(), 'parent_id = ' . $parent_id, '*', 'sorter,name,id', 'ASC');
4760 if (!is_array($rows)) return;
4761
4762 $ordered = array();
4763 $moved = null;
4764 foreach ($rows as $row) {
4765 if (!is_array($row)) continue;
4766 $rid = (int)($row['id'] ?? 0);
4767 if ($rid === $moved_id) {
4768 $moved = $row;
4769 continue;
4770 }
4771 $ordered[] = $row;
4772 }
4773 if (!$moved) return;
4774
4775 $inserted = false;
4776 $out = array();
4777 foreach ($ordered as $row) {
4778 $rid = (int)($row['id'] ?? 0);
4779 if ($before_id > 0 && $rid === $before_id) {
4780 $out[] = $moved;
4781 $inserted = true;
4782 }
4783 $out[] = $row;
4784 if ($after_id > 0 && $rid === $after_id) {
4785 $out[] = $moved;
4786 $inserted = true;
4787 }
4788 }
4789 if (!$inserted) $out[] = $moved;
4790
4791 $sort = 10;
4792 foreach ($out as $row) {
4793 $rid = (int)($row['id'] ?? 0);
4794 if ($rid <= 0) continue;
4795 $db->update(dbxContentLng::ddFolder(), array('sorter' => sprintf('%04d', $sort)), $rid);
4796 $sort += 10;
4797 }
4798 }
4799
4800 private function reorder_content_pages($db, $folder_id, $moved_id, $before_id = 0, $after_id = 0) {
4801 $folder_id = (int)$folder_id;
4802 $moved_id = (int)$moved_id;
4803 $rows = $db->select(dbxContentLng::ddContent(), 'folder = ' . $folder_id, '*', 'sorter,title,id', 'ASC');
4804 if (!is_array($rows)) return;
4805
4806 $ordered = array();
4807 $moved = null;
4808 foreach ($rows as $row) {
4809 if (!is_array($row)) continue;
4810 $rid = (int)($row['id'] ?? 0);
4811 if ($rid === $moved_id) {
4812 $moved = $row;
4813 continue;
4814 }
4815 $ordered[] = $row;
4816 }
4817 if (!$moved) return;
4818
4819 $inserted = false;
4820 $out = array();
4821 foreach ($ordered as $row) {
4822 $rid = (int)($row['id'] ?? 0);
4823 if ($before_id > 0 && $rid === $before_id) {
4824 $out[] = $moved;
4825 $inserted = true;
4826 }
4827 $out[] = $row;
4828 if ($after_id > 0 && $rid === $after_id) {
4829 $out[] = $moved;
4830 $inserted = true;
4831 }
4832 }
4833 if (!$inserted) $out[] = $moved;
4834
4835 $sort = 10;
4836 $changed = array();
4837 foreach ($out as $row) {
4838 $rid = (int)($row['id'] ?? 0);
4839 if ($rid <= 0) continue;
4840 $sorter = sprintf('%04d', $sort);
4841 if ((string)($row['sorter'] ?? '') !== $sorter) {
4842 $db->update(dbxContentLng::ddContent(), array('sorter' => $sorter), $rid);
4843 $changed[] = $rid;
4844 }
4845 $sort += 10;
4846 }
4847 foreach ($changed as $rid) {
4848 dbxContentPageCache::invalidateContent((int)$rid);
4849 }
4850 if (count($changed)) {
4851 dbxContentPageCache::invalidateAllMenus();
4852 }
4853 }
4854
4855 private function media_usage_page_map($db, $usage_rows) {
4856 $map = array();
4857 if (!is_array($usage_rows)) return $map;
4858
4859 $page_cache = array();
4860 $folder_cache = array();
4861
4862 foreach ($usage_rows as $usage) {
4863 if (!is_array($usage)) continue;
4864 $media_id = (int)($usage['media_id'] ?? 0);
4865 $content_id = (int)($usage['content_id'] ?? 0);
4866 $content_lng = dbxContentMediaUsageScope::language((string)($usage['content_lng'] ?? ''));
4867 if ($media_id <= 0 || $content_id <= 0) continue;
4868
4869 $page_key = $content_lng . ':' . $content_id;
4870 if (!array_key_exists($page_key, $page_cache)) {
4871 $page_cache[$page_key] = $db->select1(dbxContentLng::ddContent($content_lng), $content_id, 'id,folder,title', 0);
4872 }
4873 $page = is_array($page_cache[$page_key]) ? $page_cache[$page_key] : array();
4874 $folder_id = (int)($page['folder'] ?? ($usage['folder_id'] ?? 0));
4875
4876 $folder_key = $content_lng . ':' . $folder_id;
4877 if ($folder_id > 0 && !array_key_exists($folder_key, $folder_cache)) {
4878 $folder_cache[$folder_key] = $db->select1(dbxContentLng::ddFolder($content_lng), $folder_id, '*', 0);
4879 }
4880 $folder = ($folder_id > 0 && is_array($folder_cache[$folder_key] ?? null)) ? $folder_cache[$folder_key] : array();
4881
4882 if (!isset($map[$media_id])) $map[$media_id] = array();
4883 if (!isset($map[$media_id][$page_key])) {
4884 $map[$media_id][$page_key] = array(
4885 'id' => $content_id,
4886 'content_id' => $content_id,
4887 'content_lng' => $content_lng,
4888 'title' => (string)($page['title'] ?? ''),
4889 'folder_id' => $folder_id,
4890 'folder_title' => (string)($folder['name'] ?? $folder['title'] ?? ''),
4891 'slots' => array(),
4892 );
4893 }
4894
4895 $slot = trim((string)($usage['slot'] ?? ''));
4896 if ($slot !== '' && !in_array($slot, $map[$media_id][$page_key]['slots'], true)) {
4897 $map[$media_id][$page_key]['slots'][] = $slot;
4898 }
4899 }
4900
4901 foreach ($map as $media_id => $items) {
4902 ksort($items, SORT_NUMERIC);
4903 $map[$media_id] = array_values($items);
4904 }
4905
4906 return $map;
4907 }
4908
4909 private function media_json() {
4910 $content_id = (int)dbx()->get_modul_var('content_id', 0, 'int');
4911 $folder_id = (int)dbx()->get_modul_var('folder_id', 0, 'int');
4912 $images = (int)dbx()->get_modul_var('images', 0, 'int');
4913 $sync = (int)dbx()->get_modul_var('sync', 0, 'int');
4914 $media_type = strtolower(trim((string)dbx()->get_modul_var('media_type', '', 'varchar')));
4915 $raw_media_folder = trim((string)dbx()->get_modul_var('media_folder', '', 'varchar'));
4916 $media_folder = $raw_media_folder !== '' && strtolower($raw_media_folder) !== 'all'
4917 ? $this->clean_media_folder($raw_media_folder, $media_type ?: $this->media_type_from_folder($raw_media_folder))
4918 : '';
4919 $slot = trim((string)dbx()->get_modul_var('slot', '', 'varchar'));
4920 $query = trim((string)dbx()->get_modul_var('query', '', 'varchar'));
4921 $usage_only = (int)dbx()->get_modul_var('usage', 0, 'int');
4922 $limit = max(0, min(200, (int)dbx()->get_modul_var('limit', 0, 'int')));
4923 $offset = max(0, (int)dbx()->get_modul_var('offset', 0, 'int'));
4924 $db = dbx()->get_system_obj('dbxDB');
4925 $this->ensure_cms_schema($db);
4926 if ($sync) $this->sync_cms_media_files($db);
4927 $where = 'active = 1' . $this->cms_media_exclude_sql();
4928 if ($images) {
4929 $where .= " AND (mime LIKE 'image/%' OR file_name LIKE '%.jpg' OR file_name LIKE '%.jpeg' OR file_name LIKE '%.png' OR file_name LIKE '%.gif' OR file_name LIKE '%.webp' OR file_name LIKE '%.svg')";
4930 }
4931 if (in_array($media_type, array('image','video','external_video','file'), true)) {
4932 $where .= " AND media_type = '" . str_replace("'", "''", $media_type) . "'";
4933 }
4934 if ($media_folder !== '') {
4935 $where .= " AND media_folder = '" . str_replace("'", "''", $media_folder) . "'";
4936 }
4937 if ($query !== '') {
4938 $q = str_replace("'", "''", $query);
4939 $where .= " AND (title LIKE '%$q%' OR alt LIKE '%$q%' OR caption LIKE '%$q%' OR tags LIKE '%$q%' OR file_name LIKE '%$q%')";
4940 }
4941 $select_limit = $limit > 0 ? $limit + 1 : 0;
4942 $rows = $db->select($this->dd_media, $where, '*', 'media_folder,title,id', 'ASC', '', $select_limit, $offset, 0);
4943 if (!is_array($rows)) $rows = array();
4944 $has_more = $limit > 0 && count($rows) > $limit;
4945 if ($has_more) $rows = array_slice($rows, 0, $limit);
4946 $page_row_count = count($rows);
4947 if ($limit <= 0) $rows = $this->filter_existing_media($db, $rows);
4948
4949 $usage_where = 'active = 1';
4950 if ($limit > 0) {
4951 $page_media_ids = array_values(array_filter(array_map(function($row) {
4952 return (int)($row['id'] ?? 0);
4953 }, $rows)));
4954 $usage_where .= $page_media_ids
4955 ? ' AND media_id IN (' . implode(',', $page_media_ids) . ')'
4956 : ' AND 1 = 0';
4957 }
4958 $usage_rows = $db->select($this->dd_media_usage, $usage_where, '*', 'media_id,id', 'ASC', '', 0, 0, 0);
4959 $usage_count = array();
4960 $usage_pages = $this->media_usage_page_map($db, $usage_rows);
4961 $current_usage = array();
4962 $current_usage_row = array();
4963 $has_usage_context = $content_id > 0 || $folder_id > 0 || $slot !== '';
4964 if (is_array($usage_rows)) {
4965 foreach ($usage_rows as $usage) {
4966 if (!is_array($usage)) continue;
4967 $mid = (int)($usage['media_id'] ?? 0);
4968 if ($mid <= 0) continue;
4969 $usage_count[$mid] = ($usage_count[$mid] ?? 0) + 1;
4970 if ($has_usage_context
4971 && dbxContentMediaUsageScope::language((string)($usage['content_lng'] ?? '')) === dbxContentLng::current()
4972 && ($content_id <= 0 || (int)($usage['content_id'] ?? 0) === $content_id)
4973 && ($folder_id <= 0 || (int)($usage['folder_id'] ?? 0) === $folder_id)
4974 && ($slot === '' || (string)($usage['slot'] ?? '') === $slot)) {
4975 $current_usage[$mid] = (int)($usage['id'] ?? 0);
4976 $current_usage_row[$mid] = $usage;
4977 }
4978 }
4979 }
4980 $rows = array_map(function($row) use ($usage_count, $usage_pages, $current_usage, $current_usage_row) {
4981 $row = $this->normalize_media_row($row);
4982 $id = (int)($row['id'] ?? 0);
4983 $row['used_count'] = (int)($usage_count[$id] ?? 0);
4984 $row['usage_pages'] = $usage_pages[$id] ?? array();
4985 $row['current_usage_id'] = (int)($current_usage[$id] ?? 0);
4986 if (!empty($current_usage_row[$id])) {
4987 $usage = $current_usage_row[$id];
4988 $row['usage_id'] = (int)($usage['id'] ?? 0);
4989 $row['slot'] = (string)($usage['slot'] ?? $row['slot'] ?? 'gallery');
4990 $row['sorter'] = (string)($usage['sorter'] ?? $row['sorter'] ?? '');
4991 if (!empty($usage['template'])) $row['template'] = $usage['template'];
4992 if (!empty($usage['caption'])) $row['caption'] = $usage['caption'];
4993 }
4994 return $row;
4995 }, $rows);
4996 if ($usage_only && ($content_id > 0 || $folder_id > 0 || $slot !== '')) {
4997 $rows = array_values(array_filter($rows, function($row) {
4998 return (int)($row['current_usage_id'] ?? 0) > 0;
4999 }));
5000 }
5001 $this->cms_json_response(array(
5002 'ok' => 1,
5003 'rows' => $rows,
5004 'has_more' => $has_more ? 1 : 0,
5005 'next_offset' => $limit > 0 ? $offset + $page_row_count : 0,
5006 ));
5007 }
5008
5009 private function media_usage_rows_for_context($db, $content_id = 0, $folder_id = 0, $slot = '', $content_lng = '') {
5010 $content_id = (int)$content_id;
5011 $folder_id = (int)$folder_id;
5012 $where = dbxContentMediaUsageScope::withLanguage('active = 1', $content_lng);
5013 if ($content_id > 0) $where .= ' AND content_id = ' . $content_id;
5014 if ($folder_id > 0) $where .= ' AND folder_id = ' . $folder_id;
5015 if ($slot !== '') $where .= " AND slot = '" . str_replace("'", "''", (string)$slot) . "'";
5016
5017 $usage_rows = $db->select($this->dd_media_usage, $where, '*', 'slot,sorter,id', 'ASC', '', 0, 0, 0);
5018 if (!is_array($usage_rows)) return array();
5019
5020 $rows = array();
5021 foreach ($usage_rows as $usage) {
5022 if (!is_array($usage)) continue;
5023 $media_id = (int)($usage['media_id'] ?? 0);
5024 if ($media_id <= 0) continue;
5025 $row = $db->select1($this->dd_media, $media_id);
5026 if (!is_array($row) || (int)($row['active'] ?? 0) !== 1) continue;
5027 if (!$this->media_file_exists($row)) continue;
5028 $row = $this->normalize_media_row($row);
5029 $row['usage_id'] = (int)($usage['id'] ?? 0);
5030 $row['current_usage_id'] = (int)($usage['id'] ?? 0);
5031 $row['slot'] = (string)($usage['slot'] ?? $row['slot'] ?? 'gallery');
5032 $row['sorter'] = (string)($usage['sorter'] ?? $row['sorter'] ?? '');
5033 if (!empty($usage['template'])) $row['template'] = $usage['template'];
5034 if (!empty($usage['caption'])) $row['caption'] = $usage['caption'];
5035 $rows[] = $row;
5036 }
5037
5038 return $rows;
5039 }
5040
5041 private function inline_media_block_has_content($inner) {
5042 $inner = trim((string)$inner);
5043 if ($inner === '') return false;
5044 if (preg_match('/<(img|video|iframe|source)\b/i', $inner)) return true;
5045 if (preg_match('/\bdbx-cms-inline-video-thumb\b/i', $inner)) return true;
5046 if (preg_match('/\bdbx-cms-inline-video-empty\b/i', $inner)) return true;
5047 if (preg_match('/\bdbx-cms-inline-media-missing\b/i', $inner)) return true;
5048 return false;
5049 }
5050
5051 private function strip_empty_inline_media_wrappers($html) {
5052 $html = (string)$html;
5053 $pattern = '/<(p|figure|div)\b([^>]*(?:\bdbx-cms-inline-media\b|\bdata-cms-media-id=)[^>]*)>([\s\S]*?)<\/\1>/i';
5054 do {
5055 $next = preg_replace_callback(
5056 $pattern,
5057 function($m) {
5058 if ($this->inline_media_block_has_content($m[3] ?? '')) return $m[0];
5059 return '';
5060 },
5061 $html
5062 );
5063 if ($next === null || $next === $html) break;
5064 $html = $next;
5065 } while (true);
5066 return $html;
5067 }
5068
5069 private function inline_media_ids($html) {
5070 $html = preg_replace('/<!--[\s\S]*?-->/', '', (string)$html);
5071 $ids = array();
5072
5073 if (preg_match_all('/<(p|figure|div)\b([^>]*(?:\bdbx-cms-inline-media\b|\bdata-cms-media-id=)[^>]*)>([\s\S]*?)<\/\1>/i', $html, $blocks, PREG_SET_ORDER)) {
5074 foreach ($blocks as $block) {
5075 $inner = (string)($block[3] ?? '');
5076 if (!$this->inline_media_block_has_content($inner)) continue;
5077 if (preg_match_all('/<(?:img|video|source|iframe)\b[^>]*\bsrc=["\'][^"\']*dbx_mid=([0-9]+)/i', $inner, $inner_ids)) {
5078 foreach ($inner_ids[1] as $id) {
5079 $id = (int)$id;
5080 if ($id > 0) $ids[$id] = $id;
5081 }
5082 continue;
5083 }
5084 if (preg_match('/\bdata-cms-media-id=["\']?([0-9]+)/i', (string)($block[0] ?? ''), $id_match)) {
5085 $id = (int)$id_match[1];
5086 if ($id > 0) $ids[$id] = $id;
5087 }
5088 }
5089 }
5090 if (preg_match_all('/<(?:img|video|source|iframe)\b[^>]*\bdata-cms-media-id=["\']?([0-9]+)/i', $html, $m)) {
5091 foreach ($m[1] as $id) {
5092 $id = (int)$id;
5093 if ($id > 0) $ids[$id] = $id;
5094 }
5095 }
5096 if (preg_match_all('/<(?:img|video|source|iframe)\b[^>]*\bsrc=["\'][^"\']*dbx_mid=([0-9]+)/i', $html, $m)) {
5097 foreach ($m[1] as $id) {
5098 $id = (int)$id;
5099 if ($id > 0) $ids[$id] = $id;
5100 }
5101 }
5102 return array_values($ids);
5103 }
5104
5105 private function resolve_inline_media_ids($html, $client_ids = null, $client_ids_provided = false) {
5106 $html_ids = array();
5107 foreach ($this->inline_media_ids($html) as $id) {
5108 $id = (int)$id;
5109 if ($id > 0) $html_ids[$id] = $id;
5110 }
5111 return array_values($html_ids);
5112 }
5113
5114 private function sync_inline_media_usage($db, $content_id, $html, $folder_id = 0, $client_ids = null, $client_ids_provided = false, $content_lng = '') {
5115 $content_id = (int)$content_id;
5116 $folder_id = (int)$folder_id;
5117 $content_lng = dbxContentMediaUsageScope::language((string)$content_lng);
5118 $result = array('added' => 0, 'removed' => 0, 'kept' => 0);
5119 if ($content_id <= 0) return $result;
5120
5121 $wanted = array();
5122 foreach ($this->resolve_inline_media_ids($html, $client_ids, $client_ids_provided) as $id) {
5123 $id = (int)$id;
5124 if ($id > 0) $wanted[$id] = $id;
5125 }
5126
5127 $rows = $db->select(
5128 $this->dd_media_usage,
5129 dbxContentMediaUsageScope::withLanguage('content_id = ' . $content_id . " AND active = 1 AND slot = 'inline'", $content_lng),
5130 '*',
5131 'id'
5132 );
5133 if (is_array($rows)) {
5134 foreach ($rows as $row) {
5135 if (!is_array($row)) continue;
5136 $usage_id = (int)($row['id'] ?? 0);
5137 $media_id = (int)($row['media_id'] ?? 0);
5138 if ($usage_id > 0 && $media_id > 0 && !isset($wanted[$media_id])) {
5139 $db->update($this->dd_media_usage, array('active' => 0), $usage_id, 0, 1, 1, 0);
5140 $result['removed']++;
5141 }
5142 }
5143 }
5144
5145 foreach ($wanted as $id) {
5146 $row = $db->select1($this->dd_media, $id);
5147 if (!is_array($row) || (int)($row['active'] ?? 0) !== 1) continue;
5148
5149 $usages = $db->select(
5150 $this->dd_media_usage,
5151 dbxContentMediaUsageScope::withLanguage('media_id = ' . $id . ' AND content_id = ' . $content_id . " AND slot = 'inline'", $content_lng),
5152 '*', 'active DESC, id DESC', '', 0, 0, 0
5153 );
5154
5155 $active_usage = null;
5156 $inactive_usage = null;
5157 if (is_array($usages)) {
5158 foreach ($usages as $usage) {
5159 if (!is_array($usage)) continue;
5160 if ((int)($usage['active'] ?? 0) === 1) {
5161 if (!$active_usage) {
5162 $active_usage = $usage;
5163 } else {
5164 $dup_id = (int)($usage['id'] ?? 0);
5165 if ($dup_id > 0) {
5166 $db->update($this->dd_media_usage, array('active' => 0), $dup_id, 0, 1, 1, 0);
5167 $result['removed']++;
5168 }
5169 }
5170 continue;
5171 }
5172 if (!$inactive_usage) $inactive_usage = $usage;
5173 }
5174 }
5175
5176 if ($active_usage) {
5177 $usage_id = (int)($active_usage['id'] ?? 0);
5178 if ($usage_id > 0 && (int)($active_usage['folder_id'] ?? 0) !== $folder_id) {
5179 $db->update($this->dd_media_usage, array('folder_id' => $folder_id), $usage_id, 0, 1, 1, 0);
5180 }
5181 $result['kept']++;
5182 continue;
5183 }
5184
5185 if ($inactive_usage) {
5186 $usage_id = (int)($inactive_usage['id'] ?? 0);
5187 if ($usage_id > 0) {
5188 $db->update($this->dd_media_usage, array('active' => 1, 'folder_id' => $folder_id), $usage_id, 0, 1, 1, 0);
5189 $result['added']++;
5190 }
5191 continue;
5192 }
5193
5194 if ($this->create_media_usage($db, $id, $content_id, $folder_id, 'inline', 'image-inline', '', '', $content_lng) > 0) {
5195 $result['added']++;
5196 }
5197 }
5198
5199 return $result;
5200 }
5201
5202 private function next_media_sorter($db, $content_id, $slot) {
5203 $content_id = (int)$content_id;
5204 $slot = str_replace("'", "''", (string)$slot);
5205 $rows = $db->select($this->dd_media, "content_id = " . $content_id . " AND slot = '" . $slot . "' AND active = 1", '*', 'sorter,id', 'DESC', '', 1, 0, 0);
5206 $max = 0;
5207 if (is_array($rows) && isset($rows[0]) && is_array($rows[0])) {
5208 $max = (int)($rows[0]['sorter'] ?? 0);
5209 }
5210 return sprintf('%04d', $max + 10);
5211 }
5212
5213 private function deactivate_existing_hero_media($db, $content_id, $folder_id, $except_id = 0, $content_lng = '') {
5214 $content_id = (int)$content_id;
5215 $folder_id = (int)$folder_id;
5216 $except_id = (int)$except_id;
5217 $content_lng = dbxContentMediaUsageScope::language((string)$content_lng);
5218 $except = $except_id > 0 ? ' AND media_id <> ' . $except_id : '';
5219 if ($content_id > 0) {
5220 $db->update($this->dd_media_usage, array('active' => 0), dbxContentMediaUsageScope::withLanguage("content_id = " . $content_id . " AND slot = 'hero' AND active = 1" . $except, $content_lng), 0, 1, 1, 0);
5221 } elseif ($folder_id > 0) {
5222 $db->update($this->dd_media_usage, array('active' => 0), dbxContentMediaUsageScope::withLanguage("folder_id = " . $folder_id . " AND slot = 'hero' AND active = 1" . $except, $content_lng), 0, 1, 1, 0);
5223 }
5224 }
5225
5226 private function sync_saved_hero_media_usage($db, $content_id, $folder_id, $hero_image_id) {
5227 $content_id = (int)$content_id;
5228 $folder_id = (int)$folder_id;
5229 $hero_id = (int)$hero_image_id;
5230 if ($content_id <= 0 && $folder_id <= 0) return;
5231 if ($hero_id > 0) {
5232 $this->deactivate_existing_hero_media($db, $content_id, $folder_id, $hero_id);
5233 return;
5234 }
5235 $this->deactivate_existing_hero_media($db, $content_id, $folder_id, 0);
5236 }
5237
5238 private function is_no_hero_template($value) {
5239 $value = strtolower(trim((string)$value));
5240 return in_array($value, array('none', 'no-hero', '0', 'off'), true);
5241 }
5242
5243 private function normalize_hero_payload(array $data) {
5244 if ($this->is_no_hero_template($data['hero_template'] ?? '')) {
5245 $data['hero_template'] = 'none';
5246 $data['hero_image_id'] = '0';
5247 }
5248 return $data;
5249 }
5250
5251 private function source_media_file($row) {
5252 $rel = ltrim(str_replace('\\', '/', (string)($row['file_path'] ?? '')), '/');
5253 if ($rel === '' || strpos($rel, '..') !== false) return '';
5254 $root = rtrim(dbx()->get_file_dir(), '/\\') . '/';
5255 $root = dbx()->os_path($root);
5256 $base = realpath(rtrim($root, '/\\'));
5257 if (!$base) return '';
5258 $file = $root . $rel;
5259 $file = dbx()->os_path($file);
5260 $real = realpath($file);
5261 return $real && strpos($real, $base) === 0 && is_file($real) && is_readable($real) ? $real : '';
5262 }
5263
5264 private function copy_media_to_slot($db, array $row, $slot) {
5265 return 0;
5266 $slot = $this->valid_media_slot($slot);
5267 $src = $this->source_media_file($row);
5268 if ($src === '') return 0;
5269
5270 $dir = $this->cms_media_dir($slot);
5271 if (!is_dir($dir)) @mkdir($dir, 0777, true);
5272 if (!is_dir($dir)) return 0;
5273
5274 $name = $this->unique_media_name((string)($row['file_name'] ?? basename($src)), $slot);
5275 $dst = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $name;
5276 if (!copy($src, $dst)) return 0;
5277
5278 $rel = $this->media_rel_dir($slot) . $name;
5279 $mime = (string)($row['mime'] ?? '');
5280 $width = 0;
5281 $height = 0;
5282 $img = @getimagesize($dst);
5283 if (is_array($img)) {
5284 $width = (int)($img[0] ?? 0);
5285 $height = (int)($img[1] ?? 0);
5286 }
5287
5288 $insert = array(
5289 'active' => 1,
5290 'content_id' => (int)($row['content_id'] ?? 0),
5291 'folder_id' => (int)($row['folder_id'] ?? 0),
5292 'slot' => $slot,
5293 'usage' => $slot,
5294 'sorter' => $this->next_media_sorter($db, (int)($row['content_id'] ?? 0), $slot),
5295 'template' => (string)($row['template'] ?? ''),
5296 'title' => (string)($row['title'] ?? pathinfo($name, PATHINFO_FILENAME)),
5297 'alt' => (string)($row['alt'] ?? $row['title'] ?? ''),
5298 'caption' => (string)($row['caption'] ?? ''),
5299 'file_name' => $name,
5300 'file_path' => $rel,
5301 'mime' => $mime,
5302 'size' => (int)@filesize($dst),
5303 'width' => $width,
5304 'height' => $height,
5305 'tags' => (string)($row['tags'] ?? ''),
5306 'media_type' => $this->media_type(array('mime' => $mime, 'file_name' => $name)),
5307 );
5308 $thumb = $this->create_media_thumbnail($dst, $slot, $name, $mime);
5309 if ($thumb) $insert = array_merge($insert, $thumb);
5310 return ($db->insert($this->dd_media, $insert) === 1) ? $db->get_insert_id() : 0;
5311 }
5312
5313 private function set_media_slot_json() {
5314 $payload = $this->request_json();
5315 $usage_id = (int)($payload['usage_id'] ?? 0);
5316 $id = (int)($payload['media_id'] ?? $payload['id'] ?? 0);
5317 $slot = $this->valid_media_slot($payload['slot'] ?? 'gallery');
5318 if ($usage_id <= 0 && $id <= 0) $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Keine Medien- oder Usage-ID erhalten.'));
5319
5320 $db = dbx()->get_system_obj('dbxDB');
5321 $this->ensure_cms_schema($db);
5322 if ($usage_id <= 0) {
5323 $rows = $db->select($this->dd_media_usage, $this->usage_where($id), '*', 'id', 'DESC', '', 1, 0, 0);
5324 if (is_array($rows) && isset($rows[0])) $usage_id = (int)($rows[0]['id'] ?? 0);
5325 }
5326 if ($usage_id <= 0) {
5327 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Keine vorhandene Zuordnung gefunden.'));
5328 }
5329
5330 $usage = $db->select1($this->dd_media_usage, $usage_id);
5331 if (!is_array($usage) || (int)($usage['active'] ?? 0) !== 1) {
5332 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Zuordnung nicht gefunden.'));
5333 }
5334 if ($slot === 'hero') $this->deactivate_existing_hero_media($db, (int)($usage['content_id'] ?? 0), (int)($usage['folder_id'] ?? 0), (int)($usage['media_id'] ?? 0));
5335 $ok = $db->update($this->dd_media_usage, array('slot' => $slot), $usage_id);
5336 $usage = $this->normalize_usage_row($db->select1($this->dd_media_usage, $usage_id));
5337 $row = $this->normalize_media_row($db->select1($this->dd_media, (int)($usage['media_id'] ?? 0)));
5338 if ($ok >= 0) {
5339 $this->flush_media_cache($db, (int)($usage['content_id'] ?? 0), (int)($usage['folder_id'] ?? 0));
5340 }
5341 $this->cms_json_response(array('ok' => ($ok >= 0 ? 1 : 0), 'success' => ($ok >= 0), 'row' => $row, 'usage' => $usage, 'msg' => 'Zuordnung aktualisiert.'));
5342 }
5343
5344 private function assign_media_json() {
5345 $payload = $this->request_json();
5346 $id = (int)($payload['media_id'] ?? $payload['id'] ?? 0);
5347 $content_id = (int)($payload['content_id'] ?? 0);
5348 $folder_id = (int)($payload['folder_id'] ?? 0);
5349 $slot = $this->valid_media_slot($payload['slot'] ?? 'gallery');
5350 if ($id <= 0 || ($content_id <= 0 && !($folder_id > 0 && $slot === 'hero'))) {
5351 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Keine Zuordnung erhalten.'));
5352 }
5353
5354 $db = dbx()->get_system_obj('dbxDB');
5355 $this->ensure_cms_schema($db);
5356 $row = $db->select1($this->dd_media, $id);
5357 if (!is_array($row) || (int)($row['active'] ?? 0) !== 1) {
5358 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Medium nicht gefunden.'));
5359 }
5360
5361 $usage_id = $this->create_media_usage(
5362 $db,
5363 $id,
5364 $content_id,
5365 $folder_id,
5366 $slot,
5367 (string)($payload['template'] ?? $row['template'] ?? ''),
5368 (string)($payload['caption'] ?? ''),
5369 is_array($payload['settings'] ?? null) ? json_encode($payload['settings'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : (string)($payload['settings'] ?? '')
5370 );
5371 if ($usage_id <= 0) $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Medium konnte nicht zugeordnet werden.'));
5372 $usage = $this->normalize_usage_row($db->select1($this->dd_media_usage, $usage_id));
5373 $this->flush_media_cache($db, $content_id, $folder_id);
5374 $this->cms_json_response(array('ok' => 1, 'success' => true, 'id' => $id, 'usage_id' => $usage_id, 'row' => $this->normalize_media_row($row), 'usage' => $usage, 'msg' => 'Zuordnung angelegt.'));
5375 }
5376
5377 private function sort_media_json() {
5378 $payload = $this->request_json();
5379 $content_id = (int)($payload['content_id'] ?? 0);
5380 $folder_id = (int)($payload['folder_id'] ?? 0);
5381 $slot = $this->valid_media_slot($payload['slot'] ?? 'gallery');
5382 $ids = $payload['ids'] ?? array();
5383 if (($content_id <= 0 && $folder_id <= 0) || !is_array($ids)) {
5384 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Keine Sortierung erhalten.'));
5385 }
5386
5387 $db = dbx()->get_system_obj('dbxDB');
5388 $this->ensure_cms_schema($db);
5389 $sort = 10;
5390 foreach ($ids as $id) {
5391 $id = (int)$id;
5392 if ($id <= 0) continue;
5393 $where = dbxContentMediaUsageScope::withLanguage(
5394 "active = 1 AND slot = '" . str_replace("'", "''", $slot) . "'",
5395 dbxContentLng::current()
5396 );
5397 if ($content_id > 0) $where .= ' AND content_id = ' . $content_id;
5398 if ($folder_id > 0) $where .= ' AND folder_id = ' . $folder_id;
5399 $where .= ' AND (id = ' . $id . ' OR media_id = ' . $id . ')';
5400 $rows = $db->select($this->dd_media_usage, $where, '*', 'id', 'ASC', '', 1, 0, 0);
5401 if (!is_array($rows) || !isset($rows[0])) continue;
5402 $db->update($this->dd_media_usage, array('sorter' => sprintf('%04d', $sort)), (int)$rows[0]['id']);
5403 $sort += 10;
5404 }
5405 $this->flush_media_cache($db, $content_id, $folder_id);
5406 $this->cms_json_response(array('ok' => 1, 'success' => true));
5407 }
5408
5409 private function filter_existing_media($db, array $rows) {
5410 $out = array();
5411 foreach ($rows as $row) {
5412 if (!is_array($row)) continue;
5413 if ($this->media_file_exists($row)) {
5414 $out[] = $row;
5415 continue;
5416 }
5417 $id = (int)($row['id'] ?? 0);
5418 if ($id > 0) {
5419 $db->update($this->dd_media, array('active' => 0), $id);
5420 }
5421 }
5422 return $out;
5423 }
5424
5425 private function upload_json() {
5426 $content_length = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
5427 $max_upload = $this->upload_max_bytes();
5428 if ($content_length > 0 && $max_upload > 0 && $content_length > $max_upload) {
5429 $this->cms_json_response(array(
5430 'ok' => 0,
5431 'success' => false,
5432 'msg' => 'Upload zu gross. Erlaubt sind maximal ' . round($max_upload / 1024 / 1024) . ' MB.'
5433 ));
5434 }
5435 if (!$this->verify_media_form('upload')) {
5436 $this->cms_json_response(array(
5437 'ok' => 0,
5438 'success' => false,
5439 'msg' => 'Ungueltiger oder abgelaufener Formular-Token.'
5440 ));
5441 }
5442
5443 $file = $this->first_upload_file();
5444 if (empty($file) || !is_array($file)) {
5445 $this->cms_json_response(array('ok' => 0, 'msg' => 'Keine Datei erhalten. Bitte Upload-Limit und Dateigroesse pruefen.'));
5446 }
5447
5448 if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
5449 if (($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
5450 $this->cms_json_response(array('ok' => 0, 'msg' => 'Bitte zuerst eine Datei auswaehlen.'));
5451 }
5452 $this->cms_json_response(array('ok' => 0, 'msg' => 'Upload fehlgeschlagen.'));
5453 }
5454
5455 $name = basename((string)$file['name']);
5456 $name = preg_replace('/[^A-Za-z0-9_.-]+/', '-', $name);
5457 $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
5458 if (!in_array($ext, array('jpg','jpeg','png','gif','webp','svg','pdf','mp4','webm','ogv','ogg','mov','m4v'), true)) {
5459 $this->cms_json_response(array('ok' => 0, 'msg' => 'Dateityp nicht erlaubt.'));
5460 }
5461
5462 $db = dbx()->get_system_obj('dbxDB');
5463 $this->ensure_cms_schema($db);
5464 $content_id = (int)($_POST['content_id'] ?? 0);
5465 $folder_id = (int)($_POST['folder_id'] ?? 0);
5466 $slot = $this->valid_media_slot($_POST['slot'] ?? 'gallery');
5467 $probe_type = $this->media_type(array('file_name' => $name, 'mime' => (string)($file['type'] ?? '')));
5468 $media_folder = $this->clean_media_folder($_POST['media_folder'] ?? '', $probe_type);
5469 $media_rel_dir = $this->media_folder_rel_dir($media_folder, $probe_type);
5470 $dir = $this->cms_media_dir($media_folder);
5471 if (!is_dir($dir)) {
5472 mkdir($dir, 0777, true);
5473 }
5474
5475 $dstName = $this->unique_media_name($name);
5476 $dst = $dir . $dstName;
5477
5478 if (!move_uploaded_file($file['tmp_name'], $dst)) {
5479 $this->cms_json_response(array('ok' => 0, 'msg' => 'Datei konnte nicht gespeichert werden.'));
5480 }
5481
5482 $width = 0;
5483 $height = 0;
5484 $detected_mime = function_exists('mime_content_type') ? (string)@mime_content_type($dst) : '';
5485 $mime = $detected_mime !== '' ? $detected_mime : (string)($file['type'] ?? '');
5486 $img = @getimagesize($dst);
5487 if (is_array($img)) {
5488 $width = (int)($img[0] ?? 0);
5489 $height = (int)($img[1] ?? 0);
5490 }
5491 $max_image_size = max(800, min(6000, (int)($_POST['max_image_size'] ?? 2560)));
5492 if ($width > 0 && $height > 0 && $this->media_type(array('mime' => $mime, 'file_name' => $dstName)) === 'image') {
5493 if ($this->resize_image_to_max($dst, $mime, $max_image_size)) {
5494 clearstatcache(true, $dst);
5495 $img = @getimagesize($dst);
5496 if (is_array($img)) {
5497 $width = (int)($img[0] ?? 0);
5498 $height = (int)($img[1] ?? 0);
5499 }
5500 }
5501
5502 $webp = $this->convert_media_image_to_webp($dst, $dstName, $mime);
5503 if (is_array($webp) && !empty($webp['file']) && !empty($webp['name'])) {
5504 $dst = (string)$webp['file'];
5505 $dstName = (string)$webp['name'];
5506 $mime = (string)($webp['mime'] ?? 'image/webp');
5507 clearstatcache(true, $dst);
5508 $img = @getimagesize($dst);
5509 if (is_array($img)) {
5510 $width = (int)($img[0] ?? 0);
5511 $height = (int)($img[1] ?? 0);
5512 }
5513 }
5514 }
5515
5516 $media_tpl = $this->clean_text($_POST['template'] ?? '', 80);
5517 $file_size = (int)@filesize($dst);
5518 $title = pathinfo($name, PATHINFO_FILENAME);
5519 $media_type = $this->media_type(array('mime' => $mime, 'file_name' => $dstName));
5520 $media_folder = $this->clean_media_folder($media_folder, $media_type);
5521 if ($media_tpl === '') {
5522 $media_tpl = $media_type === 'video' ? ($slot === 'hero' ? 'video-hero' : 'video-gallery') : '';
5523 }
5524
5525 $existing = $db->select(
5526 $this->dd_media,
5527 "active = 1 AND storage_type = 'local' AND media_folder = '" . str_replace("'", "''", $media_folder) . "' AND title = '" . str_replace("'", "''", $title) . "' AND size = " . $file_size,
5528 '*',
5529 'id',
5530 'DESC'
5531 );
5532 if (is_array($existing) && isset($existing[0]) && $this->media_file_exists($existing[0])) {
5533 @unlink($dst);
5534 $row = $this->normalize_media_row($existing[0]);
5535 $usage = array();
5536 if ($content_id > 0 || $folder_id > 0) {
5537 $usage_id = $this->create_media_usage($db, (int)($row['id'] ?? 0), $content_id, $folder_id, $slot, $media_tpl, '', '');
5538 if ($usage_id > 0) $usage = $this->normalize_usage_row($db->select1($this->dd_media_usage, $usage_id));
5539 }
5540 $this->flush_media_cache($db, $content_id, $folder_id);
5541 $this->cms_json_response(array(
5542 'ok' => 1,
5543 'success' => true,
5544 'duplicate' => true,
5545 'row' => $row,
5546 'usage' => $usage,
5547 'files' => array($row['file_name'] ?? ''),
5548 'path' => '',
5549 'baseurl' => '',
5550 ));
5551 }
5552
5553 $insert = array(
5554 'active' => 1,
5555 'content_id' => 0,
5556 'folder_id' => 0,
5557 'slot' => '',
5558 'usage' => '',
5559 'sorter' => '',
5560 'template' => $media_tpl,
5561 'title' => $title,
5562 'alt' => $title,
5563 'file_name' => $dstName,
5564 'file_path' => $media_rel_dir . $dstName,
5565 'mime' => $mime,
5566 'size' => $file_size,
5567 'width' => $width,
5568 'height' => $height,
5569 'media_type' => $media_type,
5570 'storage_type' => 'local',
5571 'media_folder' => $media_folder,
5572 );
5573 $thumb = $this->create_media_thumbnail($dst, $media_folder, $dstName, $mime);
5574 if ($thumb) $insert = array_merge($insert, $thumb);
5575 $id = ($db->insert($this->dd_media, $insert) === 1) ? $db->get_insert_id() : 0;
5576
5577 if ($id > 0) {
5578 $usage = array();
5579 if ($content_id > 0 || $folder_id > 0) {
5580 $usage_id = $this->create_media_usage($db, $id, $content_id, $folder_id, $slot, $media_tpl, '', '');
5581 if ($usage_id > 0) $usage = $this->normalize_usage_row($db->select1($this->dd_media_usage, $usage_id));
5582 }
5583 $row = $this->normalize_media_row($db->select1($this->dd_media, $id));
5584 $baseurl = '';
5585 if (!empty($row['url'])) {
5586 $baseurl = preg_replace('~/[^/]*$~', '/', $row['url']);
5587 }
5588 $this->flush_media_cache($db, $content_id, $folder_id);
5589 $this->cms_json_response(array(
5590 'ok' => 1,
5591 'success' => true,
5592 'row' => $row,
5593 'usage' => $usage,
5594 'files' => array($row['file_name'] ?? $dstName),
5595 'path' => '',
5596 'baseurl' => $baseurl,
5597 ));
5598 }
5599
5600 $this->cms_json_response(array('ok' => 0, 'msg' => 'Mediendatensatz konnte nicht gespeichert werden.'));
5601 }
5602
5603 private function remove_media_json() {
5604 $id = (int)dbx()->get_modul_var('id', 0, 'int');
5605 $usage_id = (int)dbx()->get_modul_var('usage_id', 0, 'int');
5606 $payload = array();
5607 if ($id <= 0 || $usage_id <= 0) {
5608 $payload = $this->request_json();
5609 if ($usage_id <= 0) $usage_id = (int)($payload['usage_id'] ?? 0);
5610 if ($id <= 0) $id = (int)($payload['media_id'] ?? $payload['id'] ?? 0);
5611 }
5612 if ($usage_id <= 0 && $id <= 0) $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Keine Medien- oder Usage-ID erhalten.'));
5613
5614 $db = dbx()->get_system_obj('dbxDB');
5615 $this->ensure_cms_schema($db);
5616 $content_id = (int)($payload['content_id'] ?? dbx()->get_modul_var('content_id', 0, 'int'));
5617 $folder_id = (int)($payload['folder_id'] ?? dbx()->get_modul_var('folder_id', 0, 'int'));
5618 if ($usage_id > 0) {
5619 $usage_row = $db->select1($this->dd_media_usage, $usage_id);
5620 if (is_array($usage_row)) {
5621 if ($content_id <= 0) $content_id = (int)($usage_row['content_id'] ?? 0);
5622 if ($folder_id <= 0) $folder_id = (int)($usage_row['folder_id'] ?? 0);
5623 if ($id <= 0) $id = (int)($usage_row['media_id'] ?? 0);
5624 }
5625 $ok = $db->update($this->dd_media_usage, array('active' => 0), $usage_id);
5626 } else {
5627 $slot = trim((string)($payload['slot'] ?? dbx()->get_modul_var('slot', '', 'varchar')));
5628 if ($content_id <= 0 && $folder_id <= 0) {
5629 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Keine Seiten- oder Ordner-Zuordnung erhalten.'));
5630 }
5631 $where = 'media_id = ' . $id . ' AND active = 1';
5632 if ($content_id > 0) $where .= ' AND content_id = ' . $content_id;
5633 if ($folder_id > 0) $where .= ' AND folder_id = ' . $folder_id;
5634 if ($slot !== '') $where .= " AND slot = '" . str_replace("'", "''", $slot) . "'";
5635 $where = dbxContentMediaUsageScope::withLanguage($where, dbxContentLng::current());
5636 $ok = $db->update($this->dd_media_usage, array('active' => 0), $where, 0, 1, 1, 0);
5637 }
5638 $media = ($content_id > 0 || $folder_id > 0) ? $this->media_usage_rows_for_context($db, $content_id, $content_id > 0 ? 0 : $folder_id) : array();
5639 if ($ok >= 0) {
5640 $this->flush_media_cache($db, $content_id, $folder_id);
5641 }
5642 $this->cms_json_response(array('ok' => ($ok >= 0 ? 1 : 0), 'success' => ($ok >= 0), 'id' => $id, 'usage_id' => $usage_id, 'media' => $media, 'rows' => $media, 'msg' => 'Zuordnung entfernt.'));
5643 }
5644
5645 private function delete_media_json() {
5646 $id = (int)dbx()->get_modul_var('id', 0, 'int');
5647 $force = (int)dbx()->get_modul_var('force', 0, 'int');
5648 if ($id <= 0) {
5649 $payload = $this->request_json();
5650 $id = (int)($payload['media_id'] ?? $payload['id'] ?? 0);
5651 $force = (int)($payload['force'] ?? 0);
5652 }
5653 if ($id <= 0) $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Keine Medien-ID erhalten.'));
5654
5655 $db = dbx()->get_system_obj('dbxDB');
5656 $this->ensure_cms_schema($db);
5657 $used = $db->select($this->dd_media_usage, $this->usage_where($id), '*', 'id', 'ASC', '', 1, 0, 0);
5658 if (is_array($used) && isset($used[0]) && !$force) {
5659 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Medium wird noch verwendet.'));
5660 }
5661 $row = $db->select1($this->dd_media, $id);
5662 if (is_array($row)) {
5663 $this->flush_media_by_media_id($db, $id);
5664 if ($force) $db->update($this->dd_media_usage, array('active' => 0), 'media_id = ' . $id . ' AND active = 1', 0, 1, 1, 0);
5665 if (strtolower((string)($row['storage_type'] ?? 'local')) === 'local') {
5666 $file = $this->source_media_file($row);
5667 if ($file !== '') @unlink($file);
5668 } else {
5669 $file = $this->source_media_file($row);
5670 if ($file !== '' && preg_match('/\.json$/i', $file)) @unlink($file);
5671 }
5672 $thumb = $this->source_thumb_file($row);
5673 if ($thumb !== '') @unlink($thumb);
5674 }
5675 $ok = $db->update($this->dd_media, array('active' => 0), $id);
5676 $this->cms_json_response(array('ok' => ($ok >= 0 ? 1 : 0), 'success' => ($ok >= 0), 'id' => $id, 'msg' => 'Medium geloescht.'));
5677 }
5678
5679 private function youtube_video_id($url) {
5680 $url = trim((string)$url);
5681 if ($url === '') return '';
5682 if (preg_match('~^[A-Za-z0-9_-]{11}$~', $url)) return $url;
5683 $parts = @parse_url($url);
5684 if (!is_array($parts)) return '';
5685 $host = strtolower((string)($parts['host'] ?? ''));
5686 $path = trim((string)($parts['path'] ?? ''), '/');
5687 if (strpos($host, 'youtu.be') !== false && preg_match('~^([A-Za-z0-9_-]{11})~', $path, $m)) return $m[1];
5688 if (strpos($host, 'youtube.com') !== false) {
5689 parse_str((string)($parts['query'] ?? ''), $query);
5690 if (!empty($query['v']) && preg_match('~^[A-Za-z0-9_-]{11}$~', (string)$query['v'])) return (string)$query['v'];
5691 if (preg_match('~^(embed|shorts)/([A-Za-z0-9_-]{11})~', $path, $m)) return $m[2];
5692 }
5693 return '';
5694 }
5695
5696 private function external_video_json() {
5697 if (!$this->verify_media_form('external')) {
5698 $this->cms_json_response(array(
5699 'ok' => 0,
5700 'success' => false,
5701 'msg' => 'Ungueltiger oder abgelaufener Formular-Token.'
5702 ));
5703 }
5704 $payload = $this->request_json();
5705 $provider = strtolower(trim((string)($payload['provider'] ?? 'youtube')));
5706 $external_url = trim((string)($payload['external_url'] ?? $payload['url'] ?? ''));
5707 if ($provider !== 'youtube') $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Provider nicht unterstuetzt.'));
5708 $video_id = $this->youtube_video_id($external_url);
5709 if ($video_id === '') $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'YouTube-URL nicht erkannt.'));
5710
5711 $db = dbx()->get_system_obj('dbxDB');
5712 $this->ensure_cms_schema($db);
5713 $content_id = (int)($payload['content_id'] ?? 0);
5714 $folder_id = (int)($payload['folder_id'] ?? 0);
5715 $slot = $this->valid_media_slot($payload['slot'] ?? 'gallery');
5716 $media_folder = $this->clean_media_folder($payload['media_folder'] ?? 'youtube', 'external_video');
5717 $title = $this->clean_text($payload['title'] ?? ('YouTube ' . $video_id), 160);
5718 $embed_url = 'https://www.youtube.com/embed/' . $video_id;
5719 $thumb_url = 'https://img.youtube.com/vi/' . $video_id . '/hqdefault.jpg';
5720
5721 $dir = $this->cms_media_dir($media_folder);
5722 if (!is_dir($dir)) @mkdir($dir, 0777, true);
5723 $rel = $this->media_folder_rel_dir($media_folder, 'external_video') . $video_id . '.json';
5724 $file = $this->file_from_media_rel($rel);
5725 if ($file !== '') {
5726 @file_put_contents($file, json_encode(array(
5727 'provider' => 'youtube',
5728 'provider_id' => $video_id,
5729 'external_url' => $external_url,
5730 'embed_url' => $embed_url,
5731 'title' => $title,
5732 'thumb_url' => $thumb_url,
5733 ), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
5734 }
5735 $existing = $db->select($this->dd_media, "active = 1 AND provider = 'youtube' AND provider_id = '" . str_replace("'", "''", $video_id) . "'", '*', 'id', 'DESC', '', 1, 0, 0);
5736 if (is_array($existing) && isset($existing[0])) {
5737 $row = $existing[0];
5738 $update = array(
5739 'media_folder' => $media_folder,
5740 'file_name' => $video_id . '.json',
5741 'file_path' => $rel,
5742 'mime' => 'application/json',
5743 'media_type' => 'external_video',
5744 'storage_type' => 'external',
5745 'external_url' => $external_url,
5746 'embed_url' => $embed_url,
5747 'title' => $title,
5748 'size' => $file !== '' && is_file($file) ? (int)@filesize($file) : 0,
5749 );
5750 $db->update($this->dd_media, $update, (int)($row['id'] ?? 0));
5751 $row = array_merge($row, $update);
5752 } else {
5753 $id = 0;
5754 if ($db->insert($this->dd_media, array(
5755 'active' => 1,
5756 'content_id' => 0,
5757 'folder_id' => 0,
5758 'slot' => '',
5759 'usage' => '',
5760 'sorter' => '',
5761 'template' => 'video-gallery',
5762 'title' => $title,
5763 'alt' => $title,
5764 'file_name' => $video_id . '.json',
5765 'file_path' => $rel,
5766 'mime' => 'application/json',
5767 'size' => $file !== '' && is_file($file) ? (int)@filesize($file) : 0,
5768 'width' => 0,
5769 'height' => 0,
5770 'media_type' => 'external_video',
5771 'storage_type' => 'external',
5772 'media_folder' => $media_folder,
5773 'provider' => 'youtube',
5774 'provider_id' => $video_id,
5775 'external_url' => $external_url,
5776 'embed_url' => $embed_url,
5777 )) === 1) {
5778 $id = $db->get_insert_id();
5779 }
5780 $row = $db->select1($this->dd_media, $id);
5781 }
5782
5783 $usage = array();
5784 if (is_array($row) && ((int)($row['id'] ?? 0) > 0) && ($content_id > 0 || $folder_id > 0)) {
5785 $usage_id = $this->create_media_usage($db, (int)$row['id'], $content_id, $folder_id, $slot, 'video-gallery', '', '');
5786 if ($usage_id > 0) $usage = $this->normalize_usage_row($db->select1($this->dd_media_usage, $usage_id));
5787 }
5788 $this->flush_media_cache($db, $content_id, $folder_id);
5789 $this->cms_json_response(array('ok' => 1, 'success' => true, 'row' => $this->normalize_media_row($row), 'usage' => $usage, 'msg' => 'Externes Video gespeichert.'));
5790 }
5791
5792 private function media_dir_has_content($dir) {
5793 if (!is_dir($dir) || !is_readable($dir)) return false;
5794 $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS));
5795 foreach ($it as $item) {
5796 if ($item->isFile()) return true;
5797 }
5798 return false;
5799 }
5800
5801 private function is_reserved_media_folder($folder) {
5802 $folder = $this->canonical_media_folder(strtolower(trim(str_replace('\\', '/', (string)$folder), '/')));
5803 if ($folder === 'module' || strpos($folder, 'module/') === 0) return true;
5804 if (preg_match('~^img/external(/|$)~', $folder)) return true;
5805 if (preg_match('~^img/video(/|$)~', $folder)) return true;
5806 return in_array($folder, $this->media_slots(), true);
5807 }
5808
5809 private function media_folder_dir_is_listable($folder, $path, $base = '') {
5810 return is_dir($path) && is_readable($path);
5811 }
5812
5813 private function media_folder_is_listable($folder) {
5814 $folder = $this->canonical_media_folder($folder);
5815 if ($folder === '' || $folder === 'module') return false;
5816 if ($this->is_reserved_media_folder($folder)) return false;
5817 $dir = $this->cms_media_dir($folder);
5818 return is_dir($dir) && is_readable($dir);
5819 }
5820
5821 private function collect_custom_media_root_folders(array &$folders) {
5822 $mediaRoot = rtrim(dbx()->get_file_dir(), '/\\') . '/media/';
5823 $mediaRoot = dbx()->os_path($mediaRoot);
5824 if (!is_dir($mediaRoot) || !is_readable($mediaRoot)) return;
5825 $skip = array('.', '..', '_thumbs', 'img', 'video', 'videos', 'youtube', 'external', 'file', 'module');
5826 $skip = array_merge($skip, $this->media_slots());
5827 $root_norm = str_replace('\\', '/', rtrim($mediaRoot, '/\\')) . '/';
5828 $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($mediaRoot, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST);
5829 foreach ($it as $item) {
5830 if (!$item->isDir()) continue;
5831 $path = str_replace('\\', '/', $item->getPathname());
5832 if (strpos($path . '/', $root_norm) !== 0) continue;
5833 $rel = trim(substr($path, strlen($root_norm)), '/');
5834 if ($rel === '') continue;
5835 $top = strtok($rel, '/');
5836 if ($top === false || in_array($top, $skip, true)) continue;
5837 if ($this->is_reserved_media_folder($rel)) continue;
5838 $folders[] = $rel;
5839 }
5840 }
5841
5842 private function media_folders_json() {
5843 $bases = $this->media_standard_bases();
5844 $folders = array();
5845 foreach ($bases as $base => $type) {
5846 $root = $this->cms_media_base_dir($base);
5847 if (!is_dir($root) || !is_readable($root)) continue;
5848 if ($base === 'youtube' || $type === 'video') {
5849 $folders[] = $this->clean_media_folder($base, $type);
5850 }
5851 $root_norm = str_replace('\\', '/', rtrim($root, '/\\')) . '/';
5852 $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST);
5853 foreach ($it as $item) {
5854 if (!$item->isDir()) continue;
5855 $path = str_replace('\\', '/', $item->getPathname());
5856 if (strpos($path . '/', $root_norm) !== 0) continue;
5857 $rel = trim(substr($path, strlen($root_norm)), '/');
5858 if ($rel === '') continue;
5859 $folder = $this->clean_media_folder($base . '/' . $rel, $type);
5860 if ($this->is_reserved_media_folder($folder)) continue;
5861 if (!$this->media_folder_dir_is_listable($folder, $path, $base)) continue;
5862 $folders[] = $folder;
5863 }
5864 }
5865 $this->collect_custom_media_root_folders($folders);
5866
5867 $folders = array_values(array_unique(array_map(array($this, 'canonical_media_folder'), $folders)));
5868 $verified = array();
5869 foreach ($folders as $folder) {
5870 $folder = $this->canonical_media_folder($folder);
5871 if ($folder === '' || $this->is_excluded_cms_media_folder($folder)) continue;
5872 $dir = $this->cms_media_dir($folder);
5873 if (!is_dir($dir) || !is_readable($dir)) continue;
5874 $verified[] = $folder;
5875 }
5876 $folders = $verified;
5877 sort($folders);
5878 $this->cms_json_response(array('ok' => 1, 'success' => true, 'root' => 'files/media/', 'folders' => $folders));
5879 }
5880
5881 private function media_folder_create_json() {
5882 $payload = $this->request_json();
5883 $type = $this->media_type(array('media_type' => ($payload['media_type'] ?? 'image')));
5884 $folder = $this->clean_media_folder($payload['media_folder'] ?? $payload['folder'] ?? '', $type);
5885 if ($this->is_excluded_cms_media_folder($folder)) {
5886 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Dieser Ordner ist im CMS-Medienbrowser nicht verfuegbar.'));
5887 }
5888 $dir = $this->cms_media_dir($folder);
5889 if (!is_dir($dir) && !@mkdir($dir, 0777, true)) {
5890 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Ordner konnte nicht angelegt werden.'));
5891 }
5892 $this->cms_json_response(array('ok' => 1, 'success' => true, 'folder' => $folder, 'msg' => 'Ordner angelegt.'));
5893 }
5894
5895 private function media_folder_delete_json() {
5896 $payload = $this->request_json();
5897 $raw_folder = $payload['media_folder'] ?? $payload['folder'] ?? '';
5898 $type = $this->media_type(array('media_type' => ($payload['media_type'] ?? $this->media_type_from_folder($raw_folder))));
5899 $folder = $this->clean_media_folder($raw_folder, $type);
5900 if ($this->is_excluded_cms_media_folder($folder)) {
5901 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Dieser Ordner ist im CMS-Medienbrowser nicht verfuegbar.'));
5902 }
5903 $dir = $this->cms_media_dir($folder);
5904 if (!is_dir($dir)) $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Ordner nicht gefunden.'));
5905
5906 $db = dbx()->get_system_obj('dbxDB');
5907 $this->ensure_cms_schema($db);
5908 $used = $db->select($this->dd_media, "active = 1 AND media_folder = '" . str_replace("'", "''", $folder) . "'", '*', 'id', 'ASC', '', 1, 0, 0);
5909 if (is_array($used) && isset($used[0])) $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Ordner enthaelt noch Medien.'));
5910
5911 $items = @scandir($dir);
5912 $items = is_array($items) ? array_diff($items, array('.', '..')) : array();
5913 if (!empty($items)) $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Ordner ist nicht leer.'));
5914 if (!@rmdir($dir)) $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Ordner konnte nicht geloescht werden.'));
5915 $this->cms_json_response(array('ok' => 1, 'success' => true, 'folder' => $folder, 'msg' => 'Ordner geloescht.'));
5916 }
5917
5918 private function media_folder_rename_json() {
5919 $payload = $this->request_json();
5920 $from = $this->clean_media_folder($payload['from_folder'] ?? $payload['media_folder_from'] ?? $payload['from'] ?? '', 'image');
5921 $to_raw = trim((string)($payload['to_folder'] ?? $payload['media_folder_to'] ?? $payload['to'] ?? ''));
5922 if ($to_raw !== '' && strpos($to_raw, '/') === false && $from !== '') {
5923 $base = strtok($from, '/');
5924 $to_raw = ($base !== false && $base !== '' ? $base : 'img') . '/' . $to_raw;
5925 }
5926 $to = $this->clean_media_folder($to_raw, $this->media_type_from_folder($to_raw !== '' ? $to_raw : $from));
5927 if ($from === '' || $to === '' || $from === $to) {
5928 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Ungueltiger Ordner.'));
5929 }
5930 if ($this->is_excluded_cms_media_folder($from) || $this->is_excluded_cms_media_folder($to)) {
5931 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Dieser Ordner ist im CMS-Medienbrowser nicht verfuegbar.'));
5932 }
5933 $from_dir = $this->cms_media_dir($from);
5934 $to_dir = $this->cms_media_dir($to);
5935 if (!is_dir($from_dir) || !is_readable($from_dir)) {
5936 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Quellordner nicht gefunden.'));
5937 }
5938 if (is_dir($to_dir)) {
5939 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Zielordner existiert bereits.'));
5940 }
5941 if (!@rename($from_dir, $to_dir)) {
5942 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Ordner konnte nicht umbenannt werden.'));
5943 }
5944
5945 $db = dbx()->get_system_obj('dbxDB');
5946 $this->ensure_cms_schema($db);
5947 $from_prefix = 'media/' . $from . '/';
5948 $to_prefix = 'media/' . $to . '/';
5949 $thumb_from = 'media/_thumbs/' . $from . '/';
5950 $thumb_to = 'media/_thumbs/' . $to . '/';
5951 $thumb_from_dir = $this->file_from_media_rel($thumb_from);
5952 $thumb_to_dir = $this->file_from_media_rel($thumb_to);
5953 if ($thumb_from_dir !== '' && is_dir($thumb_from_dir) && $thumb_to_dir !== '' && !is_dir($thumb_to_dir)) {
5954 @rename($thumb_from_dir, $thumb_to_dir);
5955 }
5956
5957 $rows = $db->select($this->dd_media, "active = 1 AND (media_folder = '" . str_replace("'", "''", $from) . "' OR file_path LIKE '" . str_replace("'", "''", $from_prefix) . "%')", '*', 'id');
5958 if (!is_array($rows)) $rows = array();
5959 $updated = 0;
5960 foreach ($rows as $row) {
5961 if (!is_array($row)) continue;
5962 $id = (int)($row['id'] ?? 0);
5963 if ($id <= 0) continue;
5964 $path = ltrim(str_replace('\\', '/', (string)($row['file_path'] ?? '')), '/');
5965 $new_path = $path;
5966 if (strpos($path, $from_prefix) === 0) {
5967 $new_path = $to_prefix . substr($path, strlen($from_prefix));
5968 }
5969 $update = array(
5970 'media_folder' => $to,
5971 'file_path' => $new_path,
5972 );
5973 $thumb = ltrim(str_replace('\\', '/', (string)($row['thumb_file_path'] ?? '')), '/');
5974 if ($thumb !== '' && strpos($thumb, $thumb_from) === 0) {
5975 $update['thumb_file_path'] = $thumb_to . substr($thumb, strlen($thumb_from));
5976 }
5977 $db->update($this->dd_media, $update, $id);
5978 $updated++;
5979 }
5980
5981 $this->cms_json_response(array(
5982 'ok' => 1,
5983 'success' => true,
5984 'from_folder' => $from,
5985 'to_folder' => $to,
5986 'updated' => $updated,
5987 'msg' => 'Ordner umbenannt.',
5988 ));
5989 }
5990
5991 private function media_is_used($db, int $id): bool {
5992 if ($id <= 0) return true;
5993 $used = $db->select(
5994 $this->dd_media_usage,
5995 'media_id = ' . $id . ' AND active = 1',
5996 'id',
5997 'id',
5998 'ASC',
5999 '',
6000 1,
6001 0,
6002 0
6003 );
6004 if (is_array($used) && isset($used[0])) return true;
6005
6006 foreach (dbxContentLngSync::accessibleLngs() as $lng) {
6007 $contentDd = dbxContentLng::ddContent((string)$lng);
6008 $folderDd = dbxContentLng::ddFolder((string)$lng);
6009 if ($db->count($contentDd, 'hero_image_id = ' . $id . ' OR seo_image_id = ' . $id) > 0 || $db->count($folderDd, 'hero_image_id = ' . $id) > 0) {
6010 return true;
6011 }
6012 $pages = $db->select($contentDd, '', 'content', 'id', 'ASC', '', 0, 0, 0);
6013 foreach (is_array($pages) ? $pages : array() as $page) {
6014 $content = (string)($page['content'] ?? '');
6015 if (preg_match('/data-cms-media-id=["\']?' . $id . '(?:["\'\s>]|$)/i', $content)
6016 || preg_match('/(?:dbx_mid|media_id)=' . $id . '(?:[^0-9]|$)/i', $content)) {
6017 return true;
6018 }
6019 }
6020 }
6021 return false;
6022 }
6023
6024 private function move_media_record_to_folder($db, int $id, string $target_folder): array {
6025 if ($id <= 0) return array('ok' => 0, 'msg' => 'Keine Medien-ID erhalten.');
6026 $row = $db->select1($this->dd_media, $id);
6027 if (!is_array($row) || (int)($row['active'] ?? 0) !== 1) return array('ok' => 0, 'msg' => 'Medium nicht gefunden.');
6028
6029 $media_type = $this->media_type($row);
6030 $folder = $this->clean_media_folder($target_folder, $media_type);
6031 if ($this->is_excluded_cms_media_folder($folder)) {
6032 return array('ok' => 0, 'msg' => 'Dieser Zielordner ist im CMS-Medienbrowser nicht verfuegbar.');
6033 }
6034 $old_file = $this->source_media_file($row);
6035 $name = basename((string)($row['file_name'] ?? $row['file_path'] ?? ''));
6036 if ($name === '') return array('ok' => 0, 'msg' => 'Dateiname fehlt.');
6037 $dir = $this->cms_media_dir($folder);
6038 if (!is_dir($dir)) @mkdir($dir, 0777, true);
6039 if (!is_dir($dir)) return array('ok' => 0, 'msg' => 'Zielordner konnte nicht angelegt werden.');
6040 $new_rel = $this->media_folder_rel_dir($folder, $media_type) . $name;
6041 $new_file = $this->file_from_media_rel($new_rel);
6042 if ($old_file !== '' && $new_file !== '' && $old_file !== $new_file) {
6043 if (is_file($new_file)) {
6044 $name = $this->unique_media_name($name);
6045 $new_rel = $this->media_folder_rel_dir($folder, $media_type) . $name;
6046 $new_file = $this->file_from_media_rel($new_rel);
6047 }
6048 if (!@rename($old_file, $new_file)) return array('ok' => 0, 'msg' => 'Medium konnte nicht verschoben werden.');
6049 }
6050 $old_thumb = $this->source_thumb_file($row);
6051 $mime = (string)($row['mime'] ?? '');
6052 $thumb = $this->create_media_thumbnail($new_file !== '' ? $new_file : $old_file, $folder, $name, $mime);
6053 if ($old_thumb !== '' && is_file($old_thumb) && $old_thumb !== $this->file_from_media_rel((string)($thumb['thumb_file_path'] ?? ''))) {
6054 @unlink($old_thumb);
6055 }
6056 $update = array('active' => 1, 'media_folder' => $folder, 'file_name' => $name, 'file_path' => $new_rel);
6057 if ($thumb) $update = array_merge($update, $thumb);
6058 $ok = $db->update($this->dd_media, $update, $id, 0, 1, 1, 0);
6059 if ($ok < 0) return array('ok' => 0, 'msg' => 'Mediendaten konnten nicht aktualisiert werden.');
6060 $row = $this->normalize_media_row($db->select1($this->dd_media, $id));
6061 if (!is_array($row) || (int)($row['active'] ?? 0) !== 1 || (string)($row['media_folder'] ?? '') !== $folder) {
6062 return array('ok' => 0, 'msg' => 'Mediendaten wurden nicht konsistent gespeichert.');
6063 }
6064 return array('ok' => 1, 'success' => true, 'row' => $row, 'msg' => 'Medium verschoben.');
6065 }
6066
6067 private function media_move_json() {
6068 $payload = $this->request_json();
6069 $id = (int)($payload['media_id'] ?? $payload['id'] ?? 0);
6070 if ($id <= 0) $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Keine Medien-ID erhalten.'));
6071
6072 $db = dbx()->get_system_obj('dbxDB');
6073 $this->ensure_cms_schema($db);
6074 $result = $this->move_media_record_to_folder($db, $id, (string)($payload['media_folder'] ?? $payload['folder'] ?? ''));
6075 $this->cms_json_response($result);
6076 }
6077
6078 private function media_unused_json() {
6079 $payload = $this->request_json();
6080 $action = strtolower(trim((string)($payload['action'] ?? '')));
6081 if (!in_array($action, array('delete', 'move'), true)) {
6082 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Ungueltige Aktion.'));
6083 }
6084
6085 $source_raw = trim((string)($payload['source_folder'] ?? $payload['media_folder'] ?? 'all'));
6086 $source = strtolower($source_raw) === 'all' || $source_raw === ''
6087 ? 'all'
6088 : $this->clean_media_folder($source_raw, $this->media_type_from_folder($source_raw, 'image'));
6089 if ($source !== 'all' && $this->is_excluded_cms_media_folder($source)) {
6090 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Dieser Quellordner ist im CMS-Medienbrowser nicht verfuegbar.'));
6091 }
6092
6093 $target = '';
6094 if ($action === 'move') {
6095 $target_raw = trim((string)($payload['target_folder'] ?? $payload['to_folder'] ?? ''));
6096 if ($target_raw === '') {
6097 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Bitte einen Zielordner waehlen.'));
6098 }
6099 $target = $this->clean_media_folder($target_raw, 'image');
6100 if ($target === '' || $this->is_excluded_cms_media_folder($target)) {
6101 $this->cms_json_response(array('ok' => 0, 'success' => false, 'msg' => 'Bitte einen gueltigen Zielordner waehlen.'));
6102 }
6103 }
6104
6105 $db = dbx()->get_system_obj('dbxDB');
6106 $this->ensure_cms_schema($db);
6107 $this->sync_cms_media_files($db);
6108
6109 $where = "active = 1"
6110 . $this->cms_media_exclude_sql()
6111 . " AND (mime LIKE 'image/%' OR file_name LIKE '%.jpg' OR file_name LIKE '%.jpeg' OR file_name LIKE '%.png' OR file_name LIKE '%.gif' OR file_name LIKE '%.webp' OR file_name LIKE '%.svg')";
6112 if ($source !== 'all') {
6113 $where .= " AND media_folder = '" . str_replace("'", "''", $source) . "'";
6114 }
6115 if ($action === 'move') {
6116 $where .= " AND media_folder <> '" . str_replace("'", "''", $target) . "'";
6117 }
6118
6119 $rows = $db->select($this->dd_media, $where, '*', 'media_folder,title,id', 'ASC', '', 0, 0, 0);
6120 if (!is_array($rows)) $rows = array();
6121 $rows = $this->filter_existing_media($db, $rows);
6122
6123 $checked = 0;
6124 $affected = 0;
6125 $skipped_used = 0;
6126 $errors = array();
6127 $moved_rows = array();
6128
6129 foreach ($rows as $row) {
6130 if (!is_array($row)) continue;
6131 $id = (int)($row['id'] ?? 0);
6132 if ($id <= 0) continue;
6133 $checked++;
6134 if ($this->media_is_used($db, $id)) {
6135 $skipped_used++;
6136 continue;
6137 }
6138 if ($action === 'delete') {
6139 $result = $this->delete_media_record($id);
6140 if ((int)($result['ok'] ?? 0) === 1) {
6141 $affected++;
6142 } else {
6143 $errors[] = '#' . $id . ': ' . implode(' ', (array)($result['errors'] ?? array('Loeschen fehlgeschlagen.')));
6144 }
6145 continue;
6146 }
6147 $result = $this->move_media_record_to_folder($db, $id, $target);
6148 if ((int)($result['ok'] ?? 0) === 1) {
6149 $affected++;
6150 if (!empty($result['row']) && is_array($result['row'])) $moved_rows[] = $result['row'];
6151 } else {
6152 $errors[] = '#' . $id . ': ' . (string)($result['msg'] ?? 'Verschieben fehlgeschlagen.');
6153 }
6154 }
6155
6156 $label = $action === 'delete' ? 'geloescht' : 'verschoben';
6157 $msg = $affected . ' unbenutzte Bilder ' . $label . '.';
6158 if ($skipped_used > 0) $msg .= ' ' . $skipped_used . ' verwendete Bilder uebersprungen.';
6159 if (!empty($errors)) $msg .= ' ' . count($errors) . ' Fehler.';
6160
6161 $this->cms_json_response(array(
6162 'ok' => empty($errors) ? 1 : ($affected > 0 ? 1 : 0),
6163 'success' => empty($errors) || $affected > 0,
6164 'action' => $action,
6165 'source_folder' => $source,
6166 'target_folder' => $target,
6167 'checked' => $checked,
6168 'affected' => $affected,
6169 'skipped_used' => $skipped_used,
6170 'errors' => $errors,
6171 'rows' => $moved_rows,
6172 'msg' => $msg,
6173 ));
6174 }
6175
6176 private function edit_image_file($file, $mime, array $op) {
6177 if (!extension_loaded('gd') || !function_exists('imagecreatetruecolor')) return false;
6178 $img = $this->load_gd_image($file, $mime);
6179 if (!$img) return false;
6180
6181 $src_w = imagesx($img);
6182 $src_h = imagesy($img);
6183 if ($src_w <= 0 || $src_h <= 0) {
6184 imagedestroy($img);
6185 return false;
6186 }
6187
6188 $action = strtolower((string)($op['action'] ?? 'resize'));
6189 if ($action === 'crop') {
6190 $x = max(0, min($src_w - 1, (int)($op['x'] ?? 0)));
6191 $y = max(0, min($src_h - 1, (int)($op['y'] ?? 0)));
6192 $w = max(1, min($src_w - $x, (int)($op['width'] ?? $src_w)));
6193 $h = max(1, min($src_h - $y, (int)($op['height'] ?? $src_h)));
6194 $dst = imagecreatetruecolor($w, $h);
6195 imagefill($dst, 0, 0, imagecolorallocate($dst, 255, 255, 255));
6196 imagecopyresampled($dst, $img, 0, 0, $x, $y, $w, $h, $w, $h);
6197 } else {
6198 $w = (int)($op['width'] ?? 0);
6199 $h = (int)($op['height'] ?? 0);
6200 $ratio = !empty($op['ratio']);
6201 if ($w <= 0 && $h <= 0) {
6202 imagedestroy($img);
6203 return false;
6204 }
6205 if ($w <= 0) $w = max(1, (int)round($src_w * ($h / $src_h)));
6206 if ($h <= 0) $h = max(1, (int)round($src_h * ($w / $src_w)));
6207 if ($ratio && $w > 0 && $h > 0) {
6208 $scale = min($w / $src_w, $h / $src_h);
6209 $w = max(1, (int)round($src_w * $scale));
6210 $h = max(1, (int)round($src_h * $scale));
6211 }
6212 $dst = imagecreatetruecolor($w, $h);
6213 imagefill($dst, 0, 0, imagecolorallocate($dst, 255, 255, 255));
6214 imagecopyresampled($dst, $img, 0, 0, 0, 0, $w, $h, $src_w, $src_h);
6215 }
6216
6217 $ok = $this->save_gd_image($dst, $file, $mime);
6218 imagedestroy($dst);
6219 imagedestroy($img);
6220 return $ok;
6221 }
6222
6223 private function edit_media_json() {
6224 try {
6225 $payload = $this->request_json();
6226 $ids = array();
6227 if (isset($payload['ids']) && is_array($payload['ids'])) {
6228 foreach ($payload['ids'] as $id) {
6229 $id = (int)$id;
6230 if ($id > 0) $ids[$id] = $id;
6231 }
6232 } else {
6233 $id = (int)($payload['id'] ?? 0);
6234 if ($id > 0) $ids[$id] = $id;
6235 }
6236 if (!$ids) $this->cms_json_response(array('ok' => 0, 'msg' => 'Keine Medien ausgewaehlt.'));
6237
6238 $action = strtolower((string)($payload['action'] ?? 'resize'));
6239 if (!in_array($action, array('resize', 'crop'), true)) $action = 'resize';
6240 if ($action === 'crop' && count($ids) > 1) $this->cms_json_response(array('ok' => 0, 'msg' => 'Zuschneiden ist nur fuer ein Bild moeglich.'));
6241
6242 $db = dbx()->get_system_obj('dbxDB');
6243 $this->ensure_cms_schema($db);
6244 $done = array();
6245 foreach ($ids as $id) {
6246 $row = $db->select1($this->dd_media, $id);
6247 if (!is_array($row) || (int)($row['active'] ?? 0) !== 1 || $this->media_type($row) !== 'image') continue;
6248 if (preg_match('/\.svg$/i', (string)($row['file_name'] ?? ''))) continue;
6249 $file = $this->source_media_file($row);
6250 if ($file === '') continue;
6251 $mime = (string)($row['mime'] ?? '');
6252 if (!$this->edit_image_file($file, $mime, array(
6253 'action' => $action,
6254 'x' => (int)($payload['x'] ?? 0),
6255 'y' => (int)($payload['y'] ?? 0),
6256 'width' => (int)($payload['width'] ?? 0),
6257 'height' => (int)($payload['height'] ?? 0),
6258 'ratio' => !empty($payload['ratio']),
6259 ))) continue;
6260
6261 $old_thumb = $this->source_thumb_file($row);
6262 if ($old_thumb !== '') @unlink($old_thumb);
6263 $size = @getimagesize($file);
6264 $update = array(
6265 'size' => (int)@filesize($file),
6266 'width' => is_array($size) ? (int)$size[0] : 0,
6267 'height' => is_array($size) ? (int)$size[1] : 0,
6268 'media_type' => 'image',
6269 'thumb_file_path' => '',
6270 'thumb_width' => 0,
6271 'thumb_height' => 0,
6272 );
6273 $media_folder = $this->canonical_media_folder(trim((string)($row['media_folder'] ?? '')) ?: $this->media_folder_from_path((string)($row['file_path'] ?? ''), $this->media_type($row)));
6274 $thumb = $this->create_media_thumbnail($file, $media_folder, (string)($row['file_name'] ?? basename($file)), $mime);
6275 if ($thumb) $update = array_merge($update, $thumb);
6276 $db->update($this->dd_media, $update, $id);
6277 $done[] = $this->normalize_media_row($db->select1($this->dd_media, $id));
6278 }
6279
6280 if (!$done) $this->cms_json_response(array('ok' => 0, 'msg' => 'Keine bearbeitbaren Bilder gefunden.'));
6281 foreach ($ids as $id) {
6282 $this->flush_media_by_media_id($db, (int)$id);
6283 }
6284 $this->cms_json_response(array('ok' => 1, 'success' => true, 'rows' => $done));
6285 } catch (\Throwable $e) {
6286 $this->cms_json_response(array(
6287 'ok' => 0,
6288 'success' => false,
6289 'msg' => 'Medienbearbeitung fehlgeschlagen: ' . $e->getMessage()
6290 ));
6291 }
6292 }
6293
6304 private function content_template_names(): array {
6305 if (is_array($this->contentTemplateNames)) {
6306 return $this->contentTemplateNames;
6307 }
6308
6309 $dir = dbx()->os_path(
6310 dbx()->get_base_dir() . 'dbx/modules/dbxContent/tpl/htm/'
6311 );
6312 $files = is_dir($dir) ? glob(rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . 'c-*.htm') : array();
6313 $names = array();
6314
6315 if (is_array($files)) {
6316 foreach ($files as $file) {
6317 if (!is_file($file)) continue;
6318 $names[] = basename($file, '.htm');
6319 }
6320 }
6321
6322 $names = array_values(array_unique($names));
6323 sort($names, SORT_NATURAL | SORT_FLAG_CASE);
6324
6325 $this->contentTemplateNames = $names ?: array('c-content');
6326 return $this->contentTemplateNames;
6327 }
6328
6329 private function template_options() {
6330 $html = '';
6331 foreach ($this->content_template_names() as $name) {
6332 $html .= '<option value="' . dbx()->esc($name) . '">' . dbx()->esc($name) . '</option>';
6333 }
6334
6335 return $html;
6336 }
6337
6338 private function rights_options() {
6339 $values = $this->rights_values();
6340 $html = '';
6341 foreach ($values as $value => $label) {
6342 $html .= '<option value="' . dbx()->esc($value) . '">' . dbx()->esc($label) . '</option>';
6343 }
6344 return $html;
6345 }
6346
6347 private function rights_values() {
6348 $texts = $this->cms_texts();
6349 $values = array(
6350 'parent' => $texts->get_fd_message('option_parent'),
6351 '*' => '*',
6352 );
6353 $db = dbx()->get_system_obj('dbxDB');
6354 $rows = $db->select('dbxUser_groups', '', '*', 'name');
6355 if (is_array($rows)) {
6356 foreach ($rows as $row) {
6357 if (!is_array($row)) continue;
6358 $name = trim((string)($row['name'] ?? ''));
6359 if ($name !== '') $values[$name] = $name;
6360 }
6361 }
6362 return $values;
6363 }
6364
6365 private function media_template_options() {
6366 $values = array(
6367 'image-hero' => 'Bild Hero',
6368 'image-gallery' => 'Bild Galerie',
6369 'image-teaser' => 'Bild Header',
6370 'video-hero' => 'Video Hero',
6371 'video-gallery' => 'Video Galerie',
6372 'file-gallery' => 'Datei Download',
6373 );
6374 $html = '';
6375 foreach ($values as $value => $label) {
6376 $html .= '<option value="' . dbx()->esc($value) . '">' . dbx()->esc($label) . '</option>';
6377 }
6378 return $html;
6379 }
6380
6381 private function options_html(array $values, $selected = '') {
6382 $html = '';
6383 foreach ($values as $value => $label) {
6384 $sel = ((string)$value === (string)$selected) ? ' selected' : '';
6385 $html .= '<option value="' . dbx()->esc($value) . '"' . $sel . '>' . dbx()->esc($label) . '</option>';
6386 }
6387 return $html;
6388 }
6389
6390 private function content_template_options($with_parent = true) {
6391 $html = $with_parent ? '<option value="parent">parent - vom Parent</option>' : '';
6392 $html .= $this->template_options();
6393 return $html;
6394 }
6395
6396 private function hero_template_values() {
6397 $texts = $this->cms_texts();
6398 return array(
6399 'parent' => $texts->get_fd_message('option_parent'),
6400 'none' => $texts->get_fd_message('option_no_hero'),
6401 'image-hero' => 'Bild Hero',
6402 'video-hero' => 'Video Hero',
6403 );
6404 }
6405
6406 private function hero_template_options() {
6407 return $this->options_html($this->hero_template_values());
6408 }
6409
6410 private function hero_variant_values() {
6411 $texts = $this->cms_texts();
6412 return array(
6413 'parent' => $texts->get_fd_message('option_parent'),
6414 'original' => 'Original',
6415 'yellow' => 'gelblich',
6416 'green' => 'gruenlich',
6417 'blue' => 'blaeulich',
6418 'red' => 'roetlich',
6419 'light' => 'hell',
6420 'dark' => 'dunkel',
6421 'blackwhite' => 'schwarz/weiss',
6422 'monochrome' => 'monochrom',
6423 );
6424 }
6425
6426 private function hero_variant_options() {
6427 return $this->options_html($this->hero_variant_values());
6428 }
6429
6430 private function hero_sticky_values() {
6431 $texts = $this->cms_texts();
6432 return array(
6433 'parent' => $texts->get_fd_message('option_parent'),
6434 '0' => $texts->get_fd_message('option_not_sticky'),
6435 '1' => $texts->get_fd_message('option_sticky'),
6436 );
6437 }
6438
6439 private function hero_scroll_layer_values() {
6440 $texts = $this->cms_texts();
6441 return array(
6442 'parent' => $texts->get_fd_message('option_parent'),
6443 'under' => $texts->get_fd_message('option_scroll_under'),
6444 'over' => $texts->get_fd_message('option_scroll_over'),
6445 );
6446 }
6447
6448 private function gallery_template_values() {
6449 return array(
6450 'image-gallery' => 'Bildergalerie',
6451 'video-gallery' => 'Video Galerie',
6452 'carousel3' => 'Carousel 3',
6453 'cols3' => '3 Spalten',
6454 );
6455 }
6456
6457 private function gallery_template_options() {
6458 return $this->options_html($this->gallery_template_values());
6459 }
6460
6461 private function gallery_image_size_values() {
6462 return array(
6463 '800x600' => '4:3 - Standard (800 x 600)',
6464 '1200x900' => '4:3 - gross (1200 x 900)',
6465 '1024x768' => '4:3 - klassisch (1024 x 768)',
6466 '1600x1200' => '4:3 - hochaufloesend (1600 x 1200)',
6467 '1280x720' => '16:9 - breit (1280 x 720)',
6468 '1920x1080' => '16:9 - Full HD (1920 x 1080)',
6469 '1200x675' => '16:9 - Web (1200 x 675)',
6470 '1080x1080' => '1:1 - Quadrat (1080 x 1080)',
6471 '1200x1200' => '1:1 - Quadrat gross (1200 x 1200)',
6472 '1080x1350' => '4:5 - Portrait (1080 x 1350)',
6473 '900x1200' => '3:4 - Portrait (900 x 1200)',
6474 '1600x900' => '16:9 - gross (1600 x 900)',
6475 '2560x1440' => '16:9 - QHD (2560 x 1440)',
6476 );
6477 }
6478
6479 private function gallery_lightbox_width_values() {
6480 return array(
6481 '60%' => '60% - kompakt',
6482 '70%' => '70% - mittel',
6483 '80%' => '80% - Standard',
6484 '90%' => '90% - breit',
6485 '95%' => '95% - sehr breit',
6486 '70vw' => '70vw - Viewport mittel',
6487 '80vw' => '80vw - Viewport Standard',
6488 '90vw' => '90vw - Viewport breit',
6489 '960px' => '960px - kleine Desktopbreite',
6490 '1200px' => '1200px - Desktop',
6491 '1440px' => '1440px - grosser Desktop',
6492 );
6493 }
6494
6495 private function gallery_overflow_values() {
6496 return array(
6497 'grid' => 'Grid',
6498 'scroll' => 'scroll',
6499 'laufband' => 'laufband',
6500 'slider' => 'slider',
6501 'tutorial' => 'Tutorial Slideshow',
6502 );
6503 }
6504
6505 private function gallery_overflow_options() {
6506 return $this->options_html($this->gallery_overflow_values());
6507 }
6508
6509 private function gallery_click_values() {
6510 return array(
6511 'lightbox' => 'Lightbox',
6512 'swiper-coverflow' => 'Swiper Coverflow',
6513 'swiper-cube' => 'Swiper Cube',
6514 'swiper-cards' => 'Swiper Cards',
6515 'swiper-3d' => 'Swiper 3D-Slider',
6516 'viewerjs' => 'Viewer.js',
6517 'blueimp' => 'blueimp Gallery',
6518 'photoswipe' => 'PhotoSwipe',
6519 'deepzoom' => 'Deep-Zoom-Viewer',
6520 'link' => 'Link',
6521 'newtab' => 'Neues Fenster',
6522 'none' => 'Kein Klick',
6523 );
6524 }
6525
6526 private function gallery_click_options() {
6527 return $this->options_html($this->gallery_click_values());
6528 }
6529
6530 private function meta_robots_values() {
6531 return array(
6532 'index,follow' => 'index, follow (Standard)',
6533 'noindex,follow' => 'noindex, follow',
6534 'index,nofollow' => 'index, nofollow',
6535 'noindex,nofollow' => 'noindex, nofollow',
6536 );
6537 }
6538
6539 private function render_page_form() {
6540 $texts = $this->cms_texts();
6541 $form = dbx()->get_system_obj('dbxForm');
6542 $form->init('cms-page', 'cms-admin-page-form');
6543 $form->_dd = dbxContentLng::ddContent();
6544 $form->_fd = 'dbxContent_admin|cms-page';
6545 $form->load_fd_messages();
6546 $form->set_form_help_enabled(false);
6547 $form->_data = array(
6548 'activ' => 1,
6549 'template' => 'parent',
6550 'hero_template' => 'parent',
6551 'hero_image_id' => 'parent',
6552 'hero_margin_top' => 'parent',
6553 'hero_height' => 'parent',
6554 'hero_variant' => 'parent',
6555 'hero_sticky' => 'parent',
6556 'hero_scroll_layer' => 'parent',
6557 'gallery_template' => 'image-gallery',
6558 'gallery_visible_count' => '3',
6559 'gallery_image_size' => 'original',
6560 'gallery_lightbox_width' => '100vw',
6561 'gallery_overflow' => 'grid',
6562 'gallery_click_behavior' => 'lightbox',
6563 'keywords' => '',
6564 'meta_robots' => 'index,follow',
6565 'seo_title' => '',
6566 'seo_image_id' => 0,
6567 'menu_title' => '',
6568 );
6569 $form->add_fld('id', 'dbxContent_admin|cms-field-hidden', data: array('cms_field' => 'id'));
6570 $form->add_fld('folder', 'dbxContent_admin|cms-field-hidden', data: array('cms_field' => 'folder'));
6571 $form->add_fld('title', 'dbxContent_admin|cms-field-text', label: $texts->get_fd_message('label_title'), rules: 'varchar|min=1', data: array('cms_field' => 'title'));
6572 $form->add_fld('menu_title', 'dbxContent_admin|cms-field-text', label: $texts->get_fd_message('label_menu_title'), rules: 'varchar|max=96', data: array('cms_field' => 'menu_title'), placeholder: $texts->get_fd_message('placeholder_menu_title'));
6573 $form->add_fld('permalink', 'dbxContent_admin|cms-field-text', label: $texts->get_fd_message('label_permalink'), rules: 'permalink|max=254', data: array('cms_field' => 'permalink'), tooltip: $texts->get_fd_message('tooltip_permalink'));
6574 $form->add_fld('activ', 'dbxContent_admin|cms-field-select', label: $texts->get_fd_message('label_status'), rules: 'int', data: array('cms_field' => 'activ'), options: array('1' => $texts->get_fd_message('option_active'), '0' => $texts->get_fd_message('option_inactive')));
6575 $form->add_fld('template', 'dbxContent_admin|cms-field-content-template-select', label: $texts->get_fd_message('label_template'), rules: 'varchar', data: array('cms_field' => 'template'), options: $this->content_template_values());
6576 $form->add_fld('description', 'dbxContent_admin|cms-field-textarea', label: $texts->get_fd_message('label_description'), rules: 'varchar', data: array('cms_field' => 'description'), placeholder: $texts->get_fd_message('placeholder_description'));
6577 $form->add_fld('keywords', 'dbxContent_admin|cms-field-text', label: $texts->get_fd_message('label_keywords'), rules: 'varchar', data: array('cms_field' => 'keywords'), placeholder: $texts->get_fd_message('placeholder_keywords'));
6578 $form->add_fld('content', 'dbxContent_admin|cms-field-textarea-hidden', label: $texts->get_fd_message('label_content'), rules: 'text', data: array('cms_field' => 'content'));
6579 return $form->run();
6580 }
6581
6582 private function content_template_values() {
6583 $values = array('parent' => $this->cms_texts()->get_fd_message('option_parent'));
6584 foreach ($this->content_template_names() as $name) {
6585 $values[$name] = $name;
6586 }
6587
6588 return $values;
6589 }
6590
6591 private function render_folder_form() {
6592 $texts = $this->cms_texts();
6593 $form = dbx()->get_system_obj('dbxForm');
6594 $form->init('cms-folder', 'cms-admin-folder-form');
6595 $form->_dd = dbxContentLng::ddFolder();
6596 $form->_fd = 'dbxContent_admin|cms-page';
6597 $form->load_fd_messages();
6598 $form->set_form_help_enabled(false);
6599 $form->_data = array(
6600 'parent_id' => 0,
6601 'group_read' => 'parent',
6602 'template' => 'parent',
6603 'hero_template' => 'parent',
6604 'hero_image_id' => 'parent',
6605 'hero_margin_top' => 'parent',
6606 'hero_height' => 'parent',
6607 'hero_variant' => 'parent',
6608 'hero_sticky' => 'parent',
6609 'hero_scroll_layer' => 'parent',
6610 );
6611 $form->add_fld('id', 'dbxContent_admin|cms-field-folder-hidden', data: array('cms_field' => 'id'));
6612 $form->add_fld('name', 'dbxContent_admin|cms-field-folder-text', label: $texts->get_fd_message('label_name'), rules: 'varchar|min=1', data: array('cms_field' => 'name'));
6613 $form->add_fld('parent_id', 'dbxContent_admin|cms-field-folder-select', label: $texts->get_fd_message('label_assignment'), rules: 'int', data: array('cms_field' => 'parent_id'), options: array('0' => $texts->get_fd_message('option_root')));
6614 $form->add_fld('template', 'dbxContent_admin|cms-field-folder-content-template-select', label: $texts->get_fd_message('label_template'), rules: 'varchar', data: array('cms_field' => 'template'), options: $this->content_template_values());
6615 $form->add_fld('group_read', 'dbxContent_admin|cms-field-folder-rights', label: $texts->get_fd_message('label_read_rights'), rules: 'varchar', data: array('cms_field' => 'group_read'), options: $this->rights_values());
6616 return $form->run();
6617 }
6618
6619 private function render_settings_form() {
6620 $texts = $this->cms_texts();
6621 $form = dbx()->get_system_obj('dbxForm');
6622 $form->init('cms-settings', 'cms-admin-settings-panels');
6623 $form->_dd = dbxContentLng::ddContent();
6624 $form->_fd = 'dbxContent_admin|cms-page';
6625 $form->load_fd_messages();
6626 $form->set_form_help_enabled(false);
6627 $form->_data = array(
6628 'hero_template' => 'parent',
6629 'hero_image_id' => 'parent',
6630 'hero_margin_top' => 'parent',
6631 'hero_height' => 'parent',
6632 'hero_variant' => 'parent',
6633 'hero_sticky' => 'parent',
6634 'hero_scroll_layer' => 'parent',
6635 'gallery_template' => 'image-gallery',
6636 'gallery_visible_count' => '3',
6637 'gallery_image_size' => 'original',
6638 'gallery_lightbox_width' => '100vw',
6639 'gallery_overflow' => 'grid',
6640 'gallery_click_behavior' => 'lightbox',
6641 );
6642 $form->add_fld('hero_template', 'dbxContent_admin|cms-field-select', label: $texts->get_fd_message('label_hero_template'), rules: 'varchar', data: array('cms_field' => 'hero_template'), options: $this->hero_template_values());
6643 $form->add_fld('hero_image_id', 'dbxContent_admin|cms-field-hidden', rules: 'varchar', data: array('cms_field' => 'hero_image_id'));
6644 $form->add_fld('hero_margin_top', 'dbxContent_admin|cms-field-text', label: $texts->get_fd_message('label_hero_margin_top'), rules: 'varchar', data: array('cms_field' => 'hero_margin_top'));
6645 $form->add_fld('hero_height', 'dbxContent_admin|cms-field-text', label: $texts->get_fd_message('label_hero_height'), rules: 'varchar', data: array('cms_field' => 'hero_height'));
6646 $form->add_fld('hero_variant', 'dbxContent_admin|cms-field-select', label: $texts->get_fd_message('label_hero_variant'), rules: 'varchar', data: array('cms_field' => 'hero_variant'), options: $this->hero_variant_values());
6647 $form->add_fld('hero_sticky', 'dbxContent_admin|cms-field-select', label: $texts->get_fd_message('label_hero_sticky'), rules: 'varchar', data: array('cms_field' => 'hero_sticky'), options: $this->hero_sticky_values());
6648 $form->add_fld('hero_scroll_layer', 'dbxContent_admin|cms-field-select', label: $texts->get_fd_message('label_hero_scroll_layer'), rules: 'varchar', data: array('cms_field' => 'hero_scroll_layer'), options: $this->hero_scroll_layer_values());
6649 $form->add_fld('gallery_image_size', 'dbxContent_admin|cms-field-select', label: $texts->get_fd_message('label_gallery_image_size'), rules: 'varchar', data: array('cms_field' => 'gallery_image_size'), options: $this->gallery_image_size_values());
6650 $form->add_fld('gallery_lightbox_width', 'dbxContent_admin|cms-field-select', label: $texts->get_fd_message('label_gallery_lightbox_width'), rules: 'varchar', data: array('cms_field' => 'gallery_lightbox_width'), options: $this->gallery_lightbox_width_values());
6651 $form->add_fld('gallery_overflow', 'dbxContent_admin|cms-field-select', label: $texts->get_fd_message('label_gallery_overflow'), rules: 'varchar', data: array('cms_field' => 'gallery_overflow'), options: $this->gallery_overflow_values());
6652 $form->add_fld('gallery_click_behavior', 'dbxContent_admin|cms-field-select', label: $texts->get_fd_message('label_gallery_click'), rules: 'varchar', data: array('cms_field' => 'gallery_click_behavior'), options: $this->gallery_click_values());
6653 return $form->run();
6654 }
6655
6656 public function run($action = 'cms') {
6657 $this->apply_cms_lng_context();
6658 $action = (string)$action;
6659 if ($this->action_requires_token($action) && !$this->check_action_token($action)) {
6660 return $this->reject_action_token($action);
6661 }
6662 switch ($action) {
6663 case 'cms_tree':
6664 return $this->tree_json();
6665 case 'cms_lng_coverage':
6666 return $this->lng_coverage_json();
6667 case 'cms_lng_preview':
6668 return $this->lng_preview_json();
6669 case 'cms_lng_provision':
6670 return $this->lng_provision_json();
6671 case 'cms_lng_provision_tree':
6672 return $this->lng_provision_tree_json();
6673 case 'cms_lng_reset_sync':
6674 return $this->lng_reset_sync_json();
6675 case 'cms_lng_delete_preview':
6676 return $this->lng_delete_preview_json();
6677 case 'cms_page':
6678 return $this->page_json();
6679 case 'cms_save':
6680 return $this->save_json();
6681 case 'cms_new_page':
6682 return $this->new_page_json();
6683 case 'cms_duplicate_page':
6684 return $this->duplicate_page_json();
6685 case 'cms_new_folder':
6686 return $this->new_folder_json();
6687 case 'cms_save_folder':
6688 return $this->save_folder_json();
6689 case 'cms_delete_folder':
6690 return $this->delete_folder_json();
6691 case 'cms_delete_page':
6692 return $this->delete_page_json();
6693 case 'cms_move_node':
6694 return $this->move_node_json();
6695 case 'cms_media':
6696 return $this->media_json();
6697 case 'cms_media_process':
6698 return $this->media_process();
6699 case 'cms_upload':
6700 return $this->upload_json();
6701 case 'cms_external_video':
6702 return $this->external_video_json();
6703 case 'cms_media_folders':
6704 return $this->media_folders_json();
6705 case 'cms_media_folder_create':
6706 return $this->media_folder_create_json();
6707 case 'cms_media_folder_delete':
6708 return $this->media_folder_delete_json();
6709 case 'cms_media_folder_rename':
6710 return $this->media_folder_rename_json();
6711 case 'cms_media_move':
6712 return $this->media_move_json();
6713 case 'cms_media_unused':
6714 return $this->media_unused_json();
6715 case 'cms_remove_media':
6716 return $this->remove_media_json();
6717 case 'cms_delete_media':
6718 return $this->delete_media_json();
6719 case 'cms_edit_media':
6720 return $this->edit_media_json();
6721 case 'cms_set_media_slot':
6722 return $this->set_media_slot_json();
6723 case 'cms_assign_media':
6724 return $this->assign_media_json();
6725 case 'cms_sort_media':
6726 return $this->sort_media_json();
6727 case 'cms_mod_catalog':
6728 return $this->mod_catalog_json();
6729 case 'cms_mod_modules':
6730 return $this->mod_modules_json();
6731 case 'cms':
6732 default:
6733 return $this->render_cms();
6734 }
6735 }
6736}
6737
6738?>
if(!is_array( $before)||(int)( $before[ 'id'] ?? 0) !==$mediaId) if((string)($before['media_type'] ?? '') !=='video') $updated
if($updated !==1) $after
ensure_schema($db)
Kompatibilitaetsmethode fuer bestehende Aufrufer.
Gemeinsame dbxForm-Definitionen fuer den JavaScript-Medienbrowser.
Gemeinsame Basisklasse fuer dbXapp-System-, Modul- und Include-Objekte.
Definition dbxKernel.php:63
$messages
Definition config.fd.php:2
if((string)($formActionPolicy['action'] ?? '') !=='dbxAction.save'||!dbx() ->check_action_token((string)($formActionPolicy['scope'] ?? ''),(string)($formActionRoute['params']['dbx_token'] ?? ''))) $report
get_base_dir(int $cutData=0)
Liefert das Basisverzeichnis der Installation.
Definition dbxApi.php:1507
esc($value)
Escaped einen Wert fuer HTML-Text und HTML-Attribute.
Definition dbxApi.php:2310
get_file_dir()
Liefert das files/-Verzeichnis der Installation.
Definition dbxApi.php:1524
search_defaults(array $overrides=array())
Standard-Optionen fuer dbx|search (wie Content-Grid).
Definition dbxApi.php:2326
parse_url($data)
Zerlegt URL-/Parameterdaten in einen Array.
Definition dbxApi.php:2416
next_id(int $add=1)
Erzeugt eine fortlaufende ID innerhalb des DBX-Kontexts.
Definition dbxApi.php:2181
action_token(string $scope='global')
Liefert ein sessiongebundenes Token fuer zustandsaendernde Link-Aktionen.
Definition dbxApi.php:1189
check_action_token(string $scope='global', string $token='')
Prueft ein sessiongebundenes Aktions-Token.
Definition dbxApi.php:1206
$_SERVER['REQUEST_METHOD']
if( $base !==$expectedBase) if($files !==$expectedBase . 'files/') $stored
foreach(array('bootstrapRowColumns', 'setBootstrapColumnLayout', 'addBootstrapColumn', 'dissolveBootstrapColumns',) as $function) $keys
foreach($rows as $row) $targets
foreach( $iterator as $file) if(! $groups) $expected
foreach(array( 'forms', 'reports', 'dd', 'db', 'audit', 'objects', 'escaping', 'get', 'action_links') as $requiredKey) foreach(array('{fid}_{event}-Callback-Defaults', 'add_rep()', '{rpt:colspan}') as $requiredReportRule) $reference
if($web->content_permalink_redirect_target() !=='') $tpl
if(!defined( 'IMG_WEBP')) define( 'IMG_WEBP'
mod_class_files($modulesDir, $modul)
mod_scan_runs($modulesDir, $modul)
if( $demoId<=0) foreach(array('create_date', 'create_uid', 'update_date', 'update_uid', 'owner',) as $systemField) $items
DBX schema administration.
if($cinematic===''||!str_contains($cinematic, 'data-dbx-cinema')) $page
for( $step=0;$step< 1000;$step++) if(($schema['status'] ?? '') !=='finished') $media