up
This commit is contained in:
@@ -104,6 +104,15 @@ $tablesDefaults = [
|
|||||||
];
|
];
|
||||||
$tables = array_replace($tablesDefaults, $overrides['tables'] ?? []);
|
$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 = [
|
$columnsDefaults = [
|
||||||
'templates' => ['id'=>'id','name'=>'name','desc'=>null,'cat'=>null,'upd'=>'updated_at'],
|
'templates' => ['id'=>'id','name'=>'name','desc'=>null,'cat'=>null,'upd'=>'updated_at'],
|
||||||
'sections' => ['id'=>'id','name'=>'name','cat'=>null,'upd'=>'updated_at'],
|
'sections' => ['id'=>'id','name'=>'name','cat'=>null,'upd'=>'updated_at'],
|
||||||
@@ -123,4 +132,5 @@ return [
|
|||||||
'multi' => $multi,
|
'multi' => $multi,
|
||||||
'tables' => $tables,
|
'tables' => $tables,
|
||||||
'columns' => $columns,
|
'columns' => $columns,
|
||||||
|
'placeholders' => $placeholders,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ $bridgeConfig = [
|
|||||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'tables_allow' => [], // optional whitelist: ['customers', 'orders']
|
||||||
];
|
];
|
||||||
|
|
||||||
$localOverride = __DIR__ . '/emailtemplate.bridge.conf.php';
|
$localOverride = __DIR__ . '/emailtemplate.bridge.conf.php';
|
||||||
@@ -115,13 +116,24 @@ try {
|
|||||||
$dbName = $m[1];
|
$dbName = $m[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
$tablesStmt = $pdo->query('SHOW FULL TABLES');
|
$tablesStmt = $pdo->query('SHOW FULL TABLES');
|
||||||
$tables = [];
|
$tables = [];
|
||||||
while ($row = $tablesStmt->fetch(PDO::FETCH_NUM)) {
|
$whitelist = [];
|
||||||
$tableName = $row[0];
|
if (!empty($bridgeConfig['tables_allow']) && is_array($bridgeConfig['tables_allow'])) {
|
||||||
if ($tableName === null) {
|
foreach ($bridgeConfig['tables_allow'] as $tbl) {
|
||||||
continue;
|
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(
|
$columnsStmt = $pdo->prepare(
|
||||||
'SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_KEY, EXTRA
|
'SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_KEY, EXTRA
|
||||||
|
|||||||
@@ -44,13 +44,226 @@
|
|||||||
|
|
||||||
const css = o => Object.entries(o).map(([k,v]) => `${k}:${v}`).join(';');
|
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) {
|
function register(editor) {
|
||||||
log('EXECUTION', `Starte Block-Registrierung für ${TARGET_CAT_ID}.`, '#DAA520');
|
log('EXECUTION', `Starte Block-Registrierung für ${TARGET_CAT_ID}.`, '#DAA520');
|
||||||
|
|
||||||
const bm = editor.BlockManager;
|
const bm = editor.BlockManager;
|
||||||
|
ensurePlaceholderComponent(editor);
|
||||||
|
|
||||||
// --- Custom-Blöcke DEFINIEREN ---
|
// --- Custom-Blöcke DEFINIEREN ---
|
||||||
|
|
||||||
|
// PLACEHOLDER
|
||||||
|
addOnce(bm, 'cust-placeholder', {
|
||||||
|
id: 'cust-placeholder',
|
||||||
|
label: '🔖 Placeholder',
|
||||||
|
content: `<span data-gjs-type="${PLACEHOLDER_COMPONENT}" data-placeholder-type="custom" data-placeholder-key="UEBERSCHRIFT">{{UEBERSCHRIFT}}</span>`
|
||||||
|
});
|
||||||
|
|
||||||
// TEXT
|
// TEXT
|
||||||
addOnce(bm, 'cust-text', { id:'cust-text', label:'📝 Text',
|
addOnce(bm, 'cust-text', { id:'cust-text', label:'📝 Text',
|
||||||
content:`<div style="${css({'font-family':'Arial,sans-serif','font-size':'14px','line-height':'1.5',color:'#0f172a',margin:'0 0 12px'})}">
|
content:`<div style="${css({'font-family':'Arial,sans-serif','font-size':'14px','line-height':'1.5',color:'#0f172a',margin:'0 0 12px'})}">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
use DOMDocument;
|
use DOMDocument;
|
||||||
use DOMXPath;
|
use DOMXPath;
|
||||||
|
use RuntimeException;
|
||||||
use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles;
|
use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles;
|
||||||
|
|
||||||
// 💡 NEUE KORREKTUR: Starte Output Buffering so früh wie möglich, um Whitespace/Errors
|
// 💡 NEUE KORREKTUR: Starte Output Buffering so früh wie möglich, um Whitespace/Errors
|
||||||
@@ -769,6 +770,9 @@ class ApiKernel
|
|||||||
$this->authService->logout();
|
$this->authService->logout();
|
||||||
$this->respond(['ok' => true]);
|
$this->respond(['ok' => true]);
|
||||||
break;
|
break;
|
||||||
|
case 'placeholders.schema':
|
||||||
|
$this->handlePlaceholderSchema();
|
||||||
|
break;
|
||||||
case 'templates.test_send':
|
case 'templates.test_send':
|
||||||
$this->handleTemplateTestSend();
|
$this->handleTemplateTestSend();
|
||||||
break;
|
break;
|
||||||
@@ -1023,4 +1027,74 @@ class ApiKernel
|
|||||||
|
|
||||||
$node->appendChild($targetDoc->createTextNode($html));
|
$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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user