From 37318e69fbd0bdd40d94861f0a89b90c729ee73b Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Sun, 7 Dec 2025 02:24:48 +0100 Subject: [PATCH] up --- config/emailtemplate.conf.php | 10 ++ download/emailtemplate_bridge.php | 24 ++- public/assets/js/bridge/blocks-custom.js | 213 +++++++++++++++++++++++ src/ApiKernel.php | 74 ++++++++ 4 files changed, 315 insertions(+), 6 deletions(-) diff --git a/config/emailtemplate.conf.php b/config/emailtemplate.conf.php index 93cbaf8..4655572 100644 --- a/config/emailtemplate.conf.php +++ b/config/emailtemplate.conf.php @@ -104,6 +104,15 @@ $tablesDefaults = [ ]; $tables = array_replace($tablesDefaults, $overrides['tables'] ?? []); +$placeholdersDefaults = [ + 'bridge' => [ + 'url' => getenv('EMAILTEMPLATE_PLACEHOLDER_URL') ?: '', + 'token' => getenv('EMAILTEMPLATE_PLACEHOLDER_TOKEN') ?: '', + 'cache_ttl' => (int)(getenv('EMAILTEMPLATE_PLACEHOLDER_CACHE') ?: 300), + ], +]; +$placeholders = array_replace_recursive($placeholdersDefaults, $overrides['placeholders'] ?? []); + $columnsDefaults = [ 'templates' => ['id'=>'id','name'=>'name','desc'=>null,'cat'=>null,'upd'=>'updated_at'], 'sections' => ['id'=>'id','name'=>'name','cat'=>null,'upd'=>'updated_at'], @@ -123,4 +132,5 @@ return [ 'multi' => $multi, 'tables' => $tables, 'columns' => $columns, + 'placeholders' => $placeholders, ]; diff --git a/download/emailtemplate_bridge.php b/download/emailtemplate_bridge.php index 0a18b87..97ac755 100644 --- a/download/emailtemplate_bridge.php +++ b/download/emailtemplate_bridge.php @@ -32,6 +32,7 @@ $bridgeConfig = [ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ], ], + 'tables_allow' => [], // optional whitelist: ['customers', 'orders'] ]; $localOverride = __DIR__ . '/emailtemplate.bridge.conf.php'; @@ -115,13 +116,24 @@ try { $dbName = $m[1]; } - $tablesStmt = $pdo->query('SHOW FULL TABLES'); - $tables = []; - while ($row = $tablesStmt->fetch(PDO::FETCH_NUM)) { - $tableName = $row[0]; - if ($tableName === null) { - continue; +$tablesStmt = $pdo->query('SHOW FULL TABLES'); +$tables = []; +$whitelist = []; +if (!empty($bridgeConfig['tables_allow']) && is_array($bridgeConfig['tables_allow'])) { + foreach ($bridgeConfig['tables_allow'] as $tbl) { + if (is_string($tbl) && $tbl !== '') { + $whitelist[strtolower($tbl)] = true; } + } +} +while ($row = $tablesStmt->fetch(PDO::FETCH_NUM)) { + $tableName = $row[0]; + if ($tableName === null) { + continue; + } + if ($whitelist && empty($whitelist[strtolower($tableName)])) { + continue; + } $columnsStmt = $pdo->prepare( 'SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_KEY, EXTRA diff --git a/public/assets/js/bridge/blocks-custom.js b/public/assets/js/bridge/blocks-custom.js index ea39d67..c3a8ba7 100644 --- a/public/assets/js/bridge/blocks-custom.js +++ b/public/assets/js/bridge/blocks-custom.js @@ -44,13 +44,226 @@ const css = o => Object.entries(o).map(([k,v]) => `${k}:${v}`).join(';'); + const PLACEHOLDER_COMPONENT = 'placeholder-block'; + const placeholderSchemaStore = { + promise: null, + tables: [], + }; + + const fetchPlaceholderSchema = () => { + if (placeholderSchemaStore.promise) return placeholderSchemaStore.promise; + const base = B.API_KERNEL_URL || '/api.php'; + const sep = base.includes('?') ? '&' : '?'; + const url = `${base}${sep}action=placeholders.schema`; + placeholderSchemaStore.promise = fetch(url, { credentials: 'include' }) + .then(res => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then(data => { + placeholderSchemaStore.tables = Array.isArray(data?.tables) ? data.tables : []; + return placeholderSchemaStore.tables; + }) + .catch(err => { + placeholderSchemaStore.tables = []; + log('PLACEHOLDER ERROR', `Schema konnte nicht geladen werden: ${err.message || err}`, 'red', 'error'); + throw err; + }); + return placeholderSchemaStore.promise; + }; + + const getTraitByName = (model, name) => { + if (typeof model.getTrait === 'function') return model.getTrait(name); + const traits = model.get('traits'); + if (!traits) return null; + if (typeof traits.where === 'function') { + const found = traits.where({ name }); + return found && found[0]; + } + if (Array.isArray(traits.models)) { + return traits.models.find(t => t.get && t.get('name') === name) || null; + } + return null; + }; + + const ensurePlaceholderComponent = (editor) => { + const domc = editor.DomComponents; + if (domc.getType(PLACEHOLDER_COMPONENT)) return; + + const defaultType = domc.getType('default'); + const defaultModel = defaultType ? defaultType.model : null; + const defaultView = defaultType ? defaultType.view : null; + + domc.addType(PLACEHOLDER_COMPONENT, { + model: (defaultModel || editor.DomComponents.Component).extend({ + defaults: { + ...(defaultModel && defaultModel.prototype?.defaults ? defaultModel.prototype.defaults : {}), + name: 'Placeholder', + tagName: 'span', + selectable: true, + hoverable: true, + droppable: false, + attributes: { + 'data-placeholder-type': 'custom', + 'data-placeholder-key': 'UEBERSCHRIFT', + }, + traits: [ + { + type: 'select', + name: 'data-placeholder-type', + label: 'Typ', + options: [ + { id: 'custom', label: 'Allgemein' }, + { id: 'database', label: 'Datenbank' }, + ], + changeProp: true, + }, + { + type: 'text', + name: 'data-placeholder-key', + label: 'Bezeichner', + placeholder: 'UEBERSCHRIFT', + changeProp: true, + }, + { + type: 'select', + name: 'data-placeholder-table', + label: 'Tabelle', + options: [], + changeProp: true, + }, + { + type: 'select', + name: 'data-placeholder-column', + label: 'Feld', + options: [], + changeProp: true, + }, + ], + }, + + init() { + this.listenTo(this, 'change:attributes', this.updatePlaceholderState); + this.updatePlaceholderState(); + fetchPlaceholderSchema().then(() => { + this.updateSchemaTraits(); + }).catch(() => { + this.updateSchemaTraits([]); + }); + }, + + updatePlaceholderState() { + this.updateTraitVisibility(); + this.updateSchemaTraits(); + this.updateLabel(); + }, + + updateTraitVisibility() { + const attrs = this.getAttributes(); + const type = attrs['data-placeholder-type'] || 'custom'; + const isDb = type === 'database'; + const tableTrait = getTraitByName(this, 'data-placeholder-table'); + const columnTrait = getTraitByName(this, 'data-placeholder-column'); + const keyTrait = getTraitByName(this, 'data-placeholder-key'); + + if (tableTrait?.view?.el) { + tableTrait.view.el.style.display = isDb ? '' : 'none'; + } + if (columnTrait?.view?.el) { + columnTrait.view.el.style.display = isDb ? '' : 'none'; + } + if (keyTrait?.view?.el) { + keyTrait.view.el.style.display = isDb ? 'none' : ''; + } + }, + + updateSchemaTraits(tablesOverride) { + const tables = Array.isArray(tablesOverride) ? tablesOverride : placeholderSchemaStore.tables; + const tableTrait = getTraitByName(this, 'data-placeholder-table'); + const columnTrait = getTraitByName(this, 'data-placeholder-column'); + + if (tableTrait) { + const opts = tables.map(tbl => ({ id: tbl.name, label: tbl.name })); + tableTrait.set('options', opts); + if (tableTrait.view && tableTrait.view.render) tableTrait.view.render(); + } + + if (columnTrait) { + const attrs = this.getAttributes(); + const tableName = (attrs['data-placeholder-table'] || '').toLowerCase(); + const table = tables.find(tbl => tbl.name.toLowerCase() === tableName); + const colOpts = table + ? table.columns.map(col => ({ + id: col.name, + label: `${col.name} (${col.type})`, + })) + : []; + columnTrait.set('options', colOpts); + if (columnTrait.view && columnTrait.view.render) columnTrait.view.render(); + } + }, + + updateLabel() { + const attrs = this.getAttributes(); + const type = attrs['data-placeholder-type'] || 'custom'; + let label; + if (type === 'database') { + const table = attrs['data-placeholder-table'] || 'TABELLE'; + const column = attrs['data-placeholder-column'] || 'FELD'; + label = `${table}.${column}`.toUpperCase(); + } else { + label = (attrs['data-placeholder-key'] || 'PLATZHALTER').toUpperCase(); + } + const text = `{{${label}}}`; + const comps = this.components(); + if (comps.length === 1 && comps.at(0).is('textnode')) { + comps.at(0).set('content', text); + } else { + comps.reset([{ type: 'textnode', content: text }]); + } + }, + }, { + isComponent(el) { + if (el && el.hasAttribute && el.hasAttribute('data-placeholder-type')) { + return { type: PLACEHOLDER_COMPONENT }; + } + return false; + }, + }), + view: (defaultView || editor.DomComponents.View).extend({ + render() { + defaultView && defaultView.prototype.render + ? defaultView.prototype.render.apply(this, arguments) + : editor.DomComponents.View.prototype.render.apply(this, arguments); + this.el.classList.add('placeholder-block'); + this.el.style.display = 'inline-block'; + this.el.style.padding = '2px 8px'; + this.el.style.border = '1px dashed #94a3b8'; + this.el.style.borderRadius = '6px'; + this.el.style.background = '#f1f5f9'; + this.el.style.fontFamily = 'monospace'; + this.el.style.fontSize = '12px'; + return this; + }, + }), + }); + }; + function register(editor) { log('EXECUTION', `Starte Block-Registrierung für ${TARGET_CAT_ID}.`, '#DAA520'); const bm = editor.BlockManager; + ensurePlaceholderComponent(editor); // --- Custom-Blöcke DEFINIEREN --- + // PLACEHOLDER + addOnce(bm, 'cust-placeholder', { + id: 'cust-placeholder', + label: '🔖 Placeholder', + content: `{{UEBERSCHRIFT}}` + }); + // TEXT addOnce(bm, 'cust-text', { id:'cust-text', label:'📝 Text', content:`
diff --git a/src/ApiKernel.php b/src/ApiKernel.php index 1958035..b54fda7 100644 --- a/src/ApiKernel.php +++ b/src/ApiKernel.php @@ -3,6 +3,7 @@ declare(strict_types=1); use DOMDocument; use DOMXPath; +use RuntimeException; use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles; // 💡 NEUE KORREKTUR: Starte Output Buffering so früh wie möglich, um Whitespace/Errors @@ -769,6 +770,9 @@ class ApiKernel $this->authService->logout(); $this->respond(['ok' => true]); break; + case 'placeholders.schema': + $this->handlePlaceholderSchema(); + break; case 'templates.test_send': $this->handleTemplateTestSend(); break; @@ -1023,4 +1027,74 @@ class ApiKernel $node->appendChild($targetDoc->createTextNode($html)); } + + private function handlePlaceholderSchema(): void + { + $this->requireAuth(); + $bridge = $this->conf['placeholders']['bridge'] ?? []; + $url = trim((string)($bridge['url'] ?? '')); + $token = trim((string)($bridge['token'] ?? '')); + if ($url === '' || $token === '') { + $this->fail('Bridge not configured', null, 500); + } + + $ttl = (int)($bridge['cache_ttl'] ?? 300); + try { + $schema = $this->fetchPlaceholderSchema($url, $token, $ttl); + } catch (Throwable $e) { + $this->fail('Bridge request failed', $e->getMessage(), 502); + return; + } + + $this->respond([ + 'ok' => true, + 'tables' => $schema['tables'] ?? [], + 'fetched' => $schema['fetched'] ?? date(DATE_ATOM), + ]); + } + + private function fetchPlaceholderSchema(string $url, string $token, int $ttl): array + { + $cacheFile = $this->placeholderCachePath($url, $token); + if ($ttl > 0 && is_file($cacheFile) && (filemtime($cacheFile) + $ttl) > time()) { + $cached = json_decode((string)@file_get_contents($cacheFile), true); + if (is_array($cached)) { + return $cached; + } + } + + $endpoint = $url; + if (stripos($endpoint, 'action=') === false) { + $endpoint .= (strpos($endpoint, '?') === false ? '?' : '&') . 'action=schema'; + } + + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => "Authorization: Bearer {$token}\r\nAccept: application/json\r\n", + 'timeout' => 10, + ], + ]); + $response = @file_get_contents($endpoint, false, $context); + if ($response === false) { + throw new RuntimeException('Bridge endpoint unreachable'); + } + + $decoded = json_decode($response, true); + if (!is_array($decoded) || empty($decoded['ok'])) { + throw new RuntimeException('Bridge did not return a valid schema'); + } + + if ($ttl > 0) { + @file_put_contents($cacheFile, json_encode($decoded)); + } + + return $decoded; + } + + private function placeholderCachePath(string $url, string $token): string + { + $hash = md5($url . '|' . $token); + return sys_get_temp_dir() . '/emailtemplate_placeholder_' . $hash . '.json'; + } }