commit fd83e4810bdb5881c116b488b0be6c898952ced2 Author: Michael Date: Mon Mar 30 11:27:36 2026 +0000 BreznFlow 1.0.0 — WordPress.org submission Initial public release of BreznFlow, an n8n workflow renderer for WordPress. Fully PHPCS-compliant (WordPress Coding Standards), security-hardened, and ready for WordPress.org plugin review. Co-Authored-By: Claude Opus 4.6 diff --git a/assets/admin.css b/assets/admin.css new file mode 100644 index 0000000..86f7cff --- /dev/null +++ b/assets/admin.css @@ -0,0 +1,168 @@ +/* BreznFlow — Admin Styles */ + +.breznflow-wizard, +.breznflow-list-page, +.breznflow-settings-page { + max-width: 900px; +} + +/* Wizard Steps */ +.breznflow-wizard-steps { + display: flex; + gap: 0; + margin: 16px 0 24px; + border-bottom: 2px solid #ddd; +} + +.breznflow-step { + padding: 8px 20px; + font-size: 14px; + color: #666; + border-bottom: 3px solid transparent; + margin-bottom: -2px; +} + +.breznflow-step.active { + color: #2271b1; + border-bottom-color: #2271b1; + font-weight: 600; +} + +.breznflow-step.done { + color: #00a32a; +} + +/* Cards */ +.breznflow-card { + background: #fff; + border: 1px solid #ddd; + border-radius: 6px; + padding: 20px 24px; + margin-bottom: 20px; + box-shadow: 0 1px 3px rgba(0,0,0,.04); +} + +.breznflow-card h2 { + margin-top: 0; + font-size: 16px; + border-bottom: 1px solid #eee; + padding-bottom: 10px; + margin-bottom: 16px; +} + +.breznflow-card-security { + border-color: #f0c000; + background: #fffbee; +} + +/* URL Import */ +.breznflow-url-row { + display: flex; + gap: 8px; + margin-bottom: 8px; +} + +.breznflow-url-row input { + flex: 1; +} + +/* File upload */ +.breznflow-file-upload { + margin-bottom: 16px; +} + +.breznflow-file-upload label, +.breznflow-step1-import-url label, +.breznflow-json-field label { + display: block; + font-weight: 600; + margin-bottom: 6px; +} + +/* JSON textarea */ +.breznflow-json-field textarea { + font-family: 'Courier New', Courier, monospace; + font-size: 12px; + resize: vertical; +} + +/* Validation result */ +#breznflow-validation-result { + padding: 8px 12px; + border-radius: 4px; + margin-bottom: 12px; + font-size: 13px; +} + +#breznflow-validation-result.success { + background: #edfaef; + border: 1px solid #00a32a; + color: #00a32a; +} + +#breznflow-validation-result.error { + background: #fdecea; + border: 1px solid #d63638; + color: #d63638; +} + +/* Shortcode preview */ +.breznflow-shortcode-preview { + display: flex; + align-items: center; + gap: 12px; + background: #f6f7f7; + padding: 10px 14px; + border-radius: 4px; + font-size: 14px; +} + +.breznflow-shortcode-preview code { + flex: 1; + font-size: 14px; +} + +/* Mask log */ +.breznflow-mask-log { + margin: 10px 0 0; + padding-left: 20px; + font-size: 13px; + line-height: 2; +} + +.breznflow-mask-log em { + color: #666; +} + +/* Step 2 meta info */ +.breznflow-step2-meta { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 16px; + font-size: 13px; + color: #555; +} + +/* List table */ +.breznflow-badge-ai { + background: #7c3aed; + color: #fff; + padding: 1px 6px; + border-radius: 3px; + font-size: 11px; + font-weight: 600; +} + +/* Preview container in step 3 */ +.breznflow-preview-container { + border: 1px solid #ddd; + border-radius: 6px; + overflow: hidden; + min-height: 300px; +} + +/* Multiselect */ +.breznflow-multiselect { + min-height: 80px; +} diff --git a/assets/admin.js b/assets/admin.js new file mode 100644 index 0000000..0d05a05 --- /dev/null +++ b/assets/admin.js @@ -0,0 +1,199 @@ +/* BreznFlow — Admin JS (vanilla ES2020, no dependencies) */ +/* global breznflowAdmin */ + +(function () { + 'use strict'; + + // ── AJAX helpers ────────────────────────────────────────────────────────── + + function post(action, data, callback) { + const params = new URLSearchParams(); + params.append('action', action); + params.append('nonce', breznflowAdmin.nonce); + for (const [k, v] of Object.entries(data)) { + params.append(k, v); + } + + fetch(breznflowAdmin.ajaxUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }) + .then(function(r) { return r.json(); }) + .then(callback) + .catch(function(err) { + callback({ success: false, data: { message: String(err) } }); + }); + } + + // ── Step 1: Validation ──────────────────────────────────────────────────── + + const jsonTextarea = document.getElementById('breznflow-json'); + const validateBtn = document.getElementById('breznflow-validate-btn'); + const submitBtn = document.getElementById('breznflow-step1-submit'); + const resultDiv = document.getElementById('breznflow-validation-result'); + + function showResult(success, message) { + if (!resultDiv) return; + resultDiv.removeAttribute('hidden'); + resultDiv.className = success ? 'success' : 'error'; + resultDiv.textContent = message; + } + + if (validateBtn && jsonTextarea) { + validateBtn.addEventListener('click', function () { + const json = jsonTextarea.value.trim(); + if (!json) { + showResult(false, breznflowAdmin.i18n.pasteFirst || 'Please paste a workflow JSON first.'); + return; + } + validateBtn.disabled = true; + validateBtn.textContent = breznflowAdmin.i18n.validating || 'Validating...'; + + post('breznflow_validate_json', { json: json }, function(resp) { + validateBtn.disabled = false; + validateBtn.textContent = breznflowAdmin.i18n.validateJson || 'Validate JSON'; + + if (resp.success) { + const msg = (breznflowAdmin.i18n.valid || 'Valid n8n workflow') + + ': ' + resp.data.name + ' \u2014 ' + resp.data.nodes + ' ' + (breznflowAdmin.i18n.nodes || 'nodes'); + showResult(true, msg); + if (submitBtn) submitBtn.disabled = false; + } else { + showResult(false, (breznflowAdmin.i18n.invalid || 'Invalid') + ': ' + (resp.data && resp.data.message ? resp.data.message : 'Unknown error')); + if (submitBtn) submitBtn.disabled = true; + } + }); + }); + } + + // ── Step 1: File upload → textarea ──────────────────────────────────────── + + const fileInput = document.getElementById('breznflow-file'); + if (fileInput && jsonTextarea) { + fileInput.addEventListener('change', function () { + const file = this.files && this.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = function(e) { + jsonTextarea.value = e.target.result; + if (resultDiv) { + resultDiv.setAttribute('hidden', ''); + resultDiv.className = ''; + resultDiv.textContent = ''; + } + if (submitBtn) submitBtn.disabled = true; + }; + reader.readAsText(file); + }); + } + + // ── Step 1: URL fetch ───────────────────────────────────────────────────── + + const urlInput = document.getElementById('breznflow-url'); + const fetchBtn = document.getElementById('breznflow-fetch-url'); + if (fetchBtn && urlInput && jsonTextarea) { + fetchBtn.addEventListener('click', function () { + const url = urlInput.value.trim(); + if (!url) return; + fetchBtn.disabled = true; + fetchBtn.textContent = breznflowAdmin.i18n.fetching || 'Fetching...'; + + post('breznflow_fetch_url', { url: url }, function(resp) { + fetchBtn.disabled = false; + fetchBtn.textContent = breznflowAdmin.i18n.fetch || 'Fetch'; + if (resp.success && resp.data && resp.data.json) { + jsonTextarea.value = resp.data.json; + if (submitBtn) submitBtn.disabled = true; + if (resultDiv) { + resultDiv.setAttribute('hidden', ''); + resultDiv.textContent = ''; + } + } else { + showResult(false, resp.data && resp.data.message ? resp.data.message : (breznflowAdmin.i18n.fetchFailed || 'Fetch failed')); + } + }); + }); + } + + // ── Copy Shortcode buttons ──────────────────────────────────────────────── + + function attachCopyButtons() { + document.querySelectorAll('.breznflow-copy-sc').forEach(function(btn) { + btn.addEventListener('click', function () { + const sc = btn.getAttribute('data-sc') || ''; + const label = btn.textContent; + if (!sc) return; + + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(sc).then(function() { + btn.textContent = breznflowAdmin.i18n.copied || 'Copied!'; + setTimeout(function() { btn.textContent = label; }, 2000); + }).catch(function() { + fallbackCopy(sc, btn, label); + }); + } else { + fallbackCopy(sc, btn, label); + } + }); + }); + } + + function fallbackCopy(text, btn, label) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + try { + document.execCommand('copy'); + btn.textContent = breznflowAdmin.i18n.copied || 'Copied!'; + setTimeout(function() { btn.textContent = label; }, 2000); + } catch (e) { + // silently ignore + } + document.body.removeChild(ta); + } + + attachCopyButtons(); + + // ── Step 2: Live shortcode preview ──────────────────────────────────────── + + const scLive = document.getElementById('breznflow-shortcode-live'); + if (scLive) { + const postIdInput = document.querySelector('input[name="breznflow_post_id"]'); + const postId = postIdInput ? postIdInput.value : ''; + const modeRadios = document.querySelectorAll('input[name="default_mode"]'); + const showTitleCb = document.querySelector('input[name="show_title"]'); + const showInfoCb = document.querySelector('input[name="show_infobox"]'); + const showDlCb = document.querySelector('input[name="show_download"]'); + const zoomInput = document.querySelector('input[name="default_zoom"]'); + + function updatePreview() { + let sc = '[breznflow id="' + postId + '"'; + const modeVal = document.querySelector('input[name="default_mode"]:checked'); + if (modeVal && modeVal.value !== 'visual') sc += ' mode="' + modeVal.value + '"'; + if (zoomInput && zoomInput.value !== '100') sc += ' zoom="' + zoomInput.value + '"'; + if (showTitleCb && !showTitleCb.checked) sc += ' show_title="0"'; + if (showInfoCb && !showInfoCb.checked) sc += ' show_infobox="0"'; + if (showDlCb && showDlCb.checked) sc += ' show_download="1"'; + sc += ']'; + scLive.textContent = sc; + + // Update copy button data attribute + const copyBtn = scLive.nextElementSibling; + if (copyBtn && copyBtn.classList.contains('breznflow-copy-sc')) { + copyBtn.setAttribute('data-sc', sc); + } + } + + modeRadios.forEach(function(r) { r.addEventListener('change', updatePreview); }); + if (showTitleCb) showTitleCb.addEventListener('change', updatePreview); + if (showInfoCb) showInfoCb.addEventListener('change', updatePreview); + if (showDlCb) showDlCb.addEventListener('change', updatePreview); + if (zoomInput) zoomInput.addEventListener('input', updatePreview); + } + +}()); diff --git a/assets/brezn.css b/assets/brezn.css new file mode 100644 index 0000000..06de5f1 --- /dev/null +++ b/assets/brezn.css @@ -0,0 +1,52 @@ +/* +Theme Name: Brezn +Theme ID: brezn +Description: Biergarten bei Nacht – dark amber canvas, Bavarian gold nodes, royal blue connections, state-seal red logic accents. +Author: BreznFlow +*/ + +.breznflow-wrap[data-theme="brezn"], +.breznflow-modal-overlay[data-theme="brezn"], +.breznflow-fs-portal[data-theme="brezn"] { + --breznflow-canvas-bg: #0d0800; + --breznflow-node-bg: #1e1300; + --breznflow-node-text: #f5c800; + --breznflow-node-sub: #8a6c00; + --breznflow-node-border: #3a2a00; + --breznflow-connection: #0066b3; + --breznflow-connection-hover: #3399ff; + --breznflow-toolbar-bg: #080500; + --breznflow-toolbar-text: #f5c800; + --breznflow-toolbar-border: #2a1f00; + --breznflow-panel-bg: #080500; + --breznflow-panel-text: #e8d070; + --breznflow-panel-border: #2a1f00; + --breznflow-btn-bg: #0066b3; + --breznflow-btn-text: #ffffff; + --breznflow-btn-border: #0077cc; + --breznflow-btn-hover-bg: #0077cc; + --breznflow-action-bar-bg: #080500; + --breznflow-action-bar-border: #2a1f00; + --breznflow-modal-overlay-bg: rgba(5, 3, 0, 0.88); + --breznflow-modal-bg: #100c00; + --breznflow-modal-border: #3a2a00; + --breznflow-modal-title: #f5c800; + --breznflow-modal-text: #e8d070; + --breznflow-modal-sub: #8a6c00; + --breznflow-modal-close: #8a6c00; + --breznflow-modal-secondary-bg: #0d0800; + --breznflow-modal-secondary-border: #3a2a00; + --breznflow-modal-code-bg: #050300; + --breznflow-tooltip-bg: rgba(5, 3, 0, 0.95); + --breznflow-tooltip-text: #f5c800; + --breznflow-fullscreen-overlay-bg: rgba(0, 0, 0, 0.92); + --breznflow-minimap-bg: rgba(13, 8, 0, 0.9); + --breznflow-minimap-border: #3a2a00; + --breznflow-color-trigger: #22c55e; + --breznflow-color-http: #0066b3; + --breznflow-color-code: #f5c800; + --breznflow-color-logic: #cc2200; + --breznflow-color-database: #b88a00; + --breznflow-color-ai: #ff8c00; + --breznflow-color-fallback: #5b9bc4; +} diff --git a/assets/renderer.css b/assets/renderer.css new file mode 100644 index 0000000..32adca9 --- /dev/null +++ b/assets/renderer.css @@ -0,0 +1,613 @@ +/* BreznFlow — Frontend Renderer Styles */ +/* Color values live in assets/themes/*.css — this file contains structure only. */ + +/* Wrap */ +.breznflow-embed { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 14px; + line-height: 1.5; + border: 1px solid var(--breznflow-node-border); + border-radius: 8px; + overflow: hidden; + background: var(--breznflow-canvas-bg); +} + +.breznflow-wrap { + display: flex; + flex-direction: column; + width: 100%; +} + +/* Title */ +.breznflow-title { + margin: 0 0 0.5em; +} + +/* Toolbar */ +.breznflow-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--breznflow-toolbar-bg); + border-bottom: 1px solid var(--breznflow-toolbar-border); + flex-wrap: wrap; +} + +.breznflow-toolbar-name { + font-weight: 600; + color: var(--breznflow-toolbar-text); + margin-right: auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 40%; +} + +.breznflow-btn { + background: var(--breznflow-btn-bg); + color: var(--breznflow-btn-text); + border: 1px solid var(--breznflow-btn-border); + border-radius: 4px; + padding: 4px 10px; + cursor: pointer; + font-size: 13px; + line-height: 1.4; + transition: background 0.15s; +} + +.breznflow-btn:hover { + background: var(--breznflow-btn-hover-bg); +} + +.breznflow-zoom-label { + color: var(--breznflow-node-sub); + font-size: 12px; + min-width: 36px; + text-align: center; +} + +/* Diagram Container */ +.breznflow-diagram-container { + position: relative; + overflow: hidden; + min-height: 200px; + cursor: grab; + user-select: none; +} + +.breznflow-diagram-container:active { + cursor: grabbing; +} + +.breznflow-svg { + display: block; + width: 100%; + overflow: visible; +} + +/* Canvas (zoom/pan transform applied here) */ +.breznflow-canvas { + transition: none; +} + +/* Connections */ +.breznflow-connection-path { + fill: none; + stroke: var(--breznflow-connection); + stroke-width: 2; + stroke-linecap: round; + transition: stroke 0.2s; +} + +.breznflow-connection-path:hover { + stroke: var(--breznflow-connection-hover); +} + +/* Nodes */ +.breznflow-node { + cursor: pointer; +} + +.breznflow-node:hover .breznflow-node-box { + filter: brightness(1.15); +} + +.breznflow-node.selected .breznflow-node-box { + stroke-width: 2; +} + +.breznflow-node-box { + fill: var(--breznflow-node-bg); + stroke: var(--breznflow-node-border); + stroke-width: 1; + rx: 6; + ry: 6; + transition: filter 0.15s; +} + +.breznflow-node-icon-rect { + rx: 4; + ry: 4; +} + +.breznflow-node-icon-text { + dominant-baseline: central; + text-anchor: middle; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; + font-weight: 700; +} + +.breznflow-node-name { + fill: var(--breznflow-node-text); + dominant-baseline: central; + text-anchor: start; + font-size: 13px; + font-weight: 600; +} + +.breznflow-node-type { + fill: var(--breznflow-node-sub); + dominant-baseline: central; + text-anchor: start; + font-size: 10px; +} + +/* Detail Panel */ +.breznflow-detail-panel { + background: var(--breznflow-panel-bg); + border-top: 1px solid var(--breznflow-panel-border); + color: var(--breznflow-panel-text); + overflow: hidden; + max-height: 0; + transition: max-height 0.3s ease; +} + +.breznflow-detail-panel.open { + max-height: 400px; + overflow-y: auto; +} + +.breznflow-detail-panel-inner { + padding: 16px; +} + +.breznflow-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.breznflow-detail-title { + font-size: 15px; + font-weight: 600; +} + +.breznflow-detail-close { + background: none; + border: none; + color: var(--breznflow-node-sub); + cursor: pointer; + font-size: 18px; + padding: 0 4px; + line-height: 1; +} + +.breznflow-detail-close:hover { + color: var(--breznflow-panel-text); +} + +.breznflow-detail-dl { + display: grid; + grid-template-columns: max-content 1fr; + gap: 4px 16px; + margin: 0; + font-size: 13px; +} + +.breznflow-detail-dt { + color: var(--breznflow-node-sub); + font-weight: 500; +} + +.breznflow-detail-dd { + margin: 0; + word-break: break-word; + color: var(--breznflow-panel-text); +} + +.breznflow-detail-multiline { + margin: 0; + font-family: inherit; + font-size: 0.88em; + white-space: pre-wrap; + word-break: break-word; + color: var(--breznflow-panel-text); + line-height: 1.5; +} + +.breznflow-detail-code { + background: var(--breznflow-modal-code-bg); + border: 1px solid var(--breznflow-modal-border); + border-radius: 4px; + padding: 8px; + font-family: monospace; + font-size: 12px; + overflow-y: auto; + white-space: pre; + color: #a8e6cf; + margin: 4px 0; +} + +/* InfoBox */ +.breznflow-infobox { + background: var(--breznflow-toolbar-bg); + border-top: 1px solid var(--breznflow-node-border); + padding: 12px 16px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.breznflow-infobox-nodes { + display: flex; + flex-wrap: wrap; + gap: 6px; + flex: 1; +} + +.breznflow-infobox-node { + background: var(--breznflow-btn-bg); + border-radius: 4px; + padding: 2px 8px; + font-size: 12px; + color: var(--breznflow-btn-text); + white-space: nowrap; +} + +.breznflow-infobox-node strong { + color: var(--breznflow-node-text); +} + +.breznflow-infobox-more { + font-size: 12px; + color: var(--breznflow-node-sub); + background: none; + border: none; + padding: 2px 4px; + cursor: pointer; + border-radius: 3px; + transition: color 0.15s, background 0.15s; +} + +.breznflow-infobox-more:hover { + color: var(--breznflow-panel-text); + background: rgba(255, 255, 255, 0.08); +} + +.breznflow-infobox-ai { + display: flex; + align-items: center; +} + +.breznflow-ai-badge { + background: linear-gradient(135deg, #7c3aed, #4338ca); + color: #fff; + border-radius: 12px; + padding: 2px 10px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; +} + +.breznflow-infobox-total { + font-size: 12px; + color: var(--breznflow-node-sub); + white-space: nowrap; +} + +/* Admin badge */ +.breznflow-badge-ai { + background: #7c3aed; + color: #fff; + padding: 1px 6px; + border-radius: 3px; + font-size: 11px; + font-weight: 600; +} + +/* Arrowhead */ +.breznflow-arrow-head { + fill: var(--breznflow-connection); +} + +/* Category node border colors */ +[data-category="trigger"] .breznflow-node-box { stroke: var(--breznflow-color-trigger); stroke-width: 2; } +[data-category="http"] .breznflow-node-box { stroke: var(--breznflow-color-http); stroke-width: 2; } +[data-category="code"] .breznflow-node-box { stroke: var(--breznflow-color-code); stroke-width: 2; } +[data-category="logic"] .breznflow-node-box { stroke: var(--breznflow-color-logic); stroke-width: 2; } +[data-category="database"].breznflow-node-box { stroke: var(--breznflow-color-database); stroke-width: 2; } +[data-category="ai"] .breznflow-node-box { stroke: var(--breznflow-color-ai); stroke-width: 2; } + +/* Connection dots — match category color */ +[data-category="trigger"] circle { stroke: var(--breznflow-color-trigger); } +[data-category="http"] circle { stroke: var(--breznflow-color-http); } +[data-category="code"] circle { stroke: var(--breznflow-color-code); } +[data-category="logic"] circle { stroke: var(--breznflow-color-logic); } +[data-category="database"]circle { stroke: var(--breznflow-color-database); } +[data-category="ai"] circle { stroke: var(--breznflow-color-ai); } + +/* Tooltip */ +.breznflow-tooltip { + position: absolute; + pointer-events: none; + background: var(--breznflow-tooltip-bg); + color: var(--breznflow-tooltip-text); + font-size: 12px; + padding: 4px 10px; + border-radius: 4px; + white-space: nowrap; + transform: translate(-50%, -100%); + opacity: 0; + transition: opacity 0.12s; + z-index: 10; +} + +.breznflow-tooltip.visible { opacity: 1; } + +/* Fullscreen portal — appended to to escape any containing block */ +.breznflow-fs-portal { + position: fixed; + inset: 0; + top: var(--wp-admin--admin-bar--height, 0px); + z-index: 9999; + display: flex; + align-items: stretch; + padding: 48px; + background: var(--breznflow-fullscreen-overlay-bg); + cursor: default; +} + +.breznflow-fs-portal .breznflow-wrap { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + border-radius: 10px; + overflow: hidden; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.7); + cursor: default; +} + +.breznflow-fs-portal .breznflow-diagram-container { + flex: 1; + min-height: 0; + height: auto !important; + cursor: grab; +} + +.breznflow-fs-portal .breznflow-diagram-container:active { + cursor: grabbing; +} + +/* Minimap */ +.breznflow-minimap { + position: absolute; + bottom: 8px; + right: 8px; + width: 160px; + height: 100px; + background: var(--breznflow-minimap-bg); + border: 1px solid var(--breznflow-minimap-border); + border-radius: 4px; + overflow: hidden; + cursor: crosshair; + z-index: 5; +} + +.breznflow-minimap svg { display: block; } + +/* Infobox node badges — interactive highlight */ +.breznflow-infobox-node { + cursor: pointer; + border-left: 3px solid transparent; + padding-left: 6px; + transition: background 0.15s, opacity 0.15s; +} + +.breznflow-infobox-node:hover { + background: var(--breznflow-btn-hover-bg); +} + +.breznflow-infobox-node.active { + background: var(--breznflow-btn-hover-bg); + outline: 1px solid rgba(255,255,255,0.15); + outline-offset: -1px; +} + +/* Category-colored left borders on infobox badges */ +.breznflow-infobox-node[data-category="trigger"] { border-left-color: var(--breznflow-color-trigger); } +.breznflow-infobox-node[data-category="http"] { border-left-color: var(--breznflow-color-http); } +.breznflow-infobox-node[data-category="code"] { border-left-color: var(--breznflow-color-code); } +.breznflow-infobox-node[data-category="logic"] { border-left-color: var(--breznflow-color-logic); } +.breznflow-infobox-node[data-category="database"]{ border-left-color: var(--breznflow-color-database); } +.breznflow-infobox-node[data-category="ai"] { border-left-color: var(--breznflow-color-ai); } + +/* Dim all nodes when highlight is active */ +.breznflow-nodes.breznflow-dim .breznflow-node { + opacity: 0.15; + transition: opacity 0.12s; +} + +/* Keep highlighted node fully visible */ +.breznflow-nodes.breznflow-dim .breznflow-node.breznflow-highlighted { + opacity: 1; +} + +/* Brighten the highlighted node box */ +.breznflow-node.breznflow-highlighted .breznflow-node-box { + filter: brightness(1.35); +} + +/* Action Bar */ +.breznflow-action-bar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 8px 12px; + background: var(--breznflow-action-bar-bg); + border-top: 1px solid var(--breznflow-action-bar-border); +} + +/* Modal Overlay + Box */ +.breznflow-modal-overlay { + position: fixed; + inset: 0; + z-index: 10000; + background: var(--breznflow-modal-overlay-bg); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.breznflow-modal-box { + background: var(--breznflow-modal-bg); + border: 1px solid var(--breznflow-modal-border); + border-radius: 8px; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.7); + min-width: 320px; + max-width: 540px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.breznflow-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px 10px; + border-bottom: 1px solid var(--breznflow-modal-border); +} + +.breznflow-modal-title { + font-size: 15px; + font-weight: 600; + color: var(--breznflow-modal-title); +} + +.breznflow-modal-close { + background: none; + border: none; + color: var(--breznflow-modal-close); + cursor: pointer; + font-size: 20px; + padding: 0 4px; + line-height: 1; +} + +.breznflow-modal-close:hover { + color: var(--breznflow-modal-text); +} + +.breznflow-modal-body { + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Share Blocks */ +.breznflow-share-block { + background: var(--breznflow-modal-secondary-bg); + border: 1px solid var(--breznflow-modal-secondary-border); + border-radius: 6px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.breznflow-share-label { + font-size: 11px; + font-weight: 600; + color: var(--breznflow-modal-sub); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.breznflow-share-preview { + font-size: 12px; + color: var(--breznflow-modal-text); + white-space: pre-wrap; + word-break: break-all; + background: var(--breznflow-modal-code-bg); + border: 1px solid var(--breznflow-modal-border); + border-radius: 4px; + padding: 8px; + font-family: monospace; + line-height: 1.5; + margin: 0; +} + +/* Textarea + JSON meta */ +.breznflow-modal-textarea { + width: 100%; + min-height: 90px; + background: var(--breznflow-modal-code-bg); + border: 1px solid var(--breznflow-modal-border); + border-radius: 4px; + color: var(--breznflow-modal-text); + font-family: monospace; + font-size: 12px; + padding: 8px; + resize: vertical; + box-sizing: border-box; +} + +.breznflow-modal-desc { + font-size: 13px; + color: var(--breznflow-modal-sub); + margin: 0; +} + +.breznflow-json-meta { + display: flex; + justify-content: flex-end; +} + +.breznflow-json-size { + font-size: 11px; + color: var(--breznflow-modal-sub); +} + +.breznflow-copy-btn { + align-self: flex-start; +} + +/* Mobile */ +@media (max-width: 600px) { + .breznflow-btn-zoom { + display: none; + } + + .breznflow-detail-panel.open { + max-height: 300px; + } + + .breznflow-modal-box { + min-width: 0; + max-width: 100%; + } + + .breznflow-action-bar { + padding: 6px 8px; + gap: 6px; + } +} diff --git a/assets/renderer.js b/assets/renderer.js new file mode 100644 index 0000000..695ff06 --- /dev/null +++ b/assets/renderer.js @@ -0,0 +1,1544 @@ +/* BreznFlow — Frontend SVG Renderer (ES2020, zero dependencies) */ +/* global breznflowData, breznflowIcons, breznflowI18n */ + +(function () { + 'use strict'; + + var i18n = (typeof breznflowI18n !== 'undefined') ? breznflowI18n : {}; + + const NODE_W = 180; + const NODE_H = 60; + const ICON_SIZE = 36; + const ICON_MARGIN = 12; + const PADDING = 60; + const MIN_SCALE = 0.1; + const MAX_SCALE = 5.0; + const ZOOM_STEP = 0.1; + const SVG_NS = 'http://www.w3.org/2000/svg'; + + // ── Helpers ────────────────────────────────────────────────────────────── + + function svgEl(tag, attrs) { + const el = document.createElementNS(SVG_NS, tag); + if (attrs) { + for (const [k, v] of Object.entries(attrs)) { + el.setAttribute(k, String(v)); + } + } + return el; + } + + function htmlEl(tag, cls) { + const el = document.createElement(tag); + if (cls) el.className = cls; + return el; + } + + function clearElement(el) { + while (el.firstChild) el.removeChild(el.firstChild); + } + + function connectionPath(sx, sy, tx, ty) { + const cp = Math.max(Math.abs(tx - sx) * 0.5, 60); + return 'M ' + sx + ' ' + sy + ' C ' + (sx + cp) + ' ' + sy + ' ' + (tx - cp) + ' ' + ty + ' ' + tx + ' ' + ty; + } + + function truncate(str, max) { + if (!str) return ''; + return str.length > max ? str.slice(0, max - 1) + '\u2026' : str; + } + + // ── Node type lookup ────────────────────────────────────────────────────── + + function extractSlug(type) { + if (!type) return ''; + const parts = type.split('.'); + return parts[parts.length - 1] || type; + } + + // ── Category System ─────────────────────────────────────────────────────── + + const CATEGORY_STROKE = { + trigger: '#22c55e', + http: '#3b82f6', + code: '#f97316', + logic: '#a855f7', + database: '#eab308', + ai: '#ec4899', + }; + + const CATEGORY_LABEL = { + trigger: 'Trigger', http: 'HTTP', + code: 'Code', logic: 'Logic', + database: 'Database', ai: 'AI', + transform: 'Transform', action: '', + }; + + const LOGIC_SLUGS = new Set(['if','switch','filter','merge','splitinbatches','splitout','sort','limit','removeduplicates','aggregate','comparedatasets','itemlists']); + const TRANSFORM_SLUGS = new Set(['code','function','executeworkflow','set','editfields','html','xml','markdown','crypto','tofile','converttofile','extractfromfile','compression']); + const DATABASE_SLUGS = new Set(['mysql','postgres','redis','mongodb','sqlite','microsoftsql','supabase']); + const AI_KEYWORDS_CAT = ['openai','anthropic','claude','gemini','googleai','huggingface','langchain','agent','vectorstore','gpt','ollama','mistral','cohere','lmchat','chainllm','memorybuffer']; + + function getNodeCategory(slug) { + const s = (slug || '').toLowerCase().replace(/[^a-z]/g, ''); + if (s === 'httprequest') return 'http'; + if (s.includes('trigger') || s.includes('webhook') || s === 'respondtowebhook') return 'trigger'; + if (LOGIC_SLUGS.has(s)) return 'logic'; + if (DATABASE_SLUGS.has(s)) return 'database'; + if (AI_KEYWORDS_CAT.some(function(kw) { return s.includes(kw); })) return 'ai'; + if (TRANSFORM_SLUGS.has(s)) return 'code'; + return 'action'; + } + + function getTooltipSummary(node, category) { + const params = node.parameters || {}; + if (category === 'code') { + const src = params.jsCode || params.functionCode || ''; + if (src) { + const lines = src.split('\n').filter(function(l) { return l.trim(); }).length; + return lines + ' ' + (lines === 1 ? (i18n.line || 'line') : (i18n.lines || 'lines')); + } + } else if (category === 'http') { + const method = params.method || 'GET'; + const url = params.url ? String(params.url).slice(0, 40) : ''; + return method + (url ? ' \u00b7 ' + url : ''); + } else if (category === 'trigger') { + const rule = params.rule; + if (rule && Array.isArray(rule.interval) && rule.interval[0]) { + const item = rule.interval[0]; + const IMAP = { minutes: 'minutesInterval', hours: 'hoursInterval', days: 'daysInterval', weeks: 'weeksInterval', months: 'monthsInterval' }; + const ik = IMAP[item.field]; + if (ik && item[ik]) return 'Every ' + item[ik] + ' ' + item.field; + if (item.cronExpression) return 'Cron: ' + item.cronExpression; + } + } else if (category === 'database') { + const tbl = params.table; + if (tbl && typeof tbl === 'object' && tbl.__rl) return tbl.cachedResultName || tbl.value || ''; + if (typeof tbl === 'string' && tbl) return tbl; + } + return ''; + } + + function lookupNode(type) { + const icons = (typeof breznflowIcons !== 'undefined') ? breznflowIcons : {}; + const slug = extractSlug(type); + + if (icons[slug]) return icons[slug]; + + const slugLower = slug.toLowerCase(); + for (const [key, entry] of Object.entries(icons)) { + if (key.toLowerCase() === slugLower) return entry; + } + + return generateFallback(type, slug); + } + + function djb2Hue(str) { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash) + str.charCodeAt(i); + hash = hash >>> 0; + } + return hash % 360; + } + + function hslToHex(h, s, l) { + s /= 100; l /= 100; + const a = s * Math.min(l, 1 - l); + const f = function(n) { + const k = (n + h / 30) % 12; + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return Math.round(255 * Math.max(0, Math.min(1, color))); + }; + return '#' + [f(0), f(8), f(4)].map(function(x) { return x.toString(16).padStart(2, '0'); }).join(''); + } + + function deriveInitials(slug) { + const parts = slug.split(/(?=[A-Z])/); + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); + return slug.slice(0, 2).toUpperCase(); + } + + function generateFallback(type, slug) { + const hue = djb2Hue(type); + return { + label: slug, + icon: 'initials', + symbol: deriveInitials(slug), + color: hslToHex(hue, 60, 55), + bg: hslToHex(hue, 40, 15), + }; + } + + // ── Layout computation ──────────────────────────────────────────────────── + + function computeLayout(nodes, containerW) { + if (!nodes || nodes.length === 0) return { nodes: [], scale: 1, contentW: 400, contentH: 200 }; + + const xs = nodes.map(function(n) { return n.position[0]; }); + const ys = nodes.map(function(n) { return n.position[1]; }); + const minX = Math.min.apply(null, xs); + const minY = Math.min.apply(null, ys); + const maxX = Math.max.apply(null, xs); + const maxY = Math.max.apply(null, ys); + + const contentW = (maxX - minX) + NODE_W + PADDING * 2; + const contentH = (maxY - minY) + NODE_H + PADDING * 2; + + // Pure fit-to-width scale. userZoom is applied separately in _applyTransform + // so it is never double-counted. + const scale = containerW / contentW; + + const mapped = nodes.map(function(n) { + return Object.assign({}, n, { + x: (n.position[0] - minX + PADDING), + y: (n.position[1] - minY + PADDING), + }); + }); + + return { nodes: mapped, scale: scale, contentW: contentW, contentH: contentH }; + } + + // ── SVG Rendering ───────────────────────────────────────────────────────── + + function renderNodeSVG(node) { + const info = lookupNode(node.type || ''); + const slug = extractSlug(node.type || ''); + const category = getNodeCategory(slug); + + const g = svgEl('g', { + class: 'breznflow-node', + 'data-node-id': node.id || '', + 'data-category': category, + 'data-label': info.label, + }); + g.setAttribute('transform', 'translate(' + node.x + ', ' + node.y + ')'); + + const box = svgEl('rect', { + class: 'breznflow-node-box', + x: 0, y: 0, width: NODE_W, height: NODE_H, + rx: 6, ry: 6, + }); + g.appendChild(box); + + const iconBg = svgEl('rect', { + class: 'breznflow-node-icon-rect', + x: ICON_MARGIN, + y: (NODE_H - ICON_SIZE) / 2, + width: ICON_SIZE, + height: ICON_SIZE, + fill: info.bg || '#333', + rx: 4, ry: 4, + }); + g.appendChild(iconBg); + + const iconX = ICON_MARGIN + ICON_SIZE / 2; + const iconY = NODE_H / 2; + const iconText = svgEl('text', { + class: 'breznflow-node-icon-text', + x: iconX, y: iconY, + fill: info.color || '#fff', + 'font-size': info.symbol && info.symbol.length > 2 ? '11' : '14', + }); + iconText.textContent = info.symbol || '?'; + g.appendChild(iconText); + + const textX = ICON_MARGIN + ICON_SIZE + 10; + const nameEl = svgEl('text', { + class: 'breznflow-node-name', + x: textX, + y: NODE_H / 2 - 8, + }); + nameEl.textContent = truncate(node.name || '', 16); + g.appendChild(nameEl); + + const typeEl = svgEl('text', { + class: 'breznflow-node-type', + x: textX, + y: NODE_H / 2 + 10, + }); + typeEl.textContent = truncate(info.label || slug, 18); + g.appendChild(typeEl); + + const dotIn = svgEl('circle', { + cx: 0, cy: NODE_H / 2, r: 4, + fill: '#444', stroke: '#555', 'stroke-width': 1.5, + }); + const dotOut = svgEl('circle', { + cx: NODE_W, cy: NODE_H / 2, r: 4, + fill: '#444', stroke: '#555', 'stroke-width': 1.5, + }); + g.appendChild(dotIn); + g.appendChild(dotOut); + + return { el: g, category: category }; + } + + function renderConnections(nodes, connections, defs, renderId) { + const nodeMap = {}; + for (const n of nodes) nodeMap[n.name] = n; + + const markerId = 'breznflow-arrow-' + renderId; + const marker = svgEl('marker', { + id: markerId, + markerWidth: 8, markerHeight: 8, + refX: 6, refY: 3, + orient: 'auto', + }); + const arrow = svgEl('path', { + d: 'M0,0 L0,6 L8,3 z', + class: 'breznflow-arrow-head', + }); + marker.appendChild(arrow); + defs.appendChild(marker); + + const g = svgEl('g', { class: 'breznflow-connections' }); + if (!connections) return g; + + for (const [sourceName, outputs] of Object.entries(connections)) { + const src = nodeMap[sourceName]; + if (!src || !outputs || !outputs.main) continue; + + for (const outputSlot of outputs.main) { + if (!Array.isArray(outputSlot)) continue; + for (const conn of outputSlot) { + const tgt = nodeMap[conn.node]; + if (!tgt) continue; + + const sx = src.x + NODE_W; + const sy = src.y + NODE_H / 2; + const tx = tgt.x; + const ty = tgt.y + NODE_H / 2; + + const path = svgEl('path', { + class: 'breznflow-connection-path', + d: connectionPath(sx, sy, tx, ty), + 'marker-end': 'url(#' + markerId + ')', + }); + g.appendChild(path); + } + } + } + + return g; + } + + // ── Tooltip ─────────────────────────────────────────────────────────────── + + function buildTooltip(diagramContainer) { + const tip = htmlEl('div', 'breznflow-tooltip'); + tip.setAttribute('aria-hidden', 'true'); + diagramContainer.appendChild(tip); + let hideTimer = null; + return { + show: function(x, y, text) { + clearTimeout(hideTimer); + tip.textContent = text; + tip.style.left = x + 'px'; + tip.style.top = (y - 40) + 'px'; + tip.classList.add('visible'); + }, + hide: function() { + hideTimer = setTimeout(function() { tip.classList.remove('visible'); }, 80); + }, + }; + } + + // ── Minimap ─────────────────────────────────────────────────────────────── + + const MINIMAP_W = 160, MINIMAP_H = 100; + + function buildMinimap(diagramContainer, onNavigate) { + const wrap = htmlEl('div', 'breznflow-minimap'); + const svg = svgEl('svg', { width: MINIMAP_W, height: MINIMAP_H }); + wrap.appendChild(svg); + diagramContainer.appendChild(wrap); + let savedLayout = null; + let dragging = false; + + function getPos(e) { + const r = svg.getBoundingClientRect(); + return { x: e.clientX - r.left, y: e.clientY - r.top }; + } + + function navigate(e) { + if (!savedLayout) return; + const pos = getPos(e); + const fitScale = Math.min(MINIMAP_W / savedLayout.contentW, MINIMAP_H / savedLayout.contentH); + onNavigate(pos.x / fitScale, pos.y / fitScale); + } + + svg.addEventListener('mousedown', function(e) { dragging = true; navigate(e); e.stopPropagation(); }); + svg.addEventListener('mousemove', function(e) { if (dragging) navigate(e); }); + document.addEventListener('mouseup', function() { dragging = false; }); + + return { + el: wrap, + update: function(layout, tx, ty, effectiveScale, containerW, containerH) { + savedLayout = layout; + clearElement(svg); + if (!layout || !layout.nodes.length) return; + const fitScale = Math.min(MINIMAP_W / layout.contentW, MINIMAP_H / layout.contentH); + for (const node of layout.nodes) { + const cat = getNodeCategory(extractSlug(node.type || '')); + const col = CATEGORY_STROKE[cat] || '#555'; + svg.appendChild(svgEl('rect', { + x: node.x * fitScale, y: node.y * fitScale, + width: Math.max(NODE_W * fitScale, 4), + height: Math.max(NODE_H * fitScale, 3), + fill: col, opacity: 0.75, rx: 1, + })); + } + const vx = (-tx / effectiveScale) * fitScale; + const vy = (-ty / effectiveScale) * fitScale; + const vw = (containerW / effectiveScale) * fitScale; + const vh = (containerH / effectiveScale) * fitScale; + svg.appendChild(svgEl('rect', { + x: vx, y: vy, width: vw, height: vh, + fill: 'none', stroke: 'rgba(255,255,255,0.6)', 'stroke-width': 1.5, rx: 2, + })); + }, + }; + } + + // ── Detail Panel Helpers ───────────────────────────────────────────────── + + const PARAM_LABELS = { + url: 'URL', + method: 'Method', + authentication: 'Authentication', + numberInputs: 'Inputs', + rule: 'Rule', + amount: 'Amount', + jsCode: 'Code', + functionCode: 'Code', + table: 'Table', + dataMode: 'Data Mode', + valuesToSend: 'Columns', + sendBody: 'Send Body', + sendHeaders: 'Send Headers', + contentType: 'Content Type', + rawContentType: 'Content Type (raw)', + body: 'Body', + webhookId: 'Webhook ID', + nodeCredentialType: 'Credential Type', + headerParameters: 'Header Parameters', + queryParameters: 'Query Parameters', + bodyParameters: 'Body Parameters', + conditions: 'Conditions', + modelId: 'Model', + operation: 'Operation', + resource: 'Resource', + query: 'Query', + specifyBody: 'Specify Body', + jsonBody: 'JSON Body', + }; + + // Field names whose values should always be redacted in the frontend display. + const SENSITIVE_HEADER_NAMES = ['authorization', 'token', 'api-key', 'apikey', 'x-api-key', 'x-auth', 'secret', 'password', 'bearer']; + + function isSensitiveHeaderName(name) { + var lower = (name || '').toLowerCase(); + return SENSITIVE_HEADER_NAMES.some(function(kw) { return lower.indexOf(kw) !== -1; }); + } + + function humanizeKey(k) { + if (PARAM_LABELS[k]) return PARAM_LABELS[k]; + return k.replace(/([A-Z])/g, ' $1').replace(/^./, function(s) { return s.toUpperCase(); }).trim(); + } + + function isEmptyValue(v) { + if (v === null || v === undefined || v === '') return true; + if (Array.isArray(v)) return v.length === 0; + if (typeof v === 'object') return Object.keys(v).length === 0; + return false; + } + + function humanizeScheduleRule(rule) { + if (!rule || !Array.isArray(rule.interval)) return null; + const parts = []; + const FIELD_MAP = { minutes: 'minutesInterval', hours: 'hoursInterval', days: 'daysInterval', weeks: 'weeksInterval', months: 'monthsInterval' }; + for (const item of rule.interval) { + const intervalKey = FIELD_MAP[item.field]; + if (intervalKey && item[intervalKey]) { + parts.push('Every ' + item[intervalKey] + ' ' + item.field); + } else if (item.cronExpression) { + parts.push('Cron: ' + item.cronExpression); + } else if (item.field) { + parts.push(item.field); + } + } + return parts.join(', ') || null; + } + + // Format [{name, value}, ...] arrays (HTTP headers, body params, etc.) + function humanizeNameValueArray(arr) { + if (!Array.isArray(arr) || arr.length === 0) return null; + const lines = []; + for (const item of arr) { + const name = item.name || item.key || item.parameterName || ''; + const raw = item.value !== undefined ? item.value : (item.parameterValue || ''); + const val = isSensitiveHeaderName(name) ? '[REDACTED]' : String(raw).slice(0, 150); + if (name) lines.push(name + ': ' + val); + } + return lines.length > 0 ? lines.join('\n') : null; + } + + // Format conditions list into readable summary + function humanizeConditionsList(conditions) { + if (!Array.isArray(conditions) || conditions.length === 0) return null; + const lines = conditions.map(function(c) { + const left = String(c.leftValue || '').slice(0, 50); + const right = String(c.rightValue || '').slice(0, 50); + const opType = c.operator ? (c.operator.operation || c.operator.type || '') : ''; + return left + (opType ? ' [' + opType + '] ' : ' = ') + right; + }); + return lines.join('\n'); + } + + // Flatten nested options objects into key: value pairs (max 3 levels deep) + function flattenOptionsObject(obj, depth) { + if (!depth) depth = 0; + if (depth > 3 || typeof obj !== 'object' || obj === null) return []; + const pairs = []; + for (const k of Object.keys(obj)) { + const v = obj[k]; + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') { + const str = String(v); + if (str.length < 200 && str !== '') pairs.push(humanizeKey(k) + ': ' + str); + } else if (typeof v === 'object' && v !== null) { + const nested = flattenOptionsObject(v, depth + 1); + for (const p of nested) pairs.push(p); + } + if (pairs.length >= 8) break; + } + return pairs; + } + + function humanizeParamValue(k, v) { + // Schedule rule + if (k === 'rule' && typeof v === 'object' && v !== null) { + return humanizeScheduleRule(v); + } + + // n8n Resource Locator (__rl: true) — any field + if (typeof v === 'object' && v !== null && v.__rl === true) { + return v.cachedResultName || String(v.value || '') || null; + } + + // {parameters: [{name, value}]} wrapper (headerParameters, queryParameters, etc.) + if (typeof v === 'object' && v !== null && Array.isArray(v.parameters)) { + return humanizeNameValueArray(v.parameters); + } + + // Direct array of {name, value} pairs + if (Array.isArray(v) && v.length > 0 && v[0] && (v[0].name !== undefined || v[0].key !== undefined)) { + return humanizeNameValueArray(v); + } + + // {values: [...]} wrapper — messages (OpenAI), columns, etc. + if (typeof v === 'object' && v !== null && Array.isArray(v.values)) { + if (v.values.length === 0) return null; + // Message array (role + content) + if (v.values[0] && v.values[0].role !== undefined) { + return v.values.map(function(m) { + return (m.role || '?') + ': ' + String(m.content || ''); + }).join('\n---\n'); + } + // Column list + if (v.values[0] && v.values[0].column !== undefined) { + return v.values.map(function(c) { return c.column; }).join(', '); + } + return v.values.length + ' items'; + } + + // Conditions object {conditions: [...], combinator, options} + if (k === 'conditions' && typeof v === 'object' && v !== null && Array.isArray(v.conditions)) { + return humanizeConditionsList(v.conditions); + } + + // Options: flatten to readable key: value pairs + if (k === 'options' && typeof v === 'object' && v !== null) { + const pairs = flattenOptionsObject(v); + return pairs.length > 0 ? pairs.join('\n') : null; + } + + // valuesToSend (legacy) + if (k === 'valuesToSend' && typeof v === 'object' && v !== null && Array.isArray(v.values)) { + return v.values.map(function(col) { return col.column; }).join(', '); + } + + return null; + } + + // ── Detail Panel ────────────────────────────────────────────────────────── + + function buildDetailPanel(maxCodeLines) { + const panel = htmlEl('div', 'breznflow-detail-panel'); + const inner = htmlEl('div', 'breznflow-detail-panel-inner'); + + const header = htmlEl('div', 'breznflow-detail-header'); + const titleEl = htmlEl('div', 'breznflow-detail-title'); + const closeBtn = htmlEl('button', 'breznflow-detail-close'); + closeBtn.textContent = '\u00d7'; + closeBtn.setAttribute('aria-label', i18n.close || 'Close'); + header.appendChild(titleEl); + header.appendChild(closeBtn); + + const content = htmlEl('div', 'breznflow-detail-content'); + + inner.appendChild(header); + inner.appendChild(content); + panel.appendChild(inner); + + function addRow(dl, key, value, multiline) { + const dt = htmlEl('dt', 'breznflow-detail-dt'); + const dd = htmlEl('dd', 'breznflow-detail-dd'); + dt.textContent = key; + if (multiline && value.indexOf('\n') !== -1) { + const pre = htmlEl('pre', 'breznflow-detail-multiline'); + pre.textContent = value; + dd.appendChild(pre); + } else { + dd.textContent = value; // always textContent, never innerHTML + } + dl.appendChild(dt); + dl.appendChild(dd); + } + + function openPanel(node) { + titleEl.textContent = node.name || ''; + clearElement(content); + + const info = lookupNode(node.type || ''); + const dl = htmlEl('dl', 'breznflow-detail-dl'); + + addRow(dl, i18n.type || 'Type', info.label || extractSlug(node.type || '')); + // ID intentionally omitted — internal n8n field, not useful for readers + + if (node.parameters && typeof node.parameters === 'object') { + for (const [k, v] of Object.entries(node.parameters)) { + // Code blocks: special rendering + if (k === 'jsCode' || k === 'functionCode') { + const dt = htmlEl('dt', 'breznflow-detail-dt'); + dt.textContent = i18n.code || 'Code'; + const dd = htmlEl('dd', 'breznflow-detail-dd'); + const pre = htmlEl('pre', 'breznflow-detail-code'); + const code = htmlEl('code'); + const codeStr = String(v || ''); + code.textContent = codeStr; // textContent only, never interpreted + const lineCount = codeStr.split('\n').length; + pre.style.maxHeight = 'calc(' + Math.min(lineCount, maxCodeLines || 50) + ' * 1.4em + 16px)'; + pre.appendChild(code); + dd.appendChild(pre); + dl.appendChild(dt); + dl.appendChild(dd); + continue; + } + + // Skip empty values: null, '', [], {} + if (isEmptyValue(v)) continue; + + const label = humanizeKey(k); + + if (typeof v === 'object' && v !== null) { + const smart = humanizeParamValue(k, v); + if (smart !== null) { + addRow(dl, label, smart, true); + } else { + // Generic flattening — no raw JSON ever shown + const pairs = flattenOptionsObject(v); + if (pairs.length > 0) { + addRow(dl, label, pairs.join('\n'), true); + } + } + } else { + const str = String(v !== null && v !== undefined ? v : ''); + // n8n expression strings (={{ ... }} or ={ ... }) -> code block + if (str.startsWith('={{') || str.startsWith('={')) { + const dtExpr = htmlEl('dt', 'breznflow-detail-dt'); + const ddExpr = htmlEl('dd', 'breznflow-detail-dd'); + dtExpr.textContent = label; + const preExpr = htmlEl('pre', 'breznflow-detail-code'); + const codeExpr = htmlEl('code'); + codeExpr.textContent = str; + const exprLines = str.split('\n').length; + preExpr.style.maxHeight = 'calc(' + Math.min(exprLines + 1, maxCodeLines || 15) + ' * 1.4em + 16px)'; + preExpr.appendChild(codeExpr); + ddExpr.appendChild(preExpr); + dl.appendChild(dtExpr); + dl.appendChild(ddExpr); + } else { + addRow(dl, label, str.slice(0, 500)); + } + } + } + } + + // Credentials: name only, no internal ID + if (node.credentials && typeof node.credentials === 'object') { + for (const [, v] of Object.entries(node.credentials)) { + const name = (v && typeof v === 'object') ? (v.name || '') : String(v || ''); + if (name) addRow(dl, i18n.credential || 'Credential', name); + } + } + + content.appendChild(dl); + panel.classList.add('open'); + panel.setAttribute('aria-hidden', 'false'); + panel.focus(); + } + + function closePanel() { + panel.classList.remove('open'); + panel.setAttribute('aria-hidden', 'true'); + } + + closeBtn.addEventListener('click', closePanel); + panel.addEventListener('keydown', function(e) { + if (e.key === 'Escape') closePanel(); + }); + panel.setAttribute('tabindex', '-1'); + panel.setAttribute('aria-hidden', 'true'); + + return { panel: panel, open: openPanel, close: closePanel }; + } + + // ── InfoBox ─────────────────────────────────────────────────────────────── + + function buildInfoBox(workflow) { + const div = htmlEl('div', 'breznflow-infobox'); + const AI_KW = ['openai','anthropic','claude','gemini','googleai','huggingface','langchain','agent','vectorstore','gpt','ollama','mistral','cohere']; + + const counts = {}; + let hasAi = false; + + for (const node of (workflow.nodes || [])) { + const slug = extractSlug(node.type || ''); + const info = lookupNode(node.type || ''); + const label = info.label || slug; + counts[label] = (counts[label] || 0) + 1; + const slugLower = slug.toLowerCase(); + if (!hasAi && AI_KW.some(function(kw) { return slugLower.indexOf(kw) !== -1; })) hasAi = true; + } + + const sorted = Object.entries(counts).sort(function(a, b) { return b[1] - a[1]; }); + const display = sorted.slice(0, 6); + const hidden = sorted.slice(6); + const more = hidden.length; + + const nodesDiv = htmlEl('div', 'breznflow-infobox-nodes'); + + function makeSimpleSpan(label, count) { + const span = htmlEl('span', 'breznflow-infobox-node'); + const strong = htmlEl('strong'); + strong.textContent = count + 'x'; + span.appendChild(strong); + span.appendChild(document.createTextNode(' ' + label)); + return span; + } + + for (const [label, count] of display) { + nodesDiv.appendChild(makeSimpleSpan(label, count)); + } + + if (more > 0) { + const moreBtn = htmlEl('button', 'breznflow-infobox-more'); + moreBtn.textContent = '+ ' + more + ' ' + (i18n.more || 'more'); + moreBtn.addEventListener('click', function() { + moreBtn.parentNode.removeChild(moreBtn); + for (const [lbl, cnt] of hidden) { + nodesDiv.appendChild(makeSimpleSpan(lbl, cnt)); + } + }); + nodesDiv.appendChild(moreBtn); + } + div.appendChild(nodesDiv); + + if (hasAi) { + const aiDiv = htmlEl('div', 'breznflow-infobox-ai'); + const badge = htmlEl('span', 'breznflow-ai-badge'); + badge.textContent = i18n.aiPowered || 'AI-powered'; + aiDiv.appendChild(badge); + div.appendChild(aiDiv); + } + + const totalDiv = htmlEl('div', 'breznflow-infobox-total'); + const total = (workflow.nodes || []).length; + totalDiv.textContent = total + ' ' + (total === 1 ? (i18n.node || 'node') : (i18n.nodes || 'nodes')); + div.appendChild(totalDiv); + + return div; + } + + // ── Main Renderer Class ─────────────────────────────────────────────────── + + function BreznFlowRenderer(data) { + this.data = data; + this.id = data.id; + this.workflow = data.workflow; + this.mode = data.mode || 'visual'; + this.userZoom = data.zoom || 100; + this.showInfobox = data.show_infobox !== false; + this.showDownload = data.show_download || false; + this.downloadUrl = data.download_url || ''; + this.downloadLabel = data.download_label || 'Download JSON'; + this.maxCodeLines = data.max_code_lines || 50; + this.scale = 1; + this.tx = 0; + this.ty = 0; + this.isPanning = false; + this.panStart = { x: 0, y: 0 }; + this.selectedNode = null; + this.layout = null; + this._canvas = null; + this._svg = null; + this.zoomLabel = null; + this.detailPanel = null; + this.minimap = null; + this.minimapVisible = false; + this.tooltip = null; + this._fsActive = false; + this._fsPortal = null; + this._fsOriginalParent = null; + this._fsOriginalNextSibling = null; + this._fsEscHandler = null; + this._fsClickOutside = null; + this._highlightSticky = null; + this._activeBadge = null; + this.showShare = data.show_share || false; + this.showEmbed = data.show_embed || false; + this.showGetJson = data.show_get_json || false; + this.permalink = data.permalink || ''; + this.anchorId = data.anchor_id || ''; + this.workflowTitle = data.workflow_title || (data.workflow && data.workflow.name) || ''; + this.nodeCount = data.node_count || (data.workflow && (data.workflow.nodes || []).length) || 0; + this.isAiPowered = data.is_ai_powered || false; + this.blogName = data.blog_name || ''; + this.blogUrl = data.blog_url || ''; + this.embedUrl = data.embed_url || ''; + this._activeModal = null; + this._modalEscHandler = null; + this.theme = data.theme || 'dark'; + } + + BreznFlowRenderer.prototype.mount = function(container) { + this.container = container; + container.classList.add('breznflow-wrap'); + container.setAttribute('data-theme', this.theme); + + if (this.mode === 'info') { + container.appendChild(buildInfoBox(this.workflow)); + return; + } + + if (this.mode !== 'compact') { + this.toolbar = this._buildToolbar(); + container.appendChild(this.toolbar); + } + + this.diagramContainer = htmlEl('div', 'breznflow-diagram-container'); + container.appendChild(this.diagramContainer); + + this._renderSVG(); + + // Auto-zoom large workflows: start zoomed in at the first (leftmost) node + const threshold = this.data.autofit_threshold || 0; + const nodeCount = (this.workflow.nodes || []).length; + if (threshold > 0 && nodeCount >= threshold && this.layout && this.layout.nodes.length > 0) { + const cr = this.diagramContainer.getBoundingClientRect(); + const containerW = cr.width || 600; + const containerH = cr.height || 300; + + // Find leftmost node (trigger / start) + const startNode = this.layout.nodes.reduce(function(min, n) { + return n.x < min.x ? n : min; + }, this.layout.nodes[0]); + + // Target: show ~5 node-widths in the viewport + const targetScale = containerW / (NODE_W * 5 + PADDING * 4); + const newZoom = Math.max(MIN_SCALE * 100, Math.min(MAX_SCALE * 100, + Math.round(targetScale / this.layout.scale * 100) + )); + this.userZoom = newZoom; + + // Pan to start node (left-quarter of viewport) + const s = this.layout.scale * (newZoom / 100); + this.tx = containerW / 4 - (startNode.x + NODE_W / 2) * s; + this.ty = containerH / 2 - (startNode.y + NODE_H / 2) * s; + + if (this.zoomLabel) this.zoomLabel.textContent = Math.round(newZoom) + '%'; + this._applyTransform(); + } else if (this.layout && this.layout.nodes.length > 0) { + // Center workflows that are smaller than the viewport width + const cr = this.diagramContainer.getBoundingClientRect(); + const containerW = cr.width || 600; + const s = this.layout.scale * (this.userZoom / 100); + if (this.layout.contentW * s < containerW) { + this.tx = (containerW - this.layout.contentW * s) / 2; + this._applyTransform(); + } + } + + const panelResult = buildDetailPanel(this.maxCodeLines); + this.detailPanel = panelResult; + container.appendChild(panelResult.panel); + + if (this.mode !== 'compact') { + const actionBar = this._buildActionBar(); + if (actionBar) container.appendChild(actionBar); + } + + if (this.showInfobox && this.mode !== 'compact') { + container.appendChild(this._buildInfoBox()); + } + + this._attachEvents(); + }; + + BreznFlowRenderer.prototype._buildToolbar = function() { + const self = this; + const toolbar = htmlEl('div', 'breznflow-toolbar'); + + const name = htmlEl('span', 'breznflow-toolbar-name'); + name.textContent = this.workflow.name || ''; + toolbar.appendChild(name); + + const btnOut = htmlEl('button', 'breznflow-btn breznflow-btn-zoom'); + btnOut.textContent = '\u2212'; + btnOut.setAttribute('aria-label', i18n.zoomOut || 'Zoom out'); + btnOut.title = i18n.zoomOut || 'Zoom out'; + btnOut.addEventListener('click', function() { self._zoom(-ZOOM_STEP); }); + + const zoomLabel = htmlEl('span', 'breznflow-zoom-label'); + zoomLabel.textContent = Math.round(this.userZoom) + '%'; + this.zoomLabel = zoomLabel; + + const btnIn = htmlEl('button', 'breznflow-btn breznflow-btn-zoom'); + btnIn.textContent = '+'; + btnIn.setAttribute('aria-label', i18n.zoomIn || 'Zoom in'); + btnIn.title = i18n.zoomIn || 'Zoom in'; + btnIn.addEventListener('click', function() { self._zoom(ZOOM_STEP); }); + + const btnReset = htmlEl('button', 'breznflow-btn'); + btnReset.textContent = '\u21ba'; + btnReset.setAttribute('aria-label', i18n.resetView || 'Reset view'); + btnReset.title = i18n.resetView || 'Reset view'; + btnReset.addEventListener('click', function() { self._resetView(); }); + + const btnFS = htmlEl('button', 'breznflow-btn'); + btnFS.textContent = '\u26f6'; + btnFS.setAttribute('aria-label', i18n.fullscreen || 'Fullscreen'); + btnFS.title = i18n.fullscreen || 'Fullscreen'; + btnFS.addEventListener('click', function() { self._toggleFullscreen(btnFS); }); + + const btnMM = htmlEl('button', 'breznflow-btn'); + btnMM.textContent = '\u229e'; + btnMM.setAttribute('aria-label', i18n.minimap || 'Minimap'); + btnMM.title = i18n.minimap || 'Minimap'; + btnMM.addEventListener('click', function() { self._toggleMinimap(btnMM); }); + + toolbar.appendChild(btnOut); + toolbar.appendChild(zoomLabel); + toolbar.appendChild(btnIn); + toolbar.appendChild(btnReset); + toolbar.appendChild(btnFS); + if (this.data.show_minimap !== false) { + toolbar.appendChild(btnMM); + } + + return toolbar; + }; + + BreznFlowRenderer.prototype._renderSVG = function() { + const containerW = this.diagramContainer.getBoundingClientRect().width || 600; + const layout = computeLayout(this.workflow.nodes || [], containerW); + this.layout = layout; + + const s0 = layout.scale * (this.userZoom / 100); + const svgW = Math.max(containerW, layout.contentW * s0); + const svgH = Math.max(200, (layout.contentH || 300) * s0 + 40); + + const svg = svgEl('svg', { + class: 'breznflow-svg', + width: svgW, + height: svgH, + role: 'img', + 'aria-label': this.workflow.name || 'Workflow diagram', + }); + + const defs = svgEl('defs'); + svg.appendChild(defs); + + const canvas = svgEl('g', { class: 'breznflow-canvas' }); + this._canvas = canvas; + + const connLayer = renderConnections(layout.nodes, this.workflow.connections, defs, this.id); + canvas.appendChild(connLayer); + + const nodeLayer = svgEl('g', { class: 'breznflow-nodes' }); + const self = this; + + this.tooltip = buildTooltip(this.diagramContainer); + + for (const node of layout.nodes) { + const result = renderNodeSVG(node); + const nodeEl = result.el; + const category = result.category; + const info = lookupNode(node.type || ''); + const catLabel = CATEGORY_LABEL[category] || info.label || ''; + const summary = getTooltipSummary(node, category); + const tipText = catLabel + (summary ? ' \u00b7 ' + summary : ''); + + (function(capturedNode, capturedEl, capturedTipText) { + capturedEl.addEventListener('click', function(e) { + e.stopPropagation(); + self._selectNode(capturedNode, capturedEl); + }); + capturedEl.addEventListener('mouseenter', function() { + const nr = capturedEl.getBoundingClientRect(); + const cr = self.diagramContainer.getBoundingClientRect(); + self.tooltip.show(nr.left - cr.left + nr.width / 2, nr.top - cr.top, capturedTipText); + }); + capturedEl.addEventListener('mouseleave', function() { self.tooltip.hide(); }); + }(node, nodeEl, tipText)); + + nodeLayer.appendChild(nodeEl); + } + + canvas.appendChild(nodeLayer); + svg.appendChild(canvas); + this.diagramContainer.appendChild(svg); + this._svg = svg; + + if (this.data.show_minimap !== false) { + this.minimap = buildMinimap(this.diagramContainer, function(lx, ly) { + const s = self.layout.scale * (self.userZoom / 100); + const cr = self.diagramContainer.getBoundingClientRect(); + self.tx = cr.width / 2 - lx * s; + self.ty = cr.height / 2 - ly * s; + self._applyTransform(); + }); + this.minimap.el.style.display = 'none'; + } + + this._applyTransform(); + }; + + BreznFlowRenderer.prototype._selectNode = function(node, el) { + if (this.selectedNode) this.selectedNode.classList.remove('selected'); + el.classList.add('selected'); + this.selectedNode = el; + if (this.detailPanel) this.detailPanel.open(node); + }; + + BreznFlowRenderer.prototype._zoom = function(delta) { + this.userZoom = Math.max(MIN_SCALE * 100, Math.min(MAX_SCALE * 100, this.userZoom + delta * 100)); + if (this.zoomLabel) this.zoomLabel.textContent = Math.round(this.userZoom) + '%'; + this._applyTransform(); + }; + + BreznFlowRenderer.prototype._resetView = function() { + const cr = this.diagramContainer.getBoundingClientRect(); + const containerW = cr.width || 600; + this.userZoom = this.data.zoom || 100; + this.layout = computeLayout(this.workflow.nodes || [], containerW); + this.tx = 0; + this.ty = 0; + // Center if content is narrower than the container + const s = this.layout.scale * (this.userZoom / 100); + if (this.layout.contentW * s < containerW) { + this.tx = (containerW - this.layout.contentW * s) / 2; + } + if (this.zoomLabel) this.zoomLabel.textContent = Math.round(this.userZoom) + '%'; + if (this._svg) { + const svgW = Math.max(containerW, this.layout.contentW * s); + const svgH = this._fsActive + ? (cr.height || 400) + : Math.max(200, (this.layout.contentH || 300) * s + 40); + this._svg.setAttribute('width', svgW); + this._svg.setAttribute('height', svgH); + } + this._applyTransform(); + }; + + BreznFlowRenderer.prototype._applyTransform = function() { + if (!this._canvas || !this.layout) return; + const s = this.layout.scale * (this.userZoom / 100); + this._canvas.setAttribute('transform', 'translate(' + this.tx + ', ' + this.ty + ') scale(' + s + ')'); + if (this.minimap && this.minimapVisible) { + const cr = this.diagramContainer.getBoundingClientRect(); + this.minimap.update(this.layout, this.tx, this.ty, s, cr.width, cr.height); + } + }; + + BreznFlowRenderer.prototype._attachEvents = function() { + const svg = this._svg; + if (!svg) return; + const self = this; + + svg.addEventListener('wheel', function(e) { + e.preventDefault(); + const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP; + const oldS = self.layout.scale * (self.userZoom / 100); + const newZoom = Math.max(MIN_SCALE * 100, Math.min(MAX_SCALE * 100, self.userZoom + delta * 100)); + const newS = self.layout.scale * (newZoom / 100); + if (oldS === 0) return; + // Keep the point under the cursor fixed + const rect = svg.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + self.tx = mx - (mx - self.tx) * (newS / oldS); + self.ty = my - (my - self.ty) * (newS / oldS); + self.userZoom = newZoom; + if (self.zoomLabel) self.zoomLabel.textContent = Math.round(newZoom) + '%'; + self._applyTransform(); + }, { passive: false }); + + svg.addEventListener('pointerdown', function(e) { + const t = e.target; + const isBg = t === svg || + (t.getAttribute && (t.getAttribute('class') === 'breznflow-canvas' || t.getAttribute('class') === 'breznflow-connection-path')); + if (!isBg) return; + self.isPanning = true; + self.panStart = { x: e.clientX - self.tx, y: e.clientY - self.ty }; + svg.setPointerCapture(e.pointerId); + }); + + svg.addEventListener('pointermove', function(e) { + if (!self.isPanning) return; + self.tx = e.clientX - self.panStart.x; + self.ty = e.clientY - self.panStart.y; + self._applyTransform(); + }); + + svg.addEventListener('pointerup', function() { self.isPanning = false; }); + svg.addEventListener('pointercancel', function() { self.isPanning = false; }); + + svg.addEventListener('click', function(e) { + const t = e.target; + const isBg = t === svg || (t.getAttribute && t.getAttribute('class') === 'breznflow-canvas'); + if (!isBg) return; + if (self.selectedNode) { + self.selectedNode.classList.remove('selected'); + self.selectedNode = null; + } + if (self.detailPanel) self.detailPanel.close(); + if (self._highlightSticky) { + self._highlightSticky = null; + if (self._activeBadge) { self._activeBadge.classList.remove('active'); self._activeBadge = null; } + self._clearHighlight(true); + } + }); + }; + + BreznFlowRenderer.prototype._toggleFullscreen = function(btn) { + if (this._fsActive) { + this._exitFullscreen(btn); + } else { + this._enterFullscreen(btn); + } + }; + + BreznFlowRenderer.prototype._enterFullscreen = function(btn) { + this._fsActive = true; + btn.setAttribute('aria-pressed', 'true'); + const self = this; + + const portal = htmlEl('div', 'breznflow-fs-portal'); + portal.setAttribute('data-theme', this.theme); + this._fsOriginalParent = this.container.parentElement; + this._fsOriginalNextSibling = this.container.nextSibling; + portal.appendChild(this.container); + document.body.appendChild(portal); + this._fsPortal = portal; + document.body.style.overflow = 'hidden'; + + this._fsClickOutside = function(e) { + if (e.target === portal) self._exitFullscreen(btn); + }; + portal.addEventListener('click', this._fsClickOutside); + + this._fsEscHandler = function(e) { + if (e.key === 'Escape') self._exitFullscreen(btn); + }; + document.addEventListener('keydown', this._fsEscHandler); + + requestAnimationFrame(function() { self._resetView(); }); + }; + + BreznFlowRenderer.prototype._exitFullscreen = function(btn) { + if (!this._fsActive) return; + this._fsActive = false; + btn.setAttribute('aria-pressed', 'false'); + const self = this; + + if (this._fsEscHandler) { + document.removeEventListener('keydown', this._fsEscHandler); + this._fsEscHandler = null; + } + this._fsClickOutside = null; + + if (this._fsOriginalNextSibling) { + this._fsOriginalParent.insertBefore(this.container, this._fsOriginalNextSibling); + } else { + this._fsOriginalParent.appendChild(this.container); + } + document.body.removeChild(this._fsPortal); + this._fsPortal = null; + document.body.style.overflow = ''; + + requestAnimationFrame(function() { self._resetView(); }); + }; + + BreznFlowRenderer.prototype._toggleMinimap = function(btn) { + this.minimapVisible = !this.minimapVisible; + btn.setAttribute('aria-pressed', String(this.minimapVisible)); + if (this.minimap) { + this.minimap.el.style.display = this.minimapVisible ? '' : 'none'; + if (this.minimapVisible) { + const s = this.layout.scale * (this.userZoom / 100); + const cr = this.diagramContainer.getBoundingClientRect(); + this.minimap.update(this.layout, this.tx, this.ty, s, cr.width, cr.height); + } + } + }; + + BreznFlowRenderer.prototype._buildInfoBox = function() { + const self = this; + const div = htmlEl('div', 'breznflow-infobox'); + const AI_KW = ['openai','anthropic','claude','gemini','googleai','huggingface','langchain','agent','vectorstore','gpt','ollama','mistral','cohere']; + + const counts = {}; + let hasAi = false; + + for (const node of (this.workflow.nodes || [])) { + const slug = extractSlug(node.type || ''); + const info = lookupNode(node.type || ''); + const label = info.label || slug; + const category = getNodeCategory(slug); + if (!counts[label]) counts[label] = { count: 0, category: category }; + counts[label].count++; + const slugLower = slug.toLowerCase(); + if (!hasAi && AI_KW.some(function(kw) { return slugLower.indexOf(kw) !== -1; })) hasAi = true; + } + + const sorted = Object.entries(counts).sort(function(a, b) { return b[1].count - a[1].count; }); + const display = sorted.slice(0, 6); + const hidden = sorted.slice(6); + const more = hidden.length; + + const nodesDiv = htmlEl('div', 'breznflow-infobox-nodes'); + + function makeNodeSpan(label, entry) { + const span = htmlEl('span', 'breznflow-infobox-node'); + span.setAttribute('data-category', entry.category); + span.title = i18n.highlightInDiagram || 'Highlight in diagram'; + const strong = htmlEl('strong'); + strong.textContent = entry.count + 'x'; + span.appendChild(strong); + span.appendChild(document.createTextNode(' ' + label)); + span.addEventListener('mouseenter', function() { + if (!self._highlightSticky) self._highlightByLabel(label); + }); + span.addEventListener('mouseleave', function() { + if (!self._highlightSticky) self._clearHighlight(false); + }); + span.addEventListener('click', function(e) { + e.stopPropagation(); + if (self._highlightSticky === label) { + self._highlightSticky = null; + span.classList.remove('active'); + self._activeBadge = null; + self._clearHighlight(true); + } else { + if (self._activeBadge) self._activeBadge.classList.remove('active'); + self._highlightSticky = label; + self._activeBadge = span; + span.classList.add('active'); + self._highlightByLabel(label); + } + }); + return span; + } + + for (const [label, entry] of display) { + nodesDiv.appendChild(makeNodeSpan(label, entry)); + } + + if (more > 0) { + const moreBtn = htmlEl('button', 'breznflow-infobox-more'); + moreBtn.textContent = '+ ' + more + ' ' + (i18n.more || 'more'); + moreBtn.addEventListener('click', function() { + moreBtn.parentNode.removeChild(moreBtn); + for (const [lbl, ent] of hidden) { + nodesDiv.appendChild(makeNodeSpan(lbl, ent)); + } + }); + nodesDiv.appendChild(moreBtn); + } + div.appendChild(nodesDiv); + + if (hasAi) { + const aiDiv = htmlEl('div', 'breznflow-infobox-ai'); + const badge = htmlEl('span', 'breznflow-ai-badge'); + badge.textContent = i18n.aiPowered || 'AI-powered'; + aiDiv.appendChild(badge); + div.appendChild(aiDiv); + } + + const totalDiv = htmlEl('div', 'breznflow-infobox-total'); + const total = (this.workflow.nodes || []).length; + totalDiv.textContent = total + ' ' + (total === 1 ? (i18n.node || 'node') : (i18n.nodes || 'nodes')); + div.appendChild(totalDiv); + + return div; + }; + + BreznFlowRenderer.prototype._highlightByLabel = function(label) { + if (!this._svg) return; + const nodeLayer = this._svg.querySelector('.breznflow-nodes'); + if (!nodeLayer) return; + nodeLayer.classList.add('breznflow-dim'); + for (const node of nodeLayer.querySelectorAll('.breznflow-node')) { + node.classList.toggle('breznflow-highlighted', node.getAttribute('data-label') === label); + } + }; + + BreznFlowRenderer.prototype._clearHighlight = function(force) { + if (!force && this._highlightSticky) return; + if (!this._svg) return; + const nodeLayer = this._svg.querySelector('.breznflow-nodes'); + if (!nodeLayer) return; + nodeLayer.classList.remove('breznflow-dim'); + for (const node of nodeLayer.querySelectorAll('.breznflow-node')) { + node.classList.remove('breznflow-highlighted'); + } + }; + + // ── Action Bar ──────────────────────────────────────────────────────────── + + BreznFlowRenderer.prototype._buildActionBar = function() { + const self = this; + const hasShare = this.showShare; + const hasEmbed = this.showEmbed; + const hasGetJson = this.showGetJson; + const hasDownload = this.showDownload && this.downloadUrl; + + if (!hasShare && !hasEmbed && !hasGetJson && !hasDownload) return null; + + const bar = htmlEl('div', 'breznflow-action-bar'); + + if (hasShare) { + const btn = htmlEl('button', 'breznflow-btn'); + btn.textContent = i18n.share || 'Share'; + btn.addEventListener('click', function() { self._openModal('share'); }); + bar.appendChild(btn); + } + + if (hasEmbed) { + const btn = htmlEl('button', 'breznflow-btn'); + btn.textContent = i18n.embed || 'Embed'; + btn.addEventListener('click', function() { self._openModal('embed'); }); + bar.appendChild(btn); + } + + if (hasGetJson) { + const btn = htmlEl('button', 'breznflow-btn'); + btn.textContent = i18n.getJson || 'Get JSON'; + btn.addEventListener('click', function() { self._openModal('getjson'); }); + bar.appendChild(btn); + } + + if (hasDownload) { + const dl = htmlEl('button', 'breznflow-btn'); + dl.textContent = this.downloadLabel; + dl.addEventListener('click', function() { window.open(self.downloadUrl, '_self'); }); + bar.appendChild(dl); + } + + return bar; + }; + + BreznFlowRenderer.prototype._openModal = function(type) { + const self = this; + if (this._activeModal) this._closeModal(); + + const overlay = htmlEl('div', 'breznflow-modal-overlay'); + overlay.setAttribute('data-theme', this.theme); + const box = htmlEl('div', 'breznflow-modal-box'); + const header = htmlEl('div', 'breznflow-modal-header'); + const titleEl = htmlEl('span', 'breznflow-modal-title'); + const labels = { share: i18n.share || 'Share', embed: i18n.embed || 'Embed', getjson: i18n.getJson || 'Get JSON' }; + titleEl.textContent = labels[type] || ''; + + const closeBtn = htmlEl('button', 'breznflow-modal-close'); + closeBtn.textContent = '\u00d7'; + closeBtn.setAttribute('aria-label', i18n.close || 'Close'); + closeBtn.addEventListener('click', function() { self._closeModal(); }); + + header.appendChild(titleEl); + header.appendChild(closeBtn); + + const body = htmlEl('div', 'breznflow-modal-body'); + const content = type === 'share' ? this._buildShareModalContent() + : type === 'embed' ? this._buildEmbedModalContent() + : this._buildGetJsonModalContent(); + body.appendChild(content); + + box.appendChild(header); + box.appendChild(body); + overlay.appendChild(box); + + overlay.addEventListener('click', function(e) { + if (e.target === overlay) self._closeModal(); + }); + + this._modalEscHandler = function(e) { + if (e.key === 'Escape') self._closeModal(); + }; + document.addEventListener('keydown', this._modalEscHandler); + + document.body.appendChild(overlay); + this._activeModal = overlay; + }; + + BreznFlowRenderer.prototype._closeModal = function() { + if (this._activeModal && this._activeModal.parentNode) { + this._activeModal.parentNode.removeChild(this._activeModal); + } + this._activeModal = null; + if (this._modalEscHandler) { + document.removeEventListener('keydown', this._modalEscHandler); + this._modalEscHandler = null; + } + }; + + BreznFlowRenderer.prototype._buildCopyBlock = function(label, copyText, previewText) { + const block = htmlEl('div', 'breznflow-share-block'); + const lbl = htmlEl('div', 'breznflow-share-label'); + lbl.textContent = label; + const pre = htmlEl('pre', 'breznflow-share-preview'); + pre.textContent = previewText; + const btn = htmlEl('button', 'breznflow-btn breznflow-copy-btn'); + btn.textContent = i18n.copy || 'Copy'; + btn.addEventListener('click', function() { copyToClipboard(copyText, btn); }); + block.appendChild(lbl); + block.appendChild(pre); + block.appendChild(btn); + return block; + }; + + BreznFlowRenderer.prototype._buildShareModalContent = function() { + const frag = document.createDocumentFragment(); + const nodesLabel = this.nodeCount + ' ' + (this.nodeCount === 1 ? (i18n.node || 'node') : (i18n.nodes || 'nodes')); + const metaSuffix = nodesLabel + (this.isAiPowered ? ' \u00b7 ' + (i18n.aiPowered || 'AI-powered') : ''); + const text1 = this.permalink + '\n' + this.workflowTitle + '\n' + metaSuffix; + frag.appendChild(this._buildCopyBlock(i18n.articleLink || 'Article Link', text1, text1)); + const anchorUrl = this.permalink + '#' + this.anchorId; + const text2 = anchorUrl + '\n' + this.workflowTitle + '\n' + metaSuffix; + frag.appendChild(this._buildCopyBlock(i18n.anchorLink || 'Workflow Anchor Link', text2, text2)); + return frag; + }; + + BreznFlowRenderer.prototype._buildEmbedModalContent = function() { + const frag = document.createDocumentFragment(); + const iframeCode = ''; + const desc = htmlEl('p', 'breznflow-modal-desc'); + desc.textContent = i18n.embedDesc || 'Embed this workflow on any website:'; + const textarea = htmlEl('textarea', 'breznflow-modal-textarea'); + textarea.setAttribute('readonly', ''); + textarea.value = iframeCode; + const btn = htmlEl('button', 'breznflow-btn breznflow-copy-btn'); + btn.textContent = i18n.copy || 'Copy'; + btn.addEventListener('click', function() { copyToClipboard(iframeCode, btn); }); + const paramsLabel = htmlEl('p', 'breznflow-modal-desc'); + paramsLabel.textContent = i18n.optionalParams || 'Optional URL parameters:'; + const paramsCode = htmlEl('pre', 'breznflow-share-preview'); + paramsCode.textContent = '?theme=dark|light|minimal|tech|brezn\n?minimap=1 (default)\n?minimap=0 (hide minimap)'; + frag.appendChild(desc); + frag.appendChild(textarea); + frag.appendChild(btn); + frag.appendChild(paramsLabel); + frag.appendChild(paramsCode); + return frag; + }; + + BreznFlowRenderer.prototype._buildGetJsonModalContent = function() { + const frag = document.createDocumentFragment(); + const jsonStr = JSON.stringify(this.data.workflow, null, 2); + const sizeKB = (jsonStr.length / 1024).toFixed(1); + const meta = htmlEl('div', 'breznflow-json-meta'); + const sizeSpan = htmlEl('span', 'breznflow-json-size'); + sizeSpan.textContent = sizeKB + ' KB'; + meta.appendChild(sizeSpan); + const textarea = htmlEl('textarea', 'breznflow-modal-textarea'); + textarea.setAttribute('readonly', ''); + textarea.value = jsonStr; + const btn = htmlEl('button', 'breznflow-btn breznflow-copy-btn'); + btn.textContent = i18n.copy || 'Copy'; + btn.addEventListener('click', function() { copyToClipboard(jsonStr, btn); }); + frag.appendChild(meta); + frag.appendChild(textarea); + frag.appendChild(btn); + return frag; + }; + + // ── Clipboard helper ────────────────────────────────────────────────────── + + function copyToClipboard(text, btn) { + const orig = btn.textContent; + function onSuccess() { + btn.textContent = i18n.copied || 'Copied!'; + setTimeout(function() { btn.textContent = orig; }, 1500); + } + function onError() { + btn.textContent = i18n.error || 'Error'; + setTimeout(function() { btn.textContent = orig; }, 1500); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(onSuccess, onError); + } else { + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + onSuccess(); + } catch (e) { + onError(); + } + } + } + + // ── Bootstrap ───────────────────────────────────────────────────────────── + + function init() { + if (typeof breznflowData === 'undefined' || !Array.isArray(breznflowData)) return; + + for (const data of breznflowData) { + const container = document.getElementById('breznflow-wrap-' + data.id); + if (!container) continue; + + try { + const renderer = new BreznFlowRenderer(data); + renderer.mount(container); + } catch (err) { + if (typeof console !== 'undefined' && console.error) { + console.error('[BreznFlow] Render error for id ' + data.id + ':', err); + } + } + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + +}()); diff --git a/assets/themes/brezn.css b/assets/themes/brezn.css new file mode 100644 index 0000000..06de5f1 --- /dev/null +++ b/assets/themes/brezn.css @@ -0,0 +1,52 @@ +/* +Theme Name: Brezn +Theme ID: brezn +Description: Biergarten bei Nacht – dark amber canvas, Bavarian gold nodes, royal blue connections, state-seal red logic accents. +Author: BreznFlow +*/ + +.breznflow-wrap[data-theme="brezn"], +.breznflow-modal-overlay[data-theme="brezn"], +.breznflow-fs-portal[data-theme="brezn"] { + --breznflow-canvas-bg: #0d0800; + --breznflow-node-bg: #1e1300; + --breznflow-node-text: #f5c800; + --breznflow-node-sub: #8a6c00; + --breznflow-node-border: #3a2a00; + --breznflow-connection: #0066b3; + --breznflow-connection-hover: #3399ff; + --breznflow-toolbar-bg: #080500; + --breznflow-toolbar-text: #f5c800; + --breznflow-toolbar-border: #2a1f00; + --breznflow-panel-bg: #080500; + --breznflow-panel-text: #e8d070; + --breznflow-panel-border: #2a1f00; + --breznflow-btn-bg: #0066b3; + --breznflow-btn-text: #ffffff; + --breznflow-btn-border: #0077cc; + --breznflow-btn-hover-bg: #0077cc; + --breznflow-action-bar-bg: #080500; + --breznflow-action-bar-border: #2a1f00; + --breznflow-modal-overlay-bg: rgba(5, 3, 0, 0.88); + --breznflow-modal-bg: #100c00; + --breznflow-modal-border: #3a2a00; + --breznflow-modal-title: #f5c800; + --breznflow-modal-text: #e8d070; + --breznflow-modal-sub: #8a6c00; + --breznflow-modal-close: #8a6c00; + --breznflow-modal-secondary-bg: #0d0800; + --breznflow-modal-secondary-border: #3a2a00; + --breznflow-modal-code-bg: #050300; + --breznflow-tooltip-bg: rgba(5, 3, 0, 0.95); + --breznflow-tooltip-text: #f5c800; + --breznflow-fullscreen-overlay-bg: rgba(0, 0, 0, 0.92); + --breznflow-minimap-bg: rgba(13, 8, 0, 0.9); + --breznflow-minimap-border: #3a2a00; + --breznflow-color-trigger: #22c55e; + --breznflow-color-http: #0066b3; + --breznflow-color-code: #f5c800; + --breznflow-color-logic: #cc2200; + --breznflow-color-database: #b88a00; + --breznflow-color-ai: #ff8c00; + --breznflow-color-fallback: #5b9bc4; +} diff --git a/assets/themes/dark.css b/assets/themes/dark.css new file mode 100644 index 0000000..11ee3b7 --- /dev/null +++ b/assets/themes/dark.css @@ -0,0 +1,53 @@ +/* +Theme Name: Dark +Theme ID: dark +Description: Deep navy canvas, dark panels. The default BreznFlow theme. +Author: BreznFlow +*/ + +:root, +.breznflow-wrap[data-theme="dark"], +.breznflow-modal-overlay[data-theme="dark"], +.breznflow-fs-portal[data-theme="dark"] { + --breznflow-canvas-bg: #1a1a2e; + --breznflow-node-bg: #2D2D2D; + --breznflow-node-text: #f0f0f0; + --breznflow-node-sub: #9ca3af; + --breznflow-node-border: #3f3f3f; + --breznflow-connection: #555; + --breznflow-connection-hover: #888; + --breznflow-toolbar-bg: #1f1f1f; + --breznflow-toolbar-text: #e0e0e0; + --breznflow-toolbar-border: #3f3f3f; + --breznflow-panel-bg: #1f1f1f; + --breznflow-panel-text: #e0e0e0; + --breznflow-panel-border: #333; + --breznflow-btn-bg: #333; + --breznflow-btn-text: #e0e0e0; + --breznflow-btn-border: #555; + --breznflow-btn-hover-bg: #444; + --breznflow-action-bar-bg: #1f1f1f; + --breznflow-action-bar-border: #3f3f3f; + --breznflow-modal-overlay-bg: rgba(0,0,0,0.65); + --breznflow-modal-bg: #1f1f1f; + --breznflow-modal-border: #444; + --breznflow-modal-title: #e0e0e0; + --breznflow-modal-text: #e0e0e0; + --breznflow-modal-sub: #aaa; + --breznflow-modal-close: #888; + --breznflow-modal-secondary-bg: #2a2a2a; + --breznflow-modal-secondary-border: #3a3a3a; + --breznflow-modal-code-bg: #111; + --breznflow-tooltip-bg: rgba(0,0,0,0.85); + --breznflow-tooltip-text: #f0f0f0; + --breznflow-fullscreen-overlay-bg: rgba(0,0,0,0.85); + --breznflow-minimap-bg: rgba(0,0,0,0.72); + --breznflow-minimap-border: #555; + --breznflow-color-trigger: #22c55e; + --breznflow-color-http: #3b82f6; + --breznflow-color-code: #f97316; + --breznflow-color-logic: #a855f7; + --breznflow-color-database: #eab308; + --breznflow-color-ai: #ec4899; + --breznflow-color-fallback: #6366f1; +} diff --git a/assets/themes/light.css b/assets/themes/light.css new file mode 100644 index 0000000..13c97d4 --- /dev/null +++ b/assets/themes/light.css @@ -0,0 +1,52 @@ +/* +Theme Name: Light +Theme ID: light +Description: Light blue-grey canvas, white nodes, dark text. For bright blogs. +Author: BreznFlow +*/ + +.breznflow-wrap[data-theme="light"], +.breznflow-modal-overlay[data-theme="light"], +.breznflow-fs-portal[data-theme="light"] { + --breznflow-canvas-bg: #eef2f7; + --breznflow-node-bg: #ffffff; + --breznflow-node-text: #111827; + --breznflow-node-sub: #6b7280; + --breznflow-node-border: #d1d5db; + --breznflow-connection: #94a3b8; + --breznflow-connection-hover: #475569; + --breznflow-toolbar-bg: #f8fafc; + --breznflow-toolbar-text: #374151; + --breznflow-toolbar-border: #e2e8f0; + --breznflow-panel-bg: #f8fafc; + --breznflow-panel-text: #374151; + --breznflow-panel-border: #e2e8f0; + --breznflow-btn-bg: #e2e8f0; + --breznflow-btn-text: #374151; + --breznflow-btn-border: #cbd5e1; + --breznflow-btn-hover-bg: #d1d9e6; + --breznflow-action-bar-bg: #f1f5f9; + --breznflow-action-bar-border: #e2e8f0; + --breznflow-modal-overlay-bg: rgba(0,0,0,0.4); + --breznflow-modal-bg: #ffffff; + --breznflow-modal-border: #d1d5db; + --breznflow-modal-title: #111827; + --breznflow-modal-text: #374151; + --breznflow-modal-sub: #6b7280; + --breznflow-modal-close: #9ca3af; + --breznflow-modal-secondary-bg: #f1f5f9; + --breznflow-modal-secondary-border: #e2e8f0; + --breznflow-modal-code-bg: #f8fafc; + --breznflow-tooltip-bg: rgba(0,0,0,0.75); + --breznflow-tooltip-text: #ffffff; + --breznflow-fullscreen-overlay-bg: rgba(0,0,0,0.7); + --breznflow-minimap-bg: rgba(255,255,255,0.85); + --breznflow-minimap-border: #d1d5db; + --breznflow-color-trigger: #16a34a; + --breznflow-color-http: #2563eb; + --breznflow-color-code: #ea580c; + --breznflow-color-logic: #9333ea; + --breznflow-color-database: #ca8a04; + --breznflow-color-ai: #db2777; + --breznflow-color-fallback: #4f46e5; +} diff --git a/assets/themes/minimal.css b/assets/themes/minimal.css new file mode 100644 index 0000000..4ce30da --- /dev/null +++ b/assets/themes/minimal.css @@ -0,0 +1,52 @@ +/* +Theme Name: Minimal +Theme ID: minimal +Description: Almost white, muted category colors, documentation style. +Author: BreznFlow +*/ + +.breznflow-wrap[data-theme="minimal"], +.breznflow-modal-overlay[data-theme="minimal"], +.breznflow-fs-portal[data-theme="minimal"] { + --breznflow-canvas-bg: #fafafa; + --breznflow-node-bg: #ffffff; + --breznflow-node-text: #374151; + --breznflow-node-sub: #9ca3af; + --breznflow-node-border: #e5e7eb; + --breznflow-connection: #d1d5db; + --breznflow-connection-hover: #9ca3af; + --breznflow-toolbar-bg: #ffffff; + --breznflow-toolbar-text: #6b7280; + --breznflow-toolbar-border: #f3f4f6; + --breznflow-panel-bg: #ffffff; + --breznflow-panel-text: #374151; + --breznflow-panel-border: #f3f4f6; + --breznflow-btn-bg: #f3f4f6; + --breznflow-btn-text: #6b7280; + --breznflow-btn-border: #e5e7eb; + --breznflow-btn-hover-bg: #e9ecef; + --breznflow-action-bar-bg: #ffffff; + --breznflow-action-bar-border: #f3f4f6; + --breznflow-modal-overlay-bg: rgba(0,0,0,0.2); + --breznflow-modal-bg: #ffffff; + --breznflow-modal-border: #e5e7eb; + --breznflow-modal-title: #111827; + --breznflow-modal-text: #374151; + --breznflow-modal-sub: #9ca3af; + --breznflow-modal-close: #d1d5db; + --breznflow-modal-secondary-bg: #fafafa; + --breznflow-modal-secondary-border: #f3f4f6; + --breznflow-modal-code-bg: #f9fafb; + --breznflow-tooltip-bg: rgba(55,65,81,0.9); + --breznflow-tooltip-text: #ffffff; + --breznflow-fullscreen-overlay-bg: rgba(0,0,0,0.5); + --breznflow-minimap-bg: rgba(255,255,255,0.9); + --breznflow-minimap-border: #e5e7eb; + --breznflow-color-trigger: #86efac; + --breznflow-color-http: #93c5fd; + --breznflow-color-code: #fdba74; + --breznflow-color-logic: #d8b4fe; + --breznflow-color-database: #fde68a; + --breznflow-color-ai: #f9a8d4; + --breznflow-color-fallback: #c4b5fd; +} diff --git a/assets/themes/tech.css b/assets/themes/tech.css new file mode 100644 index 0000000..5f48277 --- /dev/null +++ b/assets/themes/tech.css @@ -0,0 +1,52 @@ +/* +Theme Name: Tech +Theme ID: tech +Description: GitHub Dark palette. Deep black canvas, electric blue accents. SSH console feel. +Author: BreznFlow +*/ + +.breznflow-wrap[data-theme="tech"], +.breznflow-modal-overlay[data-theme="tech"], +.breznflow-fs-portal[data-theme="tech"] { + --breznflow-canvas-bg: #0d1117; + --breznflow-node-bg: #161b22; + --breznflow-node-text: #c9d1d9; + --breznflow-node-sub: #8b949e; + --breznflow-node-border: #30363d; + --breznflow-connection: #21262d; + --breznflow-connection-hover: #58a6ff; + --breznflow-toolbar-bg: #010409; + --breznflow-toolbar-text: #58a6ff; + --breznflow-toolbar-border: #21262d; + --breznflow-panel-bg: #0d1117; + --breznflow-panel-text: #c9d1d9; + --breznflow-panel-border: #21262d; + --breznflow-btn-bg: #21262d; + --breznflow-btn-text: #58a6ff; + --breznflow-btn-border: #30363d; + --breznflow-btn-hover-bg: #30363d; + --breznflow-action-bar-bg: #010409; + --breznflow-action-bar-border: #21262d; + --breznflow-modal-overlay-bg: rgba(1,4,9,0.85); + --breznflow-modal-bg: #161b22; + --breznflow-modal-border: #30363d; + --breznflow-modal-title: #58a6ff; + --breznflow-modal-text: #c9d1d9; + --breznflow-modal-sub: #8b949e; + --breznflow-modal-close: #484f58; + --breznflow-modal-secondary-bg: #0d1117; + --breznflow-modal-secondary-border: #21262d; + --breznflow-modal-code-bg: #010409; + --breznflow-tooltip-bg: rgba(1,4,9,0.95); + --breznflow-tooltip-text: #58a6ff; + --breznflow-fullscreen-overlay-bg: rgba(1,4,9,0.92); + --breznflow-minimap-bg: rgba(13,17,23,0.9); + --breznflow-minimap-border: #30363d; + --breznflow-color-trigger: #3fb950; + --breznflow-color-http: #58a6ff; + --breznflow-color-code: #f0883e; + --breznflow-color-logic: #bc8cff; + --breznflow-color-database: #e3b341; + --breznflow-color-ai: #ff7b9c; + --breznflow-color-fallback: #79c0ff; +} diff --git a/breznflow.php b/breznflow.php new file mode 100644 index 0000000..3c6c2fd --- /dev/null +++ b/breznflow.php @@ -0,0 +1,52 @@ +init(); + } +); + +register_activation_hook( + BREZNFLOW_FILE, + function () { + flush_rewrite_rules(); + } +); + +register_deactivation_hook( + BREZNFLOW_FILE, + function () { + flush_rewrite_rules(); + } +); diff --git a/includes/Admin/AdminMenu.php b/includes/Admin/AdminMenu.php new file mode 100644 index 0000000..75edeb6 --- /dev/null +++ b/includes/Admin/AdminMenu.php @@ -0,0 +1,207 @@ +register(); + } + + /** + * Adds the top-level menu and submenu pages to wp-admin. + * + * @since 1.0.0 + * @return void + */ + public function add_menus(): void { + add_menu_page( + __( 'BreznFlow', 'breznflow' ), + __( 'BreznFlow', 'breznflow' ), + 'edit_posts', + 'breznflow', + array( $this, 'render_dashboard' ), + 'dashicons-networking', + 30 + ); + + add_submenu_page( + 'breznflow', + __( 'All Workflows', 'breznflow' ), + __( 'All Workflows', 'breznflow' ), + 'edit_posts', + 'breznflow', + array( $this, 'render_dashboard' ) + ); + + add_submenu_page( + 'breznflow', + __( 'Add Workflow', 'breznflow' ), + __( 'Add Workflow', 'breznflow' ), + 'edit_posts', + 'breznflow-add', + array( $this, 'render_wizard' ) + ); + + add_submenu_page( + 'breznflow', + __( 'Themes', 'breznflow' ), + __( 'Themes', 'breznflow' ), + 'manage_options', + 'breznflow-themes', + array( $this, 'render_themes' ) + ); + + add_submenu_page( + 'breznflow', + __( 'Settings', 'breznflow' ), + __( 'Settings', 'breznflow' ), + 'manage_options', + 'breznflow-settings', + array( $this, 'render_settings' ) + ); + } + + /** + * Handles single and bulk delete actions early (admin_init), + * before any output is sent — so wp_safe_redirect() works correctly. + */ + public function handle_delete_action(): void { + // Only act on our page. + if ( ! isset( $_GET['page'] ) || 'breznflow' !== $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return; + } + + $action = isset( $_GET['action'] ) ? sanitize_key( $_GET['action'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + // ── Single-row delete ───────────────────────────────────────────────── + if ( 'delete' === $action && isset( $_GET['post'] ) && ! isset( $_GET['workflow'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $post_id = (int) sanitize_text_field( wp_unslash( $_GET['post'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( + $post_id > 0 + && check_admin_referer( 'breznflow_delete_' . $post_id ) + && current_user_can( 'delete_post', $post_id ) + ) { + wp_trash_post( $post_id ); + wp_safe_redirect( + add_query_arg( + array( + 'page' => 'breznflow', + 'deleted' => '1', + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } + } + + // ── Bulk delete ─────────────────────────────────────────────────────── + // WP_List_Table sends action2 when the bottom select is used. + $bulk = $action; + if ( 'delete' !== $bulk && isset( $_GET['action2'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $bulk = sanitize_key( $_GET['action2'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + } + + if ( 'delete' === $bulk && isset( $_GET['workflow'] ) && is_array( $_GET['workflow'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + check_admin_referer( 'bulk-workflows' ); + $count = 0; + $raw_workflows = array_map( 'absint', wp_unslash( $_GET['workflow'] ) ); + foreach ( $raw_workflows as $pid ) { + if ( $pid > 0 && current_user_can( 'delete_post', $pid ) ) { + wp_trash_post( $pid ); + ++$count; + } + } + wp_safe_redirect( + add_query_arg( + array( + 'page' => 'breznflow', + 'deleted' => $count, + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } + } + + /** + * Renders the workflow list dashboard page. + * + * @since 1.0.0 + * @return void + */ + public function render_dashboard(): void { + require_once BREZNFLOW_DIR . 'includes/Admin/WorkflowListTable.php'; + $table = new WorkflowListTable(); + $table->prepare_items(); + require BREZNFLOW_DIR . 'includes/Admin/views/list.php'; + } + + /** + * Renders the add-workflow wizard page for the current step. + * + * @since 1.0.0 + * @return void + */ + public function render_wizard(): void { + $step = isset( $_GET['step'] ) ? (int) $_GET['step'] : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + switch ( $step ) { + case 2: + require BREZNFLOW_DIR . 'includes/Admin/views/wizard-step-2.php'; + break; + case 3: + require BREZNFLOW_DIR . 'includes/Admin/views/wizard-step-3.php'; + break; + default: + require BREZNFLOW_DIR . 'includes/Admin/views/wizard-step-1.php'; + break; + } + } + + /** + * Renders the themes management page. + * + * @since 1.0.0 + * @return void + */ + public function render_themes(): void { + require_once BREZNFLOW_DIR . 'includes/Admin/ThemesPage.php'; + ( new ThemesPage() )->render(); + } + + /** + * Renders the plugin settings page. + * + * @since 1.0.0 + * @return void + */ + public function render_settings(): void { + require BREZNFLOW_DIR . 'includes/Admin/views/settings.php'; + } +} diff --git a/includes/Admin/SettingsPage.php b/includes/Admin/SettingsPage.php new file mode 100644 index 0000000..6692145 --- /dev/null +++ b/includes/Admin/SettingsPage.php @@ -0,0 +1,138 @@ + array( $this, 'sanitize_settings' ), + ) + ); + } + + /** + * Sanitizes the settings array on save. + * + * @param mixed $input Raw input from the settings form. + * @return array Sanitized settings. + */ + public function sanitize_settings( $input ): array { + if ( ! is_array( $input ) ) { + return self::get_defaults(); + } + + $defaults = self::get_defaults(); + $clean = array(); + + $clean['default_zoom'] = isset( $input['default_zoom'] ) + ? max( 10, min( 200, (int) $input['default_zoom'] ) ) + : $defaults['default_zoom']; + $clean['autofit_threshold'] = isset( $input['autofit_threshold'] ) + ? max( 0, min( 500, (int) $input['autofit_threshold'] ) ) + : $defaults['autofit_threshold']; + $clean['max_code_lines'] = isset( $input['max_code_lines'] ) + ? max( 5, min( 500, (int) $input['max_code_lines'] ) ) + : $defaults['max_code_lines']; + $clean['allow_download'] = ! empty( $input['allow_download'] ); + $clean['download_label'] = isset( $input['download_label'] ) + ? sanitize_text_field( wp_unslash( $input['download_label'] ) ) + : $defaults['download_label']; + $clean['default_mode'] = in_array( $input['default_mode'] ?? '', array( 'visual', 'info', 'compact' ), true ) + ? $input['default_mode'] + : $defaults['default_mode']; + $clean['show_infobox_default'] = ! empty( $input['show_infobox_default'] ); + $clean['show_title_default'] = ! empty( $input['show_title_default'] ); + $clean['schema_howto'] = ! empty( $input['schema_howto'] ); + $clean['related_workflows'] = ! empty( $input['related_workflows'] ); + $clean['view_counting'] = ! empty( $input['view_counting'] ); + $clean['allow_share'] = ! empty( $input['allow_share'] ); + $clean['allow_embed'] = ! empty( $input['allow_embed'] ); + $clean['allow_get_json'] = ! empty( $input['allow_get_json'] ); + + $allowed_themes = \BreznFlow\Features\ThemeRegistry::get_theme_ids(); + $clean['default_theme'] = in_array( $input['default_theme'] ?? '', $allowed_themes, true ) + ? $input['default_theme'] + : $defaults['default_theme']; + + // Renderer colors — validate hex format. + $color_keys = array( 'trigger', 'action', 'logic', 'transform', 'database', 'ai' ); + $clean['renderer_colors'] = array(); + foreach ( $color_keys as $key ) { + $raw_color = isset( $input['renderer_colors'][ $key ] ) + ? sanitize_text_field( wp_unslash( $input['renderer_colors'][ $key ] ) ) + : $defaults['renderer_colors'][ $key ]; + $clean['renderer_colors'][ $key ] = preg_match( '/^#[0-9a-fA-F]{6}$/', $raw_color ) + ? $raw_color + : $defaults['renderer_colors'][ $key ]; + } + + return $clean; + } + + /** + * Returns default settings. + * + * @return array + */ + public static function get_defaults(): array { + return array( + 'default_zoom' => 100, + 'autofit_threshold' => 30, + 'max_code_lines' => 50, + 'allow_download' => false, + 'download_label' => __( 'Download JSON', 'breznflow' ), + 'default_mode' => 'visual', + 'show_infobox_default' => true, + 'show_title_default' => true, + 'schema_howto' => false, + 'related_workflows' => true, + 'view_counting' => true, + 'allow_share' => true, + 'allow_embed' => false, + 'allow_get_json' => false, + 'default_theme' => 'dark', + 'renderer_colors' => array( + 'trigger' => '#ff6b6b', + 'action' => '#4ecdc4', + 'logic' => '#f39c12', + 'transform' => '#26c6da', + 'database' => '#4479a1', + 'ai' => '#7c3aed', + ), + ); + } +} diff --git a/includes/Admin/ThemesPage.php b/includes/Admin/ThemesPage.php new file mode 100644 index 0000000..7dde88a --- /dev/null +++ b/includes/Admin/ThemesPage.php @@ -0,0 +1,188 @@ + 'breznflow-themes', + 'error' => 'invalid_file', + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } + + if ( ! isset( $file['tmp_name'] ) || ! is_uploaded_file( $file['tmp_name'] ) ) { + wp_safe_redirect( + add_query_arg( + array( + 'page' => 'breznflow-themes', + 'error' => 'upload_failed', + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $raw = file_get_contents( $file['tmp_name'] ); + if ( false === $raw ) { + wp_safe_redirect( + add_query_arg( + array( + 'page' => 'breznflow-themes', + 'error' => 'read_failed', + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } + + $data = json_decode( $raw, true ); + if ( ! is_array( $data ) ) { + wp_safe_redirect( + add_query_arg( + array( + 'page' => 'breznflow-themes', + 'error' => 'invalid_json', + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } + + $result = ThemeImporter::import( $data ); + + if ( is_wp_error( $result ) ) { + wp_safe_redirect( + add_query_arg( + array( + 'page' => 'breznflow-themes', + 'error' => 'validation', + 'message' => rawurlencode( $result->get_error_message() ), + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } + + wp_safe_redirect( + add_query_arg( + array( + 'page' => 'breznflow-themes', + 'imported' => '1', + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } + + /** + * Processes a theme delete action. + */ + public function handle_delete(): void { + if ( ! isset( $_GET['page'] ) || 'breznflow-themes' !== $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return; + } + + if ( empty( $_GET['action'] ) || 'delete_theme' !== $_GET['action'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return; + } + + $id = isset( $_GET['theme_id'] ) ? sanitize_key( $_GET['theme_id'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( '' === $id ) { + return; + } + + check_admin_referer( 'breznflow_theme_delete_' . $id ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Insufficient permissions.', 'breznflow' ) ); + } + + ThemeImporter::delete( $id ); + + wp_safe_redirect( + add_query_arg( + array( + 'page' => 'breznflow-themes', + 'deleted' => '1', + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } + + /** + * Renders the themes management page. + * + * @since 1.0.0 + * @return void + */ + public function render(): void { + require BREZNFLOW_DIR . 'includes/Admin/views/themes.php'; + } +} diff --git a/includes/Admin/WizardPage.php b/includes/Admin/WizardPage.php new file mode 100644 index 0000000..de60ab5 --- /dev/null +++ b/includes/Admin/WizardPage.php @@ -0,0 +1,420 @@ + admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'breznflow_wizard' ), + 'i18n' => array( + 'validating' => __( 'Validating...', 'breznflow' ), + 'valid' => __( 'Valid n8n workflow', 'breznflow' ), + 'invalid' => __( 'Invalid workflow', 'breznflow' ), + 'fetching' => __( 'Fetching URL...', 'breznflow' ), + 'copyShortcode' => __( 'Copy', 'breznflow' ), + 'copied' => __( 'Copied!', 'breznflow' ), + 'validateJson' => __( 'Validate JSON', 'breznflow' ), + 'fetch' => __( 'Fetch', 'breznflow' ), + 'fetchFailed' => __( 'Fetch failed', 'breznflow' ), + 'pasteFirst' => __( 'Please paste a workflow JSON first.', 'breznflow' ), + 'nodes' => __( 'nodes', 'breznflow' ), + ), + ) + ); + } + + /** + * AJAX: Validate JSON without saving. + */ + public function ajax_validate_json(): void { + check_ajax_referer( 'breznflow_wizard', 'nonce' ); + + if ( ! current_user_can( 'edit_posts' ) ) { + wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'breznflow' ) ) ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- verified above; sanitize_textarea_field() is not used because strip_tags() corrupts JSON (strips HTML inside string values); sanitization happens after json_decode() in WorkflowSanitizer + $raw = isset( $_POST['json'] ) ? trim( wp_unslash( (string) $_POST['json'] ) ) : ''; + + if ( '' === $raw ) { + wp_send_json_error( array( 'message' => __( 'No JSON provided.', 'breznflow' ) ) ); + } + + $result = WorkflowValidator::validate( $raw ); + + if ( is_wp_error( $result ) ) { + wp_send_json_error( array( 'message' => $result->get_error_message() ) ); + } + + wp_send_json_success( + array( + 'name' => esc_html( $result['name'] ), + 'nodes' => count( $result['nodes'] ), + ) + ); + } + + /** + * AJAX: Fetch JSON from a URL. + */ + public function ajax_fetch_url(): void { + check_ajax_referer( 'breznflow_wizard', 'nonce' ); + + if ( ! current_user_can( 'edit_posts' ) ) { + wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'breznflow' ) ) ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified via check_ajax_referer() above + $url = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : ''; + + if ( '' === $url ) { + wp_send_json_error( array( 'message' => __( 'No URL provided.', 'breznflow' ) ) ); + } + + if ( ! $this->is_ssrf_safe_url( $url ) ) { + wp_send_json_error( array( 'message' => __( 'This URL is not allowed.', 'breznflow' ) ) ); + } + + $response = wp_remote_get( + $url, + array( + 'timeout' => 15, + 'user-agent' => 'BreznFlow/' . BREZNFLOW_VERSION . '; WordPress/' . get_bloginfo( 'version' ), + ) + ); + + if ( is_wp_error( $response ) ) { + wp_send_json_error( array( 'message' => $response->get_error_message() ) ); + } + + $body = wp_remote_retrieve_body( $response ); + if ( '' === $body ) { + wp_send_json_error( array( 'message' => __( 'Empty response from URL.', 'breznflow' ) ) ); + } + + wp_send_json_success( array( 'json' => $body ) ); + } + + /** + * Checks whether a URL is safe to fetch (no SSRF risk). + * Blocks loopback, private RFC-1918, and link-local (cloud metadata) ranges. + * + * @param string $url The URL to check. + * @return bool True if safe to fetch, false if blocked. + */ + private function is_ssrf_safe_url( string $url ): bool { + $host = wp_parse_url( $url, PHP_URL_HOST ); + if ( ! $host || '' === $host ) { + return false; + } + // Strip IPv6 brackets: [::1] → ::1. + $host = trim( $host, '[]' ); + $ip = gethostbyname( $host ); + + $blocked = array( + '/^127\./', // 127.x.x.x — Loopback. + '/^10\./', // 10.x.x.x — RFC 1918. + '/^192\.168\./', // 192.168.x.x — RFC 1918. + '/^172\.(1[6-9]|2[0-9]|3[01])\./', // 172.16–31.x.x — RFC 1918. + '/^169\.254\./', // 169.254.x.x — Link-local / AWS metadata. + '/^0\./', // 0.x.x.x — Invalid. + '/^::1$/', // IPv6 loopback. + '/^fc[0-9a-f]{2}:/i', // IPv6 ULA fc::/7. + '/^fd[0-9a-f]{2}:/i', // IPv6 ULA fd::/8. + '/^fe80:/i', // IPv6 link-local. + ); + foreach ( $blocked as $pattern ) { + if ( preg_match( $pattern, $ip ) ) { + return false; + } + } + return true; + } + + /** + * POST handler: Step 1 — validate, sanitize, create draft. + */ + public function handle_step1(): void { + check_admin_referer( 'breznflow_step1', 'breznflow_nonce' ); + + if ( ! current_user_can( 'edit_posts' ) ) { + wp_die( esc_html__( 'Insufficient permissions.', 'breznflow' ) ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- verified above; sanitize_textarea_field() is not used because strip_tags() corrupts JSON (strips HTML inside string values); sanitization happens after json_decode() in WorkflowSanitizer + $raw = isset( $_POST['breznflow_json'] ) ? trim( wp_unslash( (string) $_POST['breznflow_json'] ) ) : ''; + + if ( '' === $raw ) { + wp_safe_redirect( + add_query_arg( + array( + 'page' => 'breznflow-add', + 'error' => 'empty', + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } + + $validated = WorkflowValidator::validate( $raw ); + if ( is_wp_error( $validated ) ) { + wp_safe_redirect( + add_query_arg( + array( + 'page' => 'breznflow-add', + 'error' => 'validation', + 'message' => rawurlencode( $validated->get_error_message() ), + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } + + // Sanitize + mask. + $sanitizer = new WorkflowSanitizer(); + $processed = $sanitizer->process( $validated ); + $data = $processed['data']; + $mask_log = $processed['mask_log']; + + // Fallback for nodes whose name was stripped entirely by sanitization. + if ( isset( $data['nodes'] ) && is_array( $data['nodes'] ) ) { + foreach ( $data['nodes'] as &$node ) { + if ( isset( $node['name'] ) && '' === $node['name'] ) { + $node['name'] = isset( $node['type'] ) ? sanitize_text_field( $node['type'] ) : __( 'Unnamed Node', 'breznflow' ); + } + } + unset( $node ); + } + + // Categorize nodes. + $categorized = NodeCategorizer::categorize( $data['nodes'] ); + $node_summary = array(); + foreach ( $categorized['counts'] as $label => $count ) { + $node_summary[ $label ] = $count; + } + + // Create or update draft post. + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified via check_admin_referer() above + $post_id = isset( $_POST['breznflow_post_id'] ) ? (int) $_POST['breznflow_post_id'] : 0; + + $settings = \BreznFlow\Admin\SettingsPage::get_defaults(); + $saved = get_option( 'breznflow_settings', array() ); + $settings = array_merge( $settings, $saved ); + + $post_data = array( + 'post_type' => 'breznflow_workflow', + 'post_status' => 'draft', + 'post_title' => sanitize_text_field( $data['name'] ), + ); + + if ( $post_id > 0 ) { + $post_data['ID'] = $post_id; + $post_id = wp_update_post( $post_data ); + } else { + $post_id = wp_insert_post( $post_data ); + } + + if ( is_wp_error( $post_id ) || 0 === $post_id ) { + wp_die( esc_html__( 'Failed to create workflow post.', 'breznflow' ) ); + } + + // Store sanitized JSON (never raw). + // wp_slash() counteracts update_metadata()'s internal wp_unslash() which would corrupt JSON backslashes. + update_post_meta( $post_id, '_breznflow_raw_json', wp_slash( wp_json_encode( $data ) ) ); + update_post_meta( $post_id, '_breznflow_original_name', sanitize_text_field( $data['name'] ) ); + update_post_meta( $post_id, '_breznflow_node_count', (int) $categorized['total'] ); + update_post_meta( $post_id, '_breznflow_node_summary', wp_slash( wp_json_encode( $node_summary ) ) ); + update_post_meta( $post_id, '_breznflow_has_ai_nodes', (int) $categorized['has_ai'] ); + update_post_meta( $post_id, '_breznflow_ai_node_types', wp_slash( wp_json_encode( $categorized['ai_nodes'] ) ) ); + update_post_meta( $post_id, '_breznflow_mask_log', wp_slash( wp_json_encode( $mask_log ) ) ); + + // Defaults. + update_post_meta( $post_id, '_breznflow_default_zoom', (int) $settings['default_zoom'] ); + update_post_meta( $post_id, '_breznflow_show_title', (int) $settings['show_title_default'] ); + update_post_meta( $post_id, '_breznflow_show_infobox', (int) $settings['show_infobox_default'] ); + update_post_meta( $post_id, '_breznflow_show_download', (int) $settings['allow_download'] ); + update_post_meta( $post_id, '_breznflow_show_embed', (int) $settings['allow_embed'] ); + update_post_meta( $post_id, '_breznflow_show_minimap', 1 ); + update_post_meta( $post_id, '_breznflow_default_mode', sanitize_text_field( $settings['default_mode'] ) ); + update_post_meta( $post_id, '_breznflow_default_theme', $settings['default_theme'] ?? 'dark' ); + + // Invalidate related workflow caches. + delete_transient( 'breznflow_related_' . $post_id ); + + wp_safe_redirect( + add_query_arg( + array( + 'page' => 'breznflow-add', + 'step' => '2', + 'post_id' => $post_id, + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } + + /** + * POST handler: Step 2 — save settings, redirect to step 3. + */ + public function handle_step2(): void { + check_admin_referer( 'breznflow_step2', 'breznflow_nonce' ); + + if ( ! current_user_can( 'edit_posts' ) ) { + wp_die( esc_html__( 'Insufficient permissions.', 'breznflow' ) ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified via check_admin_referer() above + $post_id = isset( $_POST['breznflow_post_id'] ) ? (int) $_POST['breznflow_post_id'] : 0; + if ( $post_id <= 0 || 'breznflow_workflow' !== get_post_type( $post_id ) ) { + wp_die( esc_html__( 'Invalid workflow ID.', 'breznflow' ) ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above + $title = isset( $_POST['post_title'] ) ? sanitize_text_field( wp_unslash( $_POST['post_title'] ) ) : ''; + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above + $mode = isset( $_POST['default_mode'] ) ? sanitize_text_field( wp_unslash( $_POST['default_mode'] ) ) : 'visual'; + $mode = in_array( $mode, array( 'visual', 'info', 'compact' ), true ) ? $mode : 'visual'; + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above + $zoom = isset( $_POST['default_zoom'] ) ? max( 10, min( 200, (int) $_POST['default_zoom'] ) ) : 100; + + if ( $title ) { + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => $title, + ) + ); + } + + update_post_meta( $post_id, '_breznflow_default_mode', $mode ); + update_post_meta( $post_id, '_breznflow_default_zoom', $zoom ); + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above + update_post_meta( $post_id, '_breznflow_show_title', ! empty( $_POST['show_title'] ) ? 1 : 0 ); + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above + update_post_meta( $post_id, '_breznflow_show_infobox', ! empty( $_POST['show_infobox'] ) ? 1 : 0 ); + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above + update_post_meta( $post_id, '_breznflow_show_download', ! empty( $_POST['show_download'] ) ? 1 : 0 ); + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above + update_post_meta( $post_id, '_breznflow_show_embed', ! empty( $_POST['show_embed'] ) ? 1 : 0 ); + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above + update_post_meta( $post_id, '_breznflow_show_minimap', ! empty( $_POST['show_minimap'] ) ? 1 : 0 ); + + $allowed_themes = \BreznFlow\Features\ThemeRegistry::get_theme_ids(); + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above + $posted_theme = isset( $_POST['default_theme'] ) ? sanitize_text_field( wp_unslash( $_POST['default_theme'] ) ) : 'dark'; + $theme_val = in_array( $posted_theme, $allowed_themes, true ) ? $posted_theme : 'dark'; + update_post_meta( $post_id, '_breznflow_default_theme', $theme_val ); + + // Categories. + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above + if ( isset( $_POST['breznflow_categories'] ) && is_array( $_POST['breznflow_categories'] ) ) { + $cat_ids = array_map( 'intval', wp_unslash( $_POST['breznflow_categories'] ) ); + wp_set_post_terms( $post_id, $cat_ids, 'breznflow_category' ); + } + + wp_safe_redirect( + add_query_arg( + array( + 'page' => 'breznflow-add', + 'step' => '3', + 'post_id' => $post_id, + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } + + /** + * POST handler: Publish workflow. + */ + public function handle_publish(): void { + check_admin_referer( 'breznflow_publish', 'breznflow_nonce' ); + + if ( ! current_user_can( 'edit_posts' ) ) { + wp_die( esc_html__( 'Insufficient permissions.', 'breznflow' ) ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above + $post_id = isset( $_POST['breznflow_post_id'] ) ? (int) $_POST['breznflow_post_id'] : 0; + if ( $post_id <= 0 || 'breznflow_workflow' !== get_post_type( $post_id ) ) { + wp_die( esc_html__( 'Invalid workflow ID.', 'breznflow' ) ); + } + + wp_update_post( + array( + 'ID' => $post_id, + 'post_status' => 'publish', + ) + ); + + // Invalidate caches. + delete_transient( 'breznflow_related_' . $post_id ); + delete_transient( 'breznflow_stats_summary' ); + + wp_safe_redirect( + add_query_arg( + array( + 'page' => 'breznflow', + 'published' => 1, + 'post_id' => $post_id, + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } +} diff --git a/includes/Admin/WorkflowListTable.php b/includes/Admin/WorkflowListTable.php new file mode 100644 index 0000000..1c07e6d --- /dev/null +++ b/includes/Admin/WorkflowListTable.php @@ -0,0 +1,204 @@ + 'workflow', + 'plural' => 'workflows', + 'ajax' => false, + ) + ); + } + + /** + * Returns the list of table columns. + * + * @return array + */ + public function get_columns(): array { + return array( + 'cb' => '', + 'title' => __( 'Title', 'breznflow' ), + 'nodes' => __( 'Nodes', 'breznflow' ), + 'ai' => __( 'AI', 'breznflow' ), + 'mode' => __( 'Mode', 'breznflow' ), + 'views' => __( 'Views', 'breznflow' ), + 'shortcode' => __( 'Shortcode', 'breznflow' ), + 'date' => __( 'Date', 'breznflow' ), + ); + } + + /** + * Returns sortable columns configuration. + * + * @return array> + */ + protected function get_sortable_columns(): array { + return array( + 'title' => array( 'title', false ), + 'nodes' => array( 'nodes', false ), + 'views' => array( 'views', false ), + 'date' => array( 'date', false ), + ); + } + + /** + * Returns the bulk action choices. + * + * @return array + */ + protected function get_bulk_actions(): array { + return array( + 'delete' => __( 'Delete', 'breznflow' ), + ); + } + + /** + * Queries workflows and sets up pagination. + * + * @since 1.0.0 + * @return void + */ + public function prepare_items(): void { + $per_page = 20; + $current_page = $this->get_pagenum(); + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $orderby = isset( $_GET['orderby'] ) ? sanitize_key( $_GET['orderby'] ) : 'date'; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $order_raw = isset( $_GET['order'] ) ? strtolower( sanitize_text_field( wp_unslash( $_GET['order'] ) ) ) : ''; + $order = 'asc' === $order_raw ? 'ASC' : 'DESC'; + + $args = array( + 'post_type' => 'breznflow_workflow', + 'post_status' => array( 'publish', 'draft' ), + 'posts_per_page' => $per_page, + 'paged' => $current_page, + 'orderby' => $orderby, + 'order' => $order, + ); + + $query = new \WP_Query( $args ); + $this->set_pagination_args( + array( + 'total_items' => $query->found_posts, + 'per_page' => $per_page, + ) + ); + $this->items = $query->posts; + + $this->_column_headers = array( + $this->get_columns(), + array(), + $this->get_sortable_columns(), + ); + } + + /** + * Renders a non-special column value for a row. + * + * @param \WP_Post $item The current post object. + * @param string $column_name The current column name. + * @return string + */ + protected function column_default( $item, $column_name ): string { + switch ( $column_name ) { + case 'nodes': + return (string) (int) get_post_meta( $item->ID, '_breznflow_node_count', true ); + case 'ai': + $has_ai = (int) get_post_meta( $item->ID, '_breznflow_has_ai_nodes', true ); + return $has_ai ? 'AI' : '—'; + case 'mode': + $mode = get_post_meta( $item->ID, '_breznflow_default_mode', true ); + return esc_html( $mode ? $mode : 'visual' ); + case 'views': + return (string) (int) get_post_meta( $item->ID, '_breznflow_view_count', true ); + case 'shortcode': + $sc = '[breznflow id="' . $item->ID . '"]'; + return '' . esc_html( $sc ) . ' ' + . ''; + case 'date': + return esc_html( get_the_date( 'Y-m-d', $item ) ); + default: + return ''; + } + } + + /** + * Renders the title column with edit/delete row actions. + * + * @param \WP_Post $item The current post object. + * @return string + */ + protected function column_title( $item ): string { + $edit_url = add_query_arg( + array( + 'page' => 'breznflow-add', + 'step' => '2', + 'post_id' => $item->ID, + ), + admin_url( 'admin.php' ) + ); + + $delete_url = wp_nonce_url( + add_query_arg( + array( + 'action' => 'delete', + 'post' => $item->ID, + 'page' => 'breznflow', + ), + admin_url( 'admin.php' ) + ), + 'breznflow_delete_' . $item->ID + ); + + $title = esc_html( $item->post_title ); + $status = 'publish' === $item->post_status ? '' : ' (' . esc_html__( 'Draft', 'breznflow' ) . ')'; + $actions = array( + 'edit' => '' . esc_html__( 'Edit', 'breznflow' ) . '', + 'delete' => '' . esc_html__( 'Delete', 'breznflow' ) . '', + ); + + return '' . $title . '' . $status . '' + . $this->row_actions( $actions ); + } + + /** + * Renders the checkbox column for bulk actions. + * + * @param \WP_Post $item The current post object. + * @return string + */ + protected function column_cb( $item ): string { + return ''; + } +} diff --git a/includes/Admin/views/list.php b/includes/Admin/views/list.php new file mode 100644 index 0000000..7a7fbf3 --- /dev/null +++ b/includes/Admin/views/list.php @@ -0,0 +1,65 @@ + +
+

+ + + + + +
+

+ +

+
+ + + +
+

+ 0 ) { + $sc = '[breznflow id="' . $pid . '"]'; + printf( + /* translators: %s: shortcode string */ + esc_html__( 'Workflow published! Use shortcode: %s', 'breznflow' ), + '' . esc_html( $sc ) . '' + ); + } else { + esc_html_e( 'Workflow published!', 'breznflow' ); + } + ?> +

+
+ + +
+ + display(); ?> +
+
diff --git a/includes/Admin/views/settings.php b/includes/Admin/views/settings.php new file mode 100644 index 0000000..c369a8c --- /dev/null +++ b/includes/Admin/views/settings.php @@ -0,0 +1,198 @@ + +
+

+ + +
+ + +
+

+ + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +

+
+ +

+
+
+ +
+
+ +
+

+ + + + + +
+ +

+ +

+
+
+ +
+

+ + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+
+ +
+

+ + + + + + + + + + + + + +
+ +
+ +
+ +
+
+ + +
+
diff --git a/includes/Admin/views/themes.php b/includes/Admin/views/themes.php new file mode 100644 index 0000000..9551601 --- /dev/null +++ b/includes/Admin/views/themes.php @@ -0,0 +1,148 @@ + +
+

+ + +
+

+
+ + + +
+

+
+ + + + __( 'Invalid file type. Please upload a .breznflow.json file.', 'breznflow' ), + 'upload_failed' => __( 'File upload failed. Please try again.', 'breznflow' ), + 'read_failed' => __( 'Could not read the uploaded file.', 'breznflow' ), + 'invalid_json' => __( 'File is not valid JSON.', 'breznflow' ), + 'validation' => isset( $_GET['message'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ? urldecode( sanitize_text_field( wp_unslash( $_GET['message'] ) ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended + : __( 'Theme validation failed.', 'breznflow' ), + ); + $msg = $messages[ $error_code ] ?? __( 'An error occurred.', 'breznflow' ); + ?> +
+

+
+ + + +
+

+

+ +

+
+ + + + + + +
+ +
+ +
+
+ + +
+

+ + + + + + + + + + $bf_theme_name ) : ?> + + + + + + + +
+

+ +

+
+ + +
+

+ +

+ + + + + + + + + + + + + + + + + + + + +
+ + + +
+ +
+
diff --git a/includes/Admin/views/wizard-step-1.php b/includes/Admin/views/wizard-step-1.php new file mode 100644 index 0000000..bdce8e8 --- /dev/null +++ b/includes/Admin/views/wizard-step-1.php @@ -0,0 +1,75 @@ + +
+

+ +

+ +
+ + + +
+ + +

+ +

+ + +
+

+ +
+ +
+ + +
+

+
+ +
+ + +
+ +
+ + + +
+ + +
+ + + +

+ + +

+
+
+
diff --git a/includes/Admin/views/wizard-step-2.php b/includes/Admin/views/wizard-step-2.php new file mode 100644 index 0000000..35eec3c --- /dev/null +++ b/includes/Admin/views/wizard-step-2.php @@ -0,0 +1,212 @@ + 0 ? get_post( $post_id ) : null; + +if ( ! $workflow || 'breznflow_workflow' !== $workflow->post_type ) { + wp_die( esc_html__( 'Invalid workflow.', 'breznflow' ) ); +} + +$meta_mode = get_post_meta( $post_id, '_breznflow_default_mode', true ); +$display_mode = $meta_mode ? $meta_mode : 'visual'; +$meta_zoom = (int) get_post_meta( $post_id, '_breznflow_default_zoom', true ); +$zoom = $meta_zoom ? $meta_zoom : 100; +$show_title = (int) get_post_meta( $post_id, '_breznflow_show_title', true ); +$show_infobox = (int) get_post_meta( $post_id, '_breznflow_show_infobox', true ); +$show_download = (int) get_post_meta( $post_id, '_breznflow_show_download', true ); +$show_embed = (int) get_post_meta( $post_id, '_breznflow_show_embed', true ); +$saved_theme_raw = get_post_meta( $post_id, '_breznflow_default_theme', true ); +$saved_theme = $saved_theme_raw ? $saved_theme_raw : 'dark'; +$show_minimap = get_post_meta( $post_id, '_breznflow_show_minimap', true ); +$show_minimap = '' === $show_minimap ? 1 : (int) $show_minimap; +$mask_log_raw = get_post_meta( $post_id, '_breznflow_mask_log', true ); +$mask_log = json_decode( $mask_log_raw ? $mask_log_raw : '[]', true ); +$has_ai = (int) get_post_meta( $post_id, '_breznflow_has_ai_nodes', true ); +$node_count = (int) get_post_meta( $post_id, '_breznflow_node_count', true ); + +$categories = get_terms( + array( + 'taxonomy' => 'breznflow_category', + 'hide_empty' => false, + ) +); +$current_cats = wp_get_post_terms( $post_id, 'breznflow_category', array( 'fields' => 'ids' ) ); +?> +
+

+ +

+ +
+ + + +
+ +
+ + + + + + +
+ +
+ + + + +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+
+ +
+ + +
+
+
+
+
+ +
+ +
+
+ +
+

+
+ [breznflow id=""] + +
+
+ + +
+

+
+ + + +
    + +
  • + + — + () +
  • + +
+
+
+ + +

+ + +

+
+
diff --git a/includes/Admin/views/wizard-step-3.php b/includes/Admin/views/wizard-step-3.php new file mode 100644 index 0000000..57a91d5 --- /dev/null +++ b/includes/Admin/views/wizard-step-3.php @@ -0,0 +1,179 @@ + 0 ? get_post( $post_id ) : null; + +if ( ! $workflow || 'breznflow_workflow' !== $workflow->post_type ) { + wp_die( esc_html__( 'Invalid workflow.', 'breznflow' ) ); +} + +$raw_json = get_post_meta( $post_id, '_breznflow_raw_json', true ); +$mask_log_raw = get_post_meta( $post_id, '_breznflow_mask_log', true ); +$mask_log = json_decode( $mask_log_raw ? $mask_log_raw : '[]', true ); +$node_count = (int) get_post_meta( $post_id, '_breznflow_node_count', true ); +$has_ai = (int) get_post_meta( $post_id, '_breznflow_has_ai_nodes', true ); +$meta_mode = get_post_meta( $post_id, '_breznflow_default_mode', true ); +$display_mode = $meta_mode ? $meta_mode : 'visual'; +$meta_zoom = (int) get_post_meta( $post_id, '_breznflow_default_zoom', true ); +$zoom = $meta_zoom ? $meta_zoom : 100; +$show_infobox = (int) get_post_meta( $post_id, '_breznflow_show_infobox', true ); + +// Check for code nodes with jsCode. +$has_code_nodes = false; +if ( $raw_json ) { + $data = json_decode( $raw_json, true ); + if ( is_array( $data ) && ! empty( $data['nodes'] ) ) { + foreach ( $data['nodes'] as $node ) { + if ( isset( $node['parameters']['jsCode'] ) ) { + $has_code_nodes = true; + break; + } + } + } +} + +$settings = \BreznFlow\Admin\SettingsPage::get_defaults(); +$saved = get_option( 'breznflow_settings', array() ); +$settings = array_merge( $settings, $saved ); +$icon_registry = \BreznFlow\Features\NodeTypeRegistry::get_registry(); +$saved_theme_raw = get_post_meta( $post_id, '_breznflow_default_theme', true ); +$saved_theme = $saved_theme_raw ? $saved_theme_raw : ( $settings['default_theme'] ?? 'dark' ); +$allowed_themes = \BreznFlow\Features\ThemeRegistry::get_theme_ids(); +$preview_theme = in_array( $saved_theme, $allowed_themes, true ) ? $saved_theme : 'dark'; +?> +
+

+ +

+ +
+ + + +
+ + +
+

+ +

+
+ + +
+

+

+ post_title ) + ); + ?> +

+ + +
+ $bf_theme_name ) { + wp_enqueue_style( + 'breznflow-theme-' . $bf_theme_id, + \BreznFlow\Features\ThemeRegistry::get_builtin_url( $bf_theme_id ), + array( 'breznflow-renderer' ), + BREZNFLOW_VERSION + ); + } + $bf_custom_css = \BreznFlow\Features\ThemeRegistry::get_custom_theme_css(); + if ( $bf_custom_css ) { + wp_add_inline_style( 'breznflow-renderer', $bf_custom_css ); + } + wp_localize_script( + 'breznflow-renderer', + 'breznflowData', + array( + array( + 'id' => $post_id, + 'workflow' => json_decode( $raw_json, true ), + 'mode' => $display_mode, + 'zoom' => $zoom, + 'show_infobox' => $show_infobox, + 'show_title' => 0, + 'max_code_lines' => (int) $settings['max_code_lines'], + 'theme' => $preview_theme, + ), + ) + ); + wp_localize_script( 'breznflow-renderer', 'breznflowIcons', $icon_registry ); + wp_localize_script( 'breznflow-renderer', 'breznflowI18n', \BreznFlow\Shortcode::get_js_i18n() ); + ?> +
+
+
+ +
+ + +
+

+

+ +

+
+ + +
+

+

+

[breznflow id=""]

+ +
+ + + +

+ + +

+
+
+
diff --git a/includes/Core.php b/includes/Core.php new file mode 100644 index 0000000..2d3d92a --- /dev/null +++ b/includes/Core.php @@ -0,0 +1,113 @@ +load_dependencies(); + $this->register_hooks(); + } + + /** + * Loads all required class files. + * + * @since 1.0.0 + * @return void + */ + private function load_dependencies(): void { + // Security. + require_once BREZNFLOW_DIR . 'includes/Security/MaskingRules.php'; + require_once BREZNFLOW_DIR . 'includes/Security/WorkflowValidator.php'; + require_once BREZNFLOW_DIR . 'includes/Security/WorkflowSanitizer.php'; + + // Post type. + require_once BREZNFLOW_DIR . 'includes/PostType.php'; + + // Features. + require_once BREZNFLOW_DIR . 'includes/Features/NodeTypeRegistry.php'; + require_once BREZNFLOW_DIR . 'includes/Features/NodeCategorizer.php'; + require_once BREZNFLOW_DIR . 'includes/Features/InfoBoxBuilder.php'; + require_once BREZNFLOW_DIR . 'includes/Features/ViewCounter.php'; + require_once BREZNFLOW_DIR . 'includes/Features/RelatedWorkflows.php'; + require_once BREZNFLOW_DIR . 'includes/Features/ThemeRegistry.php'; + require_once BREZNFLOW_DIR . 'includes/Features/ThemeImporter.php'; + + // Shortcode, download & embed. + require_once BREZNFLOW_DIR . 'includes/Shortcode.php'; + require_once BREZNFLOW_DIR . 'includes/DownloadHandler.php'; + require_once BREZNFLOW_DIR . 'includes/EmbedHandler.php'; + + // SettingsPage is loaded always because Shortcode::render() calls SettingsPage::get_defaults() on the frontend. + require_once BREZNFLOW_DIR . 'includes/Admin/SettingsPage.php'; + + // Admin-only classes. + if ( is_admin() ) { + require_once BREZNFLOW_DIR . 'includes/Admin/AdminMenu.php'; + require_once BREZNFLOW_DIR . 'includes/Admin/WizardPage.php'; + require_once BREZNFLOW_DIR . 'includes/Admin/WorkflowListTable.php'; + } + } + + /** + * Instantiates components and registers their hooks. + * + * @since 1.0.0 + * @return void + */ + private function register_hooks(): void { + ( new PostType() )->register(); + ( new Shortcode() )->register(); + ( new DownloadHandler() )->register(); + ( new EmbedHandler() )->register(); + ( new Features\ViewCounter() )->register(); + ( new Features\RelatedWorkflows() )->register(); + + if ( is_admin() ) { + ( new Admin\AdminMenu() )->register(); + ( new Admin\SettingsPage() )->register(); + ( new Admin\WizardPage() )->register(); + } + } +} diff --git a/includes/DownloadHandler.php b/includes/DownloadHandler.php new file mode 100644 index 0000000..53b9be3 --- /dev/null +++ b/includes/DownloadHandler.php @@ -0,0 +1,84 @@ +post_type || 'publish' !== $post->post_status ) { + status_header( 404 ); + exit; + } + + // Check if download is allowed. + $meta_download = (bool) get_post_meta( $post_id, '_breznflow_show_download', true ); + $global_settings = get_option( 'breznflow_settings', array() ); + $global_download = ! empty( $global_settings['allow_download'] ); + + if ( ! $meta_download || ! $global_download ) { + status_header( 403 ); + exit; + } + + // Read the already-sanitized JSON (NEVER raw). + $json = get_post_meta( $post_id, '_breznflow_raw_json', true ); + if ( ! $json ) { + status_header( 404 ); + exit; + } + + $filename = 'workflow-' . $post_id . '.json'; + + header( 'Content-Type: application/json; charset=utf-8' ); + header( 'Content-Disposition: attachment; filename="' . rawurlencode( $filename ) . '"' ); + header( 'X-Content-Type-Options: nosniff' ); + header( 'Cache-Control: no-store, no-cache, must-revalidate' ); + header( 'Content-Length: ' . strlen( $json ) ); + + echo $json; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON output for download, not HTML + exit; + } +} diff --git a/includes/EmbedHandler.php b/includes/EmbedHandler.php new file mode 100644 index 0000000..6bfb8c9 --- /dev/null +++ b/includes/EmbedHandler.php @@ -0,0 +1,185 @@ +post_type || 'publish' !== $post->post_status ) { + status_header( 404 ); + exit; + } + + $show_embed = (bool) get_post_meta( $post_id, '_breznflow_show_embed', true ); + if ( ! $show_embed ) { + status_header( 403 ); + exit; + } + + $raw_json = get_post_meta( $post_id, '_breznflow_raw_json', true ); + if ( ! $raw_json ) { + status_header( 404 ); + exit; + } + + $workflow = json_decode( $raw_json, true ); + if ( ! is_array( $workflow ) ) { + status_header( 500 ); + exit; + } + + $allowed_themes = \BreznFlow\Features\ThemeRegistry::get_theme_ids(); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $url_theme = isset( $_GET['theme'] ) ? sanitize_text_field( wp_unslash( $_GET['theme'] ) ) : ''; + $theme = in_array( $url_theme, $allowed_themes, true ) ? $url_theme : ( $settings['default_theme'] ?? 'dark' ); + $theme = in_array( $theme, $allowed_themes, true ) ? $theme : 'dark'; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public read-only embed page, no state change. + $show_minimap_embed = isset( $_GET['minimap'] ) ? ( '0' !== sanitize_text_field( wp_unslash( $_GET['minimap'] ) ) ) : true; + + $body_bgs = array( + 'dark' => '#1a1a2e', + 'light' => '#eef2f7', + 'minimal' => '#fafafa', + 'tech' => '#0d1117', + 'brezn' => '#001f4d', + ); + $body_bg = $body_bgs[ $theme ] ?? '#1a1a2e'; + + // Set headers. + header( 'Content-Type: text/html; charset=utf-8' ); + header( 'X-Robots-Tag: noindex, nofollow' ); + header( 'X-Content-Type-Options: nosniff' ); + header_remove( 'X-Frame-Options' ); + + $article_url = esc_url( get_permalink( $post_id ) ); + $anchor_id = 'breznflow-' . $post_id; + $blog_name = esc_html( get_bloginfo( 'name' ) ); + $blog_url = esc_url( home_url( '/' ) ); + $title = esc_html( $post->post_title ); + $css_url = esc_url( BREZNFLOW_URL . 'assets/renderer.css' ) . '?v=' . BREZNFLOW_VERSION; + $js_url = esc_url( BREZNFLOW_URL . 'assets/renderer.js' ) . '?v=' . BREZNFLOW_VERSION; + + $inline_data = array( + array( + 'id' => $post_id, + 'workflow' => $workflow, + 'mode' => 'visual', + 'zoom' => 100, + 'autofit_threshold' => (int) ( $settings['autofit_threshold'] ?? 30 ), + 'show_title' => false, + 'show_infobox' => false, + 'show_download' => false, + 'show_minimap' => $show_minimap_embed, + 'show_share' => false, + 'show_embed' => false, + 'show_get_json' => false, + 'max_code_lines' => (int) ( $settings['max_code_lines'] ?? 50 ), + 'download_label' => '', + 'download_url' => '', + 'theme' => $theme, + ), + ); + + $icons_json = wp_json_encode( Features\NodeTypeRegistry::get_registry() ); + $data_json = wp_json_encode( $inline_data ); + $i18n_json = wp_json_encode( Shortcode::get_js_i18n() ); + + // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- intentional standalone HTML output; all dynamic values escaped above + ?> + + + + + +<?php echo $title; ?> + + $bf_embed_name ) : ?> + + + + + + + + +
+
+
+
+ + + +
+ + + + + $count ) { + if ( $i < 6 ) { + $display[] = array( + 'label' => $label, + 'count' => $count, + ); + } else { + ++$more; + } + ++$i; + } + + $html = '
'; + $html .= '
'; + + foreach ( $display as $item ) { + $html .= sprintf( + '%dx %s', + (int) $item['count'], + esc_html( $item['label'] ) + ); + } + + if ( $more > 0 ) { + $html .= sprintf( + '+ %s %s', + (int) $more, + /* translators: %d: number of additional node types */ + esc_html( _n( 'more type', 'more types', $more, 'breznflow' ) ) + ); + } + + $html .= '
'; + + if ( $has_ai ) { + $html .= '
'; + $html .= '' . esc_html__( 'AI-powered', 'breznflow' ) . ''; + $html .= '
'; + } + + $html .= sprintf( + '
%s
', + sprintf( + /* translators: %d: total node count */ + esc_html( _n( '%d node', '%d nodes', $total, 'breznflow' ) ), + (int) $total + ) + ); + + $html .= '
'; + return $html; + } +} diff --git a/includes/Features/NodeCategorizer.php b/includes/Features/NodeCategorizer.php new file mode 100644 index 0000000..594ea94 --- /dev/null +++ b/includes/Features/NodeCategorizer.php @@ -0,0 +1,166 @@ + $counts, + 'by_category' => $by_category, + 'ai_nodes' => $ai_nodes, + 'has_ai' => ! empty( $ai_nodes ), + 'total' => count( $nodes ), + ); + } + + /** + * Determines the category for a node slug. + * + * @since 1.0.0 + * @param string $slug Node type slug. + * @return string Category name. + */ + private static function get_category( string $slug ): string { + $slug_lower = strtolower( $slug ); + + // AI check first. + foreach ( self::AI_KEYWORDS as $keyword ) { + if ( str_contains( $slug_lower, $keyword ) ) { + return 'ai'; + } + } + + $trigger_slugs = array( 'scheduletrigger', 'webhook', 'manualtrigger', 'formtrigger', 'emailreadimap', 'rssfeadread' ); + foreach ( $trigger_slugs as $t ) { + if ( str_contains( $slug_lower, 'trigger' ) || str_contains( $slug_lower, 'webhook' ) ) { + return 'trigger'; + } + } + + $logic_slugs = array( + 'if', + 'switch', + 'filter', + 'merge', + 'splitinbatches', + 'splitout', + 'sort', + 'limit', + 'removeduplicates', + 'aggregate', + 'comparedatasets', + ); + foreach ( $logic_slugs as $l ) { + if ( $slug_lower === $l ) { + return 'logic'; + } + } + + $code_slugs = array( + 'code', + 'function', + 'executeworkflow', + 'set', + 'editfields', + 'html', + 'xml', + 'markdown', + 'crypto', + 'tofile', + 'converttofile', + 'extractfromfile', + ); + foreach ( $code_slugs as $c ) { + if ( $slug_lower === $c ) { + return 'transform'; + } + } + + $db_slugs = array( 'mysql', 'postgres', 'redis', 'mongodb', 'sqlite', 'microsoftsql', 'supabase' ); + foreach ( $db_slugs as $d ) { + if ( $slug_lower === $d ) { + return 'database'; + } + } + + return 'action'; + } +} diff --git a/includes/Features/NodeTypeRegistry.php b/includes/Features/NodeTypeRegistry.php new file mode 100644 index 0000000..cf8ada3 --- /dev/null +++ b/includes/Features/NodeTypeRegistry.php @@ -0,0 +1,813 @@ + + */ + public static function get_registry(): array { + return array( + // Triggers. + 'scheduleTrigger' => array( + 'label' => 'Schedule Trigger', + 'icon' => 'symbol', + 'symbol' => '⏰', + 'color' => '#31C48D', + 'bg' => '#1a3a2e', + ), + 'webhook' => array( + 'label' => 'Webhook', + 'icon' => 'symbol', + 'symbol' => '⇆', + 'color' => '#5277D4', + 'bg' => '#1a2040', + ), + 'manualTrigger' => array( + 'label' => 'Manual Trigger', + 'icon' => 'symbol', + 'symbol' => '▶', + 'color' => '#FF6B35', + 'bg' => '#3a1a00', + ), + 'formTrigger' => array( + 'label' => 'Form Trigger', + 'icon' => 'symbol', + 'symbol' => '▤', + 'color' => '#9B59B6', + 'bg' => '#2a1040', + ), + 'emailReadImap' => array( + 'label' => 'Email (IMAP)', + 'icon' => 'symbol', + 'symbol' => '✉', + 'color' => '#E74C3C', + 'bg' => '#3a1010', + ), + 'respondToWebhook' => array( + 'label' => 'Respond to Webhook', + 'icon' => 'symbol', + 'symbol' => '↩', + 'color' => '#5277D4', + 'bg' => '#1a2040', + ), + + // Core logic. + 'code' => array( + 'label' => 'Code', + 'icon' => 'symbol', + 'symbol' => '{ }', + 'color' => '#FF9500', + 'bg' => '#3a2000', + ), + 'function' => array( + 'label' => 'Function', + 'icon' => 'symbol', + 'symbol' => 'ƒ', + 'color' => '#FF9500', + 'bg' => '#3a2000', + ), + 'executeWorkflow' => array( + 'label' => 'Execute Workflow', + 'icon' => 'symbol', + 'symbol' => '↺', + 'color' => '#7F8C8D', + 'bg' => '#2d2d2d', + ), + 'httpRequest' => array( + 'label' => 'HTTP Request', + 'icon' => 'symbol', + 'symbol' => '⊕', + 'color' => '#5277D4', + 'bg' => '#1a2040', + ), + 'wait' => array( + 'label' => 'Wait', + 'icon' => 'symbol', + 'symbol' => 'II', + 'color' => '#9B59B6', + 'bg' => '#2a1040', + ), + 'if' => array( + 'label' => 'If', + 'icon' => 'symbol', + 'symbol' => '?', + 'color' => '#F39C12', + 'bg' => '#3a2800', + ), + 'switch' => array( + 'label' => 'Switch', + 'icon' => 'symbol', + 'symbol' => '⋈', + 'color' => '#F39C12', + 'bg' => '#3a2800', + ), + 'merge' => array( + 'label' => 'Merge', + 'icon' => 'symbol', + 'symbol' => '⊕', + 'color' => '#26C6DA', + 'bg' => '#003040', + ), + 'splitInBatches' => array( + 'label' => 'Split in Batches', + 'icon' => 'symbol', + 'symbol' => '⋮', + 'color' => '#95A5A6', + 'bg' => '#2d2d2d', + ), + 'set' => array( + 'label' => 'Set', + 'icon' => 'symbol', + 'symbol' => '=', + 'color' => '#26C6DA', + 'bg' => '#003040', + ), + 'editFields' => array( + 'label' => 'Edit Fields', + 'icon' => 'symbol', + 'symbol' => '=', + 'color' => '#26C6DA', + 'bg' => '#003040', + ), + 'filter' => array( + 'label' => 'Filter', + 'icon' => 'symbol', + 'symbol' => '▽', + 'color' => '#95A5A6', + 'bg' => '#2d2d2d', + ), + 'aggregate' => array( + 'label' => 'Aggregate', + 'icon' => 'symbol', + 'symbol' => '∑', + 'color' => '#26C6DA', + 'bg' => '#003040', + ), + 'splitOut' => array( + 'label' => 'Split Out', + 'icon' => 'symbol', + 'symbol' => '↔', + 'color' => '#95A5A6', + 'bg' => '#2d2d2d', + ), + 'sort' => array( + 'label' => 'Sort', + 'icon' => 'symbol', + 'symbol' => '↕', + 'color' => '#95A5A6', + 'bg' => '#2d2d2d', + ), + 'limit' => array( + 'label' => 'Limit', + 'icon' => 'symbol', + 'symbol' => '⊐', + 'color' => '#95A5A6', + 'bg' => '#2d2d2d', + ), + 'removeDuplicates' => array( + 'label' => 'Remove Duplicates', + 'icon' => 'symbol', + 'symbol' => '≠', + 'color' => '#95A5A6', + 'bg' => '#2d2d2d', + ), + 'itemLists' => array( + 'label' => 'Item Lists', + 'icon' => 'symbol', + 'symbol' => '≡', + 'color' => '#95A5A6', + 'bg' => '#2d2d2d', + ), + 'compareDatasets' => array( + 'label' => 'Compare Datasets', + 'icon' => 'symbol', + 'symbol' => '△', + 'color' => '#26C6DA', + 'bg' => '#003040', + ), + 'noOp' => array( + 'label' => 'No Operation', + 'icon' => 'symbol', + 'symbol' => '∅', + 'color' => '#BDC3C7', + 'bg' => '#2d2d2d', + ), + 'stickyNote' => array( + 'label' => 'Sticky Note', + 'icon' => 'symbol', + 'symbol' => '📌', + 'color' => '#F1C40F', + 'bg' => '#3a3000', + ), + 'stopAndError' => array( + 'label' => 'Stop and Error', + 'icon' => 'symbol', + 'symbol' => '✕', + 'color' => '#E74C3C', + 'bg' => '#3a1010', + ), + + // Data transformation. + 'toFile' => array( + 'label' => 'To File', + 'icon' => 'symbol', + 'symbol' => '↓', + 'color' => '#95A5A6', + 'bg' => '#2d2d2d', + ), + 'convertToFile' => array( + 'label' => 'Convert to File', + 'icon' => 'symbol', + 'symbol' => '↓', + 'color' => '#95A5A6', + 'bg' => '#2d2d2d', + ), + 'html' => array( + 'label' => 'HTML', + 'icon' => 'symbol', + 'symbol' => '', + 'color' => '#E34F26', + 'bg' => '#3a1000', + ), + 'xml' => array( + 'label' => 'XML', + 'icon' => 'symbol', + 'symbol' => '', + 'color' => '#95A5A6', + 'bg' => '#2d2d2d', + ), + 'markdown' => array( + 'label' => 'Markdown', + 'icon' => 'symbol', + 'symbol' => 'M↓', + 'color' => '#083FA1', + 'bg' => '#001040', + ), + 'crypto' => array( + 'label' => 'Crypto', + 'icon' => 'symbol', + 'symbol' => '🔒', + 'color' => '#F39C12', + 'bg' => '#3a2800', + ), + 'compression' => array( + 'label' => 'Compression', + 'icon' => 'symbol', + 'symbol' => '⇩', + 'color' => '#95A5A6', + 'bg' => '#2d2d2d', + ), + 'extractFromFile' => array( + 'label' => 'Extract from File', + 'icon' => 'symbol', + 'symbol' => '↑', + 'color' => '#95A5A6', + 'bg' => '#2d2d2d', + ), + + // Databases. + 'mySql' => array( + 'label' => 'MySQL', + 'icon' => 'initials', + 'symbol' => 'My', + 'color' => '#4479A1', + 'bg' => '#001530', + ), + 'postgres' => array( + 'label' => 'PostgreSQL', + 'icon' => 'initials', + 'symbol' => 'PG', + 'color' => '#336791', + 'bg' => '#001030', + ), + 'redis' => array( + 'label' => 'Redis', + 'icon' => 'initials', + 'symbol' => 'Re', + 'color' => '#DC382D', + 'bg' => '#3a1010', + ), + 'mongodb' => array( + 'label' => 'MongoDB', + 'icon' => 'initials', + 'symbol' => 'Mg', + 'color' => '#4DB33D', + 'bg' => '#103010', + ), + 'sqlite' => array( + 'label' => 'SQLite', + 'icon' => 'initials', + 'symbol' => 'SL', + 'color' => '#003B57', + 'bg' => '#001020', + ), + 'microsoftSql' => array( + 'label' => 'MS SQL', + 'icon' => 'initials', + 'symbol' => 'MS', + 'color' => '#CC2927', + 'bg' => '#3a1010', + ), + 'supabase' => array( + 'label' => 'Supabase', + 'icon' => 'initials', + 'symbol' => 'Sb', + 'color' => '#3ECF8E', + 'bg' => '#103020', + ), + + // Communication. + 'slack' => array( + 'label' => 'Slack', + 'icon' => 'initials', + 'symbol' => 'Sl', + 'color' => '#4A154B', + 'bg' => '#200020', + ), + 'telegram' => array( + 'label' => 'Telegram', + 'icon' => 'symbol', + 'symbol' => '✈', + 'color' => '#2CA5E0', + 'bg' => '#003040', + ), + 'discord' => array( + 'label' => 'Discord', + 'icon' => 'initials', + 'symbol' => 'Dc', + 'color' => '#5865F2', + 'bg' => '#101080', + ), + 'mattermost' => array( + 'label' => 'Mattermost', + 'icon' => 'initials', + 'symbol' => 'Mm', + 'color' => '#0058CC', + 'bg' => '#001840', + ), + 'emailSend' => array( + 'label' => 'Send Email', + 'icon' => 'symbol', + 'symbol' => '✉', + 'color' => '#E74C3C', + 'bg' => '#3a1010', + ), + 'sendEmail' => array( + 'label' => 'Send Email', + 'icon' => 'symbol', + 'symbol' => '✉', + 'color' => '#E74C3C', + 'bg' => '#3a1010', + ), + 'gmail' => array( + 'label' => 'Gmail', + 'icon' => 'initials', + 'symbol' => 'Gm', + 'color' => '#EA4335', + 'bg' => '#3a1010', + ), + 'microsoftOutlook' => array( + 'label' => 'Outlook', + 'icon' => 'initials', + 'symbol' => 'Ol', + 'color' => '#0078D4', + 'bg' => '#001840', + ), + 'whatsapp' => array( + 'label' => 'WhatsApp', + 'icon' => 'initials', + 'symbol' => 'WA', + 'color' => '#25D366', + 'bg' => '#103020', + ), + 'twilio' => array( + 'label' => 'Twilio', + 'icon' => 'initials', + 'symbol' => 'Tw', + 'color' => '#F22F46', + 'bg' => '#3a1010', + ), + + // Google. + 'googleSheets' => array( + 'label' => 'Google Sheets', + 'icon' => 'initials', + 'symbol' => 'GS', + 'color' => '#34A853', + 'bg' => '#103020', + ), + 'googleDrive' => array( + 'label' => 'Google Drive', + 'icon' => 'initials', + 'symbol' => 'GD', + 'color' => '#4285F4', + 'bg' => '#001840', + ), + 'googleCalendar' => array( + 'label' => 'Google Calendar', + 'icon' => 'initials', + 'symbol' => 'GC', + 'color' => '#4285F4', + 'bg' => '#001840', + ), + 'googleDocs' => array( + 'label' => 'Google Docs', + 'icon' => 'initials', + 'symbol' => 'Gd', + 'color' => '#4285F4', + 'bg' => '#001840', + ), + 'youTube' => array( + 'label' => 'YouTube', + 'icon' => 'initials', + 'symbol' => 'YT', + 'color' => '#FF0000', + 'bg' => '#3a0000', + ), + + // Dev / Version control. + 'github' => array( + 'label' => 'GitHub', + 'icon' => 'initials', + 'symbol' => 'GH', + 'color' => '#24292E', + 'bg' => '#000000', + ), + 'gitlab' => array( + 'label' => 'GitLab', + 'icon' => 'initials', + 'symbol' => 'GL', + 'color' => '#FC6D26', + 'bg' => '#3a1a00', + ), + 'jira' => array( + 'label' => 'Jira', + 'icon' => 'initials', + 'symbol' => 'Ji', + 'color' => '#0052CC', + 'bg' => '#001840', + ), + 'confluence' => array( + 'label' => 'Confluence', + 'icon' => 'initials', + 'symbol' => 'Cf', + 'color' => '#0052CC', + 'bg' => '#001840', + ), + 'linear' => array( + 'label' => 'Linear', + 'icon' => 'initials', + 'symbol' => 'Li', + 'color' => '#5E6AD2', + 'bg' => '#101040', + ), + 'notion' => array( + 'label' => 'Notion', + 'icon' => 'initials', + 'symbol' => 'No', + 'color' => '#FFFFFF', + 'bg' => '#1a1a1a', + ), + + // AI. + 'openAi' => array( + 'label' => 'OpenAI', + 'icon' => 'initials', + 'symbol' => 'OA', + 'color' => '#00A67E', + 'bg' => '#003020', + ), + 'anthropicClaude' => array( + 'label' => 'Anthropic', + 'icon' => 'initials', + 'symbol' => 'An', + 'color' => '#D97757', + 'bg' => '#3a1a00', + ), + 'anthropic' => array( + 'label' => 'Anthropic', + 'icon' => 'initials', + 'symbol' => 'An', + 'color' => '#D97757', + 'bg' => '#3a1a00', + ), + 'gemini' => array( + 'label' => 'Gemini', + 'icon' => 'initials', + 'symbol' => 'Gm', + 'color' => '#4285F4', + 'bg' => '#001840', + ), + 'googleAi' => array( + 'label' => 'Google AI', + 'icon' => 'initials', + 'symbol' => 'GA', + 'color' => '#4285F4', + 'bg' => '#001840', + ), + 'huggingFace' => array( + 'label' => 'HuggingFace', + 'icon' => 'initials', + 'symbol' => 'HF', + 'color' => '#FFD21E', + 'bg' => '#3a2a00', + ), + 'mistral' => array( + 'label' => 'Mistral', + 'icon' => 'initials', + 'symbol' => 'Mi', + 'color' => '#FF7000', + 'bg' => '#3a1a00', + ), + 'ollama' => array( + 'label' => 'Ollama', + 'icon' => 'initials', + 'symbol' => 'Ol', + 'color' => '#7C3AED', + 'bg' => '#200040', + ), + 'lmChatOllama' => array( + 'label' => 'Ollama Chat', + 'icon' => 'initials', + 'symbol' => 'Ol', + 'color' => '#7C3AED', + 'bg' => '#200040', + ), + 'cohere' => array( + 'label' => 'Cohere', + 'icon' => 'initials', + 'symbol' => 'Co', + 'color' => '#39594D', + 'bg' => '#102020', + ), + 'lmChatOpenAi' => array( + 'label' => 'OpenAI Chat', + 'icon' => 'initials', + 'symbol' => 'OA', + 'color' => '#00A67E', + 'bg' => '#003020', + ), + 'lmChatAnthropic' => array( + 'label' => 'Anthropic Chat', + 'icon' => 'initials', + 'symbol' => 'An', + 'color' => '#D97757', + 'bg' => '#3a1a00', + ), + 'agent' => array( + 'label' => 'AI Agent', + 'icon' => 'symbol', + 'symbol' => '🤖', + 'color' => '#7C3AED', + 'bg' => '#200040', + ), + 'chainLlm' => array( + 'label' => 'LLM Chain', + 'icon' => 'symbol', + 'symbol' => '⛓', + 'color' => '#7C3AED', + 'bg' => '#200040', + ), + 'vectorStore' => array( + 'label' => 'Vector Store', + 'icon' => 'symbol', + 'symbol' => '⊞', + 'color' => '#7C3AED', + 'bg' => '#200040', + ), + 'memoryBufferWindow' => array( + 'label' => 'Memory Buffer', + 'icon' => 'symbol', + 'symbol' => '◫', + 'color' => '#7C3AED', + 'bg' => '#200040', + ), + + // Storage / Files. + 'ftp' => array( + 'label' => 'FTP', + 'icon' => 'initials', + 'symbol' => 'FT', + 'color' => '#F39C12', + 'bg' => '#3a2800', + ), + 'ssh' => array( + 'label' => 'SSH', + 'icon' => 'symbol', + 'symbol' => '$_', + 'color' => '#2ECC71', + 'bg' => '#103020', + ), + 'readWriteFile' => array( + 'label' => 'Read/Write Files', + 'icon' => 'symbol', + 'symbol' => '📁', + 'color' => '#F39C12', + 'bg' => '#3a2800', + ), + 'airtable' => array( + 'label' => 'Airtable', + 'icon' => 'initials', + 'symbol' => 'At', + 'color' => '#18BFFF', + 'bg' => '#003040', + ), + 'baserow' => array( + 'label' => 'Baserow', + 'icon' => 'initials', + 'symbol' => 'Br', + 'color' => '#4A90D9', + 'bg' => '#001840', + ), + + // CRM / Marketing. + 'hubspot' => array( + 'label' => 'HubSpot', + 'icon' => 'initials', + 'symbol' => 'Hs', + 'color' => '#FF7A59', + 'bg' => '#3a1a00', + ), + 'salesforce' => array( + 'label' => 'Salesforce', + 'icon' => 'initials', + 'symbol' => 'Sf', + 'color' => '#00A1E0', + 'bg' => '#003040', + ), + 'mailchimp' => array( + 'label' => 'Mailchimp', + 'icon' => 'initials', + 'symbol' => 'Mc', + 'color' => '#FFE01B', + 'bg' => '#3a3000', + ), + 'brevo' => array( + 'label' => 'Brevo', + 'icon' => 'initials', + 'symbol' => 'Bv', + 'color' => '#0092FF', + 'bg' => '#003040', + ), + + // WordPress / CMS. + 'wordpress' => array( + 'label' => 'WordPress', + 'icon' => 'initials', + 'symbol' => 'WP', + 'color' => '#21759B', + 'bg' => '#002030', + ), + 'rssFeedRead' => array( + 'label' => 'RSS Feed', + 'icon' => 'symbol', + 'symbol' => '◉', + 'color' => '#F26522', + 'bg' => '#3a1000', + ), + ); + } + + /** + * Looks up a node by its full n8n type string. + * Extracts the slug (last segment after dot) for registry lookup. + * + * @param string $type Full n8n type string e.g. "n8n-nodes-base.httpRequest". + * @return array Node type data or fallback entry. + */ + public static function lookup( string $type ): array { + $registry = self::get_registry(); + $slug = self::extract_slug( $type ); + + // Case-sensitive exact match first. + if ( isset( $registry[ $slug ] ) ) { + return $registry[ $slug ]; + } + + // Case-insensitive fallback. + $slug_lower = strtolower( $slug ); + foreach ( $registry as $key => $entry ) { + if ( strtolower( $key ) === $slug_lower ) { + return $entry; + } + } + + // Generate fallback entry. + return self::generate_fallback( $type, $slug ); + } + + /** + * Extracts the node slug from a full n8n type string. + * + * @param string $type e.g. "n8n-nodes-base.httpRequest" → "httpRequest". + */ + public static function extract_slug( string $type ): string { + $parts = explode( '.', $type ); + return end( $parts ); + } + + /** + * Generates a deterministic fallback entry for unknown node types. + * Uses djb2 hash for a consistent color. + * + * @since 1.0.0 + * @param string $type Full n8n node type string. + * @param string $slug Extracted slug portion. + * @return array Fallback display metadata. + */ + private static function generate_fallback( string $type, string $slug ): array { + $hue = self::djb2_color_hue( $type ); + $hex = self::hsl_to_hex( $hue, 60, 55 ); + $bg = self::hsl_to_hex( $hue, 40, 15 ); + + // Derive 2-letter initials from slug. + $initials = self::derive_initials( $slug ); + + return array( + 'label' => $slug, + 'icon' => 'initials', + 'symbol' => $initials, + 'color' => $hex, + 'bg' => $bg, + ); + } + + /** + * Derives 2-letter initials from a camelCase or PascalCase slug. + * + * @since 1.0.0 + * @param string $slug Node type slug. + * @return string Two uppercase initials. + */ + private static function derive_initials( string $slug ): string { + // Split on camelCase boundaries. + $parts = preg_split( '/(?=[A-Z])/', $slug, -1, PREG_SPLIT_NO_EMPTY ); + if ( ! $parts ) { + return strtoupper( substr( $slug, 0, 2 ) ); + } + + if ( count( $parts ) >= 2 ) { + return strtoupper( substr( $parts[0], 0, 1 ) . substr( $parts[1], 0, 1 ) ); + } + return strtoupper( substr( $slug, 0, 2 ) ); + } + + /** + * Computes a djb2 hash and maps it to a color hue in the 0-359 range. + * + * @since 1.0.0 + * @param string $str Input string to hash. + * @return int Hue value between 0 and 359. + */ + private static function djb2_color_hue( string $str ): int { + $hash = 5381; + $len = strlen( $str ); + for ( $i = 0; $i < $len; $i++ ) { + $hash = ( ( $hash << 5 ) + $hash ) + ord( $str[ $i ] ); + $hash &= 0xFFFFFFFF; + } + return abs( $hash ) % 360; + } + + /** + * Converts HSL to 6-digit hex color string. + * + * @since 1.0.0 + * @param int $h Hue (0-359). + * @param int $s Saturation (0-100). + * @param int $l Lightness (0-100). + * @return string Hex color string (e.g. "#ff8800"). + */ + private static function hsl_to_hex( int $h, int $s, int $l ): string { + $s /= 100; + $l /= 100; + $a = $s * min( $l, 1 - $l ); + $f = static function ( $n ) use ( $h, $l, $a ) { + $k = fmod( $n + $h / 30, 12 ); + return $l - $a * max( min( $k - 3, 9 - $k, 1 ), -1 ); + }; + $r = (int) round( $f( 0 ) * 255 ); + $g = (int) round( $f( 8 ) * 255 ); + $b = (int) round( $f( 4 ) * 255 ); + return sprintf( '#%02x%02x%02x', max( 0, min( 255, $r ) ), max( 0, min( 255, $g ) ), max( 0, min( 255, $b ) ) ); + } +} diff --git a/includes/Features/RelatedWorkflows.php b/includes/Features/RelatedWorkflows.php new file mode 100644 index 0000000..29843d5 --- /dev/null +++ b/includes/Features/RelatedWorkflows.php @@ -0,0 +1,106 @@ + 'breznflow_workflow', + 'post_status' => 'publish', + 'posts_per_page' => $limit, + 'post__not_in' => array( $post_id ), // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in + 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + array( + 'key' => '_breznflow_node_summary', + 'compare' => 'EXISTS', + ), + ), + ) + ); + + // Filter by shared node types. + $scored = array(); + foreach ( $related as $post ) { + $other_summary = get_post_meta( $post->ID, '_breznflow_node_summary', true ); + if ( ! $other_summary ) { + continue; + } + $other_types = array_keys( (array) json_decode( $other_summary, true ) ); + $shared = count( array_intersect( $node_types, $other_types ) ); + if ( $shared > 0 ) { + $scored[] = array( + 'post' => $post, + 'shared' => $shared, + ); + } + } + + usort( + $scored, + static function ( $a, $b ) { + return $b['shared'] - $a['shared']; + } + ); + + $result = array_column( array_slice( $scored, 0, $limit ), 'post' ); + + set_transient( $cache_key, $result, HOUR_IN_SECONDS ); + return $result; + } +} diff --git a/includes/Features/ThemeImporter.php b/includes/Features/ThemeImporter.php new file mode 100644 index 0000000..d3d2afb --- /dev/null +++ b/includes/Features/ThemeImporter.php @@ -0,0 +1,265 @@ + 80 ) { + return new \WP_Error( 'invalid_name', __( 'Theme name must be 80 characters or fewer.', 'breznflow' ) ); + } + + // version: must be an integer. + if ( ! is_int( $data['version'] ) && ! ctype_digit( (string) $data['version'] ) ) { + return new \WP_Error( 'invalid_version', __( 'Theme version must be an integer.', 'breznflow' ) ); + } + + // tokens: must be an array. + if ( ! is_array( $data['tokens'] ) ) { + return new \WP_Error( 'invalid_tokens', __( 'Tokens must be an object.', 'breznflow' ) ); + } + + // Tokens: no extra keys. + $token_keys = array_keys( $data['tokens'] ); + $extra_tokens = array_diff( $token_keys, self::ALLOWED_TOKENS ); + if ( ! empty( $extra_tokens ) ) { + return new \WP_Error( + 'unknown_tokens', + /* translators: %s: comma-separated token names */ + sprintf( __( 'Unknown tokens: %s', 'breznflow' ), implode( ', ', $extra_tokens ) ) + ); + } + + // Tokens: all 41 must be present. + $missing = array_diff( self::ALLOWED_TOKENS, $token_keys ); + if ( ! empty( $missing ) ) { + return new \WP_Error( + 'missing_tokens', + /* translators: %s: comma-separated token names */ + sprintf( __( 'Missing tokens: %s', 'breznflow' ), implode( ', ', $missing ) ) + ); + } + + // Each token value must be a safe color expression. + foreach ( $data['tokens'] as $token => $value ) { + if ( ! self::validate_color_value( (string) $value ) ) { + return new \WP_Error( + 'invalid_color', + /* translators: 1: token name, 2: value */ + sprintf( __( 'Invalid color value for "%1$s": %2$s', 'breznflow' ), $token, $value ) + ); + } + } + + return true; + } + + /** + * Validates and stores a theme in wp_options. + * If a theme with the same ID already exists it is replaced. + * + * @param array $data Decoded JSON as PHP array. + * @return true|\WP_Error + */ + public static function import( array $data ) { + $result = self::validate( $data ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $themes = get_option( 'breznflow_custom_themes', array() ); + + // Remove existing entry with the same ID. + foreach ( $themes as $i => $t ) { + if ( sanitize_key( $data['id'] ) === $t['id'] ) { + array_splice( $themes, $i, 1 ); + break; + } + } + + $themes[] = array( + 'id' => sanitize_key( $data['id'] ), + 'name' => sanitize_text_field( $data['name'] ), + 'version' => (int) $data['version'], + 'tokens' => $data['tokens'], + ); + + update_option( 'breznflow_custom_themes', $themes ); + return true; + } + + /** + * Deletes a custom theme by ID. + * + * @param string $id Theme ID. + * @return bool True if deleted, false if not found. + */ + public static function delete( string $id ): bool { + $id = sanitize_key( $id ); + $themes = get_option( 'breznflow_custom_themes', array() ); + $new = array_values( array_filter( $themes, fn( $t ) => $t['id'] !== $id ) ); + + if ( count( $new ) === count( $themes ) ) { + return false; + } + + update_option( 'breznflow_custom_themes', $new ); + return true; + } + + /** + * Returns all stored custom themes. + * + * @return array[] + */ + public static function get_all(): array { + return get_option( 'breznflow_custom_themes', array() ); + } + + /** + * Validates a single CSS color value. + * Accepts: #rgb, #rrggbb, #rrggbbaa, rgb(...), rgba(...) + * Rejects: url(...), @import, semicolons, curly braces, etc. + * + * @param string $value Raw token value. + * @return bool + */ + private static function validate_color_value( string $value ): bool { + $value = trim( $value ); + + // Hex: #rgb, #rrggbb, #rrggbbaa. + if ( preg_match( '/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3}([0-9a-fA-F]{2})?)?$/', $value ) ) { + return true; + } + + // rgb(r, g, b). + if ( preg_match( '/^rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)$/', $value ) ) { + return true; + } + + // rgba(r, g, b, a) — alpha must be 0..1 decimal. + if ( preg_match( + '/^rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(0(\.\d+)?|1(\.0+)?)\s*\)$/', + $value + ) ) { + return true; + } + + return false; + } +} diff --git a/includes/Features/ThemeRegistry.php b/includes/Features/ThemeRegistry.php new file mode 100644 index 0000000..999fa1f --- /dev/null +++ b/includes/Features/ThemeRegistry.php @@ -0,0 +1,106 @@ + 'Dark', + 'light' => 'Light', + 'minimal' => 'Minimal', + 'tech' => 'Tech', + 'brezn' => 'Brezn', + ); + + /** + * Returns all available themes (built-in + custom) as: + * [ 'id' => ['name' => string, 'custom' => bool], ... ] + * + * @return array + */ + public static function discover(): array { + $themes = array(); + + foreach ( self::BUILTIN as $id => $name ) { + $themes[ $id ] = array( + 'name' => $name, + 'custom' => false, + ); + } + + $customs = get_option( 'breznflow_custom_themes', array() ); + foreach ( $customs as $theme ) { + $themes[ $theme['id'] ] = array( + 'name' => $theme['name'], + 'custom' => true, + ); + } + + return $themes; + } + + /** + * Returns all theme IDs as a flat array. + * + * @return string[] + */ + public static function get_theme_ids(): array { + return array_keys( self::discover() ); + } + + /** + * Returns the URL to a built-in theme CSS file. + * + * @param string $id Built-in theme ID. + * @return string + */ + public static function get_builtin_url( string $id ): string { + return BREZNFLOW_URL . 'assets/themes/' . sanitize_key( $id ) . '.css'; + } + + /** + * Generates a CSS block for all custom themes, suitable for wp_add_inline_style(). + * Tokens are mapped: token_name → --breznflow-token-name + * + * @return string CSS string (empty if no custom themes stored). + */ + public static function get_custom_theme_css(): string { + $themes = get_option( 'breznflow_custom_themes', array() ); + $css = ''; + + foreach ( $themes as $theme ) { + $id = $theme['id']; + $sel = '.breznflow-wrap[data-theme="' . $id . '"],' + . '.breznflow-modal-overlay[data-theme="' . $id . '"],' + . '.breznflow-fs-portal[data-theme="' . $id . '"]'; + + $css .= $sel . '{'; + foreach ( $theme['tokens'] as $token => $value ) { + $var = '--breznflow-' . str_replace( '_', '-', $token ); + $css .= $var . ':' . $value . ';'; + } + $css .= '}'; + } + + return $css; + } +} diff --git a/includes/Features/ViewCounter.php b/includes/Features/ViewCounter.php new file mode 100644 index 0000000..58ec8ab --- /dev/null +++ b/includes/Features/ViewCounter.php @@ -0,0 +1,55 @@ + array( + 'name' => __( 'Workflows', 'breznflow' ), + 'singular_name' => __( 'Workflow', 'breznflow' ), + 'add_new' => __( 'Add Workflow', 'breznflow' ), + 'add_new_item' => __( 'Add New Workflow', 'breznflow' ), + 'edit_item' => __( 'Edit Workflow', 'breznflow' ), + 'new_item' => __( 'New Workflow', 'breznflow' ), + 'view_item' => __( 'View Workflow', 'breznflow' ), + 'search_items' => __( 'Search Workflows', 'breznflow' ), + 'not_found' => __( 'No workflows found', 'breznflow' ), + 'not_found_in_trash' => __( 'No workflows found in trash', 'breznflow' ), + 'menu_name' => __( 'BreznFlow', 'breznflow' ), + ), + 'public' => false, + 'show_ui' => true, + 'show_in_menu' => false, + 'show_in_rest' => false, + 'capability_type' => 'post', + 'has_archive' => false, + 'rewrite' => false, + 'supports' => array( 'title', 'author' ), + 'taxonomies' => array( 'breznflow_category' ), + ) + ); + } + + /** + * Registers the breznflow_category taxonomy. + * + * @since 1.0.0 + * @return void + */ + public function register_taxonomy(): void { + register_taxonomy( + 'breznflow_category', + 'breznflow_workflow', + array( + 'labels' => array( + 'name' => __( 'Workflow Categories', 'breznflow' ), + 'singular_name' => __( 'Workflow Category', 'breznflow' ), + 'search_items' => __( 'Search Categories', 'breznflow' ), + 'all_items' => __( 'All Categories', 'breznflow' ), + 'parent_item' => __( 'Parent Category', 'breznflow' ), + 'parent_item_colon' => __( 'Parent Category:', 'breznflow' ), + 'edit_item' => __( 'Edit Category', 'breznflow' ), + 'update_item' => __( 'Update Category', 'breznflow' ), + 'add_new_item' => __( 'Add New Category', 'breznflow' ), + 'new_item_name' => __( 'New Category Name', 'breznflow' ), + 'menu_name' => __( 'Categories', 'breznflow' ), + ), + 'hierarchical' => true, + 'public' => false, + 'show_ui' => true, + 'show_in_menu' => false, + 'show_in_rest' => false, + 'show_admin_column' => true, + 'rewrite' => false, + ) + ); + } +} diff --git a/includes/Security/MaskingRules.php b/includes/Security/MaskingRules.php new file mode 100644 index 0000000..ad37beb --- /dev/null +++ b/includes/Security/MaskingRules.php @@ -0,0 +1,174 @@ + 'url_param', + 'key' => $matches[0], + 'note' => 'Sensitive URL parameter value replaced.', + ); + return $matches[1] . '[REDACTED]'; + }, + $value + ); + return null !== $masked ? $masked : $value; + } + + /** + * Masks values of fields with inherently sensitive names. + * + * @since 1.0.0 + * @param string $value Raw string to inspect. + * @param string $field_key The parameter key name. + * @param array $log Passed by reference — masked items appended here. + * @return string Possibly masked value. + */ + private static function mask_sensitive_field( string $value, string $field_key, array &$log ): string { + $sensitive_keys = array( + 'api_key', + 'apikey', + 'token', + 'secret', + 'password', + 'access_token', + 'auth', + 'private_key', + 'client_secret', + 'apiKey', + 'accessToken', + 'clientSecret', + 'privateKey', + ); + + if ( in_array( $field_key, $sensitive_keys, true ) && '' !== $value && '[REDACTED]' !== $value ) { + $log[] = array( + 'reason' => 'sensitive_field_name', + 'key' => $field_key, + 'note' => 'Field name indicates sensitive data.', + ); + return '[REDACTED]'; + } + return $value; + } + + /** + * Applies condition rightValue heuristic masking. + * Used specifically for condition node parameter values. + * + * @since 1.0.0 + * @param string $value Raw condition value. + * @param array $log Passed by reference — masked items appended here. + * @return string Possibly masked value. + */ + public static function apply_condition_heuristic( string $value, array &$log ): string { + $len = strlen( $value ); + + // Must be 8–512 chars. + if ( $len < 8 || $len > 512 ) { + return $value; + } + + // Safe-list check. + if ( in_array( strtolower( $value ), self::SAFE_CONDITION_VALUES, true ) ) { + return $value; + } + + // n8n expression check. + if ( str_starts_with( $value, '={{' ) && str_ends_with( $value, '}}' ) ) { + return $value; + } + + // ISO date check. + if ( preg_match( '/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?/', $value ) ) { + return $value; + } + + // Entropy check: UUID-shaped, or mixed case+digits, or long with no spaces. + $is_uuid = (bool) preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $value ); + $is_complex = (bool) preg_match( '/[A-Z]/', $value ) + && (bool) preg_match( '/[a-z]/', $value ) + && (bool) preg_match( '/[0-9]/', $value ); + $is_long_no_space = $len > 20 && ! str_contains( $value, ' ' ); + + if ( $is_uuid || $is_complex || $is_long_no_space ) { + $log[] = array( + 'reason' => 'condition_heuristic', + 'key' => 'rightValue', + 'note' => 'Value matches entropy heuristic for potential secret.', + ); + return '[REDACTED]'; + } + + return $value; + } +} diff --git a/includes/Security/WorkflowSanitizer.php b/includes/Security/WorkflowSanitizer.php new file mode 100644 index 0000000..eb5a6ad --- /dev/null +++ b/includes/Security/WorkflowSanitizer.php @@ -0,0 +1,160 @@ +> + */ + private array $mask_log = array(); + + /** + * Processes (sanitizes + masks) a validated workflow data array. + * + * @param array $data Validated workflow data from WorkflowValidator. + * @return array{ data: array, mask_log: array } Processed data and mask log. + */ + public function process( array $data ): array { + $this->mask_log = array(); + + // Pass 1: Sanitize all strings. + $sanitized = $this->sanitize_recursive( $data ); + + // Pass 2: Mask secrets in nodes. + if ( isset( $sanitized['nodes'] ) && is_array( $sanitized['nodes'] ) ) { + foreach ( $sanitized['nodes'] as &$node ) { + $node = $this->mask_node( $node ); + } + unset( $node ); + } + + return array( + 'data' => $sanitized, + 'mask_log' => $this->mask_log, + ); + } + + /** + * Recursively sanitizes all string values in the data. + * jsCode is preserved as-is (displayed with esc_html(), never executed). + * + * @since 1.0.0 + * @param mixed $value Value to sanitize. + * @param string $parent_key Parent array key for context. + * @return mixed Sanitized value. + */ + private function sanitize_recursive( $value, string $parent_key = '' ): mixed { + if ( is_array( $value ) ) { + $result = array(); + foreach ( $value as $key => $item ) { + $result[ $key ] = $this->sanitize_recursive( $item, (string) $key ); + } + return $result; + } + + if ( is_string( $value ) ) { + // Preserve jsCode as-is; it will be displayed with esc_html(). + if ( 'jsCode' === $parent_key ) { + return $value; + } + return sanitize_text_field( $value ); + } + + if ( is_int( $value ) || is_float( $value ) ) { + return $value; + } + + if ( is_bool( $value ) ) { + return $value; + } + + return null; + } + + /** + * Applies masking rules to a single node's parameters. + * + * @since 1.0.0 + * @param array $node Single workflow node array. + * @return array Node with masked parameter values. + */ + private function mask_node( array $node ): array { + if ( ! isset( $node['parameters'] ) || ! is_array( $node['parameters'] ) ) { + return $node; + } + + $node['parameters'] = $this->mask_parameters_recursive( $node['parameters'] ); + return $node; + } + + /** + * Recursively applies masking rules to parameter values. + * + * @since 1.0.0 + * @param array $params Parameters array to process. + * @return array Parameters with sensitive values masked. + */ + private function mask_parameters_recursive( array $params ): array { + foreach ( $params as $key => &$value ) { + if ( is_string( $value ) && 'jsCode' !== $key ) { + $value = MaskingRules::apply( $value, (string) $key, $this->mask_log ); + + // Condition rightValue heuristic. + if ( 'rightValue' === $key && '[REDACTED]' !== $value ) { + $value = MaskingRules::apply_condition_heuristic( $value, $this->mask_log ); + } + } elseif ( is_array( $value ) ) { + // {name, value} pair pattern — e.g. HTTP header/body parameters. + // If the 'name' field indicates a sensitive header, mask its 'value'. + if ( isset( $value['name'], $value['value'] ) && is_string( $value['value'] ) ) { + $this->mask_name_value_pair( $value ); + } + $value = $this->mask_parameters_recursive( $value ); + } + } + unset( $value ); + return $params; + } + + /** + * Masks the 'value' of a {name, value} pair when the name implies sensitive data. + * Covers HTTP headers like Authorization, API-Key, X-Auth-Token, etc. + * + * @param array $item Passed by reference — modifies $item['value'] in place. + */ + private function mask_name_value_pair( array &$item ): void { + if ( '[REDACTED]' === $item['value'] ) { + return; + } + + $name_lower = strtolower( (string) $item['name'] ); + $sensitive = array( 'authorization', 'token', 'api-key', 'apikey', 'x-api-key', 'x-auth', 'secret', 'password', 'bearer' ); + + foreach ( $sensitive as $keyword ) { + if ( str_contains( $name_lower, $keyword ) ) { + $this->mask_log[] = array( + 'reason' => 'sensitive_header_name', + 'key' => 'value', + 'note' => 'Parameter name "' . esc_html( $item['name'] ) . '" indicates sensitive data.', + ); + $item['value'] = '[REDACTED]'; + return; + } + } + } +} diff --git a/includes/Security/WorkflowValidator.php b/includes/Security/WorkflowValidator.php new file mode 100644 index 0000000..39ba360 --- /dev/null +++ b/includes/Security/WorkflowValidator.php @@ -0,0 +1,196 @@ + self::MAX_SIZE ) { + return new \WP_Error( + 'breznflow_too_large', + __( 'Workflow JSON exceeds the 2MB size limit.', 'breznflow' ) + ); + } + + // Check 1: JSON parse. + try { + $data = json_decode( $raw, true, 512, JSON_THROW_ON_ERROR ); + } catch ( \JsonException $e ) { + return new \WP_Error( + 'breznflow_invalid_json', + sprintf( + /* translators: %s: JSON parse error message */ + __( 'Invalid JSON: %s', 'breznflow' ), + $e->getMessage() + ) + ); + } + + // Check 2: Top-level schema. + if ( ! is_array( $data ) ) { + return new \WP_Error( 'breznflow_not_object', __( 'Workflow must be a JSON object.', 'breznflow' ) ); + } + + if ( empty( $data['name'] ) || ! is_string( $data['name'] ) ) { + return new \WP_Error( 'breznflow_missing_name', __( 'Workflow must have a non-empty "name" field.', 'breznflow' ) ); + } + + if ( ! isset( $data['nodes'] ) || ! is_array( $data['nodes'] ) || empty( $data['nodes'] ) ) { + return new \WP_Error( 'breznflow_missing_nodes', __( 'Workflow must have a non-empty "nodes" array.', 'breznflow' ) ); + } + + if ( ! isset( $data['connections'] ) || ! is_array( $data['connections'] ) ) { + return new \WP_Error( 'breznflow_missing_connections', __( 'Workflow must have a "connections" object.', 'breznflow' ) ); + } + + // Check 3: Node structure + count. + if ( count( $data['nodes'] ) > self::MAX_NODES ) { + return new \WP_Error( + 'breznflow_too_many_nodes', + sprintf( + /* translators: %d: maximum node count */ + __( 'Workflow has too many nodes (max %d).', 'breznflow' ), + self::MAX_NODES + ) + ); + } + + foreach ( $data['nodes'] as $index => $node ) { + $err = self::validate_node( $node, $index ); + if ( is_wp_error( $err ) ) { + return $err; + } + } + + // Check 4: Connection integrity. + $node_names = array_column( $data['nodes'], 'name' ); + foreach ( array_keys( $data['connections'] ) as $conn_node_name ) { + if ( ! in_array( $conn_node_name, $node_names, true ) ) { + return new \WP_Error( + 'breznflow_invalid_connection', + sprintf( + /* translators: %s: node name from connections */ + __( 'Connection references unknown node: "%s".', 'breznflow' ), + esc_html( $conn_node_name ) + ) + ); + } + } + + return $data; + } + + /** + * Validates a single node structure. + * + * @param mixed $node Node data (should be array). + * @param int $index Node index for error messages. + * @return true|\WP_Error + */ + private static function validate_node( $node, int $index ) { + if ( ! is_array( $node ) ) { + return new \WP_Error( + 'breznflow_invalid_node', + sprintf( + /* translators: %d: node index */ + __( 'Node %d must be an object.', 'breznflow' ), + $index + ) + ); + } + + // id: UUID format. + if ( empty( $node['id'] ) || ! is_string( $node['id'] ) || ! preg_match( self::UUID_PATTERN, $node['id'] ) ) { + return new \WP_Error( + 'breznflow_invalid_node_id', + sprintf( + /* translators: %d: node index */ + __( 'Node %d has an invalid or missing "id" (must be UUID format).', 'breznflow' ), + $index + ) + ); + } + + // name: non-empty string. + if ( ! isset( $node['name'] ) || ! is_string( $node['name'] ) || '' === $node['name'] ) { + return new \WP_Error( + 'breznflow_invalid_node_name', + sprintf( + /* translators: %d: node index */ + __( 'Node %d has an invalid or missing "name".', 'breznflow' ), + $index + ) + ); + } + + // type: alphanumeric + allowed chars. + if ( empty( $node['type'] ) || ! is_string( $node['type'] ) || ! preg_match( self::TYPE_PATTERN, $node['type'] ) ) { + return new \WP_Error( + 'breznflow_invalid_node_type', + sprintf( + /* translators: %d: node index */ + __( 'Node %d has an invalid or missing "type".', 'breznflow' ), + $index + ) + ); + } + + // position: [int, int]. + if ( + ! isset( $node['position'] ) || + ! is_array( $node['position'] ) || + count( $node['position'] ) < 2 || + ! is_numeric( $node['position'][0] ) || + ! is_numeric( $node['position'][1] ) + ) { + return new \WP_Error( + 'breznflow_invalid_node_position', + sprintf( + /* translators: %d: node index */ + __( 'Node %d has an invalid or missing "position" ([x, y] required).', 'breznflow' ), + $index + ) + ); + } + + // typeVersion: numeric. + if ( ! isset( $node['typeVersion'] ) || ! is_numeric( $node['typeVersion'] ) ) { + return new \WP_Error( + 'breznflow_invalid_node_version', + sprintf( + /* translators: %d: node index */ + __( 'Node %d has an invalid or missing "typeVersion".', 'breznflow' ), + $index + ) + ); + } + + return true; + } +} diff --git a/includes/Shortcode.php b/includes/Shortcode.php new file mode 100644 index 0000000..da55cf0 --- /dev/null +++ b/includes/Shortcode.php @@ -0,0 +1,317 @@ +> + */ + private static array $render_queue = array(); + + /** + * Whether frontend assets have been enqueued for this page load. + * + * @var bool + */ + private static bool $assets_enqueued = false; + + /** + * Registers the shortcode and footer hook. + * + * @since 1.0.0 + * @return void + */ + public function register(): void { + add_shortcode( 'breznflow', array( $this, 'render' ) ); + add_action( 'wp_footer', array( $this, 'output_script_data' ), 1 ); + } + + /** + * Renders the [breznflow] shortcode. + * + * @param array $atts Shortcode attributes. + * @return string HTML output. + */ + public function render( $atts ): string { + $settings = SettingsPage::get_defaults(); + $saved = get_option( 'breznflow_settings', array() ); + $settings = array_merge( $settings, $saved ); + + $atts = shortcode_atts( + array( + 'id' => 0, + 'mode' => '', + 'show_title' => '', + 'show_infobox' => '', + 'show_download' => '', + 'show_minimap' => '', + 'zoom' => '', + 'max_code_lines' => '', + 'preset' => '', + 'show_share' => '', + 'show_embed' => '', + 'show_get_json' => '', + 'theme' => '', + ), + $atts, + 'breznflow' + ); + + $post_id = (int) $atts['id']; + if ( $post_id <= 0 ) { + return ''; + } + + $post = get_post( $post_id ); + if ( ! $post || 'breznflow_workflow' !== $post->post_type || 'publish' !== $post->post_status ) { + return ''; + } + + $raw_json = get_post_meta( $post_id, '_breznflow_raw_json', true ); + if ( ! $raw_json ) { + return ''; + } + + $workflow = json_decode( $raw_json, true ); + if ( ! is_array( $workflow ) ) { + return ''; + } + + // Resolve settings from meta, overridden by shortcode attrs. + $att_mode = $atts['mode']; + $meta_mode = get_post_meta( $post_id, '_breznflow_default_mode', true ); + $mode = $att_mode ? $att_mode : ( $meta_mode ? $meta_mode : $settings['default_mode'] ); + $mode = in_array( $mode, array( 'visual', 'info', 'compact' ), true ) ? $mode : 'visual'; + $zoom = '' !== $atts['zoom'] + ? max( 10, min( 200, (int) $atts['zoom'] ) ) + : (int) get_post_meta( $post_id, '_breznflow_default_zoom', true ); + $show_title = '' !== $atts['show_title'] + ? (bool) $atts['show_title'] + : (bool) get_post_meta( $post_id, '_breznflow_show_title', true ); + $show_infobox = '' !== $atts['show_infobox'] + ? (bool) $atts['show_infobox'] + : (bool) get_post_meta( $post_id, '_breznflow_show_infobox', true ); + + // Download: shortcode can only disable if meta has it enabled (not enable if meta disables). + $meta_download = (bool) get_post_meta( $post_id, '_breznflow_show_download', true ); + $global_download = (bool) $settings['allow_download']; + $allow_download = $meta_download && $global_download; + if ( '' !== $atts['show_download'] && ! (bool) $atts['show_download'] ) { + $allow_download = false; + } + + $allow_share = (bool) $settings['allow_share']; + if ( '' !== $atts['show_share'] && ! (bool) $atts['show_share'] ) { + $allow_share = false; + } + + // Embed: dual-gate (global + per-post meta). + $meta_embed = (bool) get_post_meta( $post_id, '_breznflow_show_embed', true ); + $allow_embed = $meta_embed && (bool) $settings['allow_embed']; + if ( '' !== $atts['show_embed'] && ! (bool) $atts['show_embed'] ) { + $allow_embed = false; + } + + $allow_get_json = (bool) $settings['allow_get_json']; + if ( '' !== $atts['show_get_json'] && ! (bool) $atts['show_get_json'] ) { + $allow_get_json = false; + } + + $allowed_themes = ThemeRegistry::get_theme_ids(); + $att_theme = $atts['theme']; + $meta_theme = get_post_meta( $post_id, '_breznflow_default_theme', true ); + $theme = $att_theme ? $att_theme : ( $meta_theme ? $meta_theme : ( $settings['default_theme'] ?? 'dark' ) ); + $theme = in_array( $theme, $allowed_themes, true ) ? $theme : 'dark'; + + $meta_minimap = get_post_meta( $post_id, '_breznflow_show_minimap', true ); + $show_minimap = '' !== $atts['show_minimap'] + ? (bool) $atts['show_minimap'] + : ( '' !== $meta_minimap ? (bool) $meta_minimap : true ); + + $max_code_lines = '' !== $atts['max_code_lines'] ? max( 1, (int) $atts['max_code_lines'] ) : (int) $settings['max_code_lines']; + + // Increment view count. + ViewCounter::increment( $post_id ); + + // Enqueue assets once. + if ( ! self::$assets_enqueued ) { + $this->enqueue_assets( $settings ); + self::$assets_enqueued = true; + } + + // Queue workflow data for JS output. + self::$render_queue[] = array( + 'id' => $post_id, + 'workflow' => $workflow, + 'mode' => $mode, + 'zoom' => $zoom ? $zoom : 100, + 'autofit_threshold' => (int) ( $settings['autofit_threshold'] ?? 30 ), + 'show_title' => $show_title, + 'show_infobox' => $show_infobox, + 'show_download' => $allow_download, + 'show_minimap' => $show_minimap, + 'max_code_lines' => $max_code_lines, + 'download_label' => esc_html( $settings['download_label'] ), + 'download_url' => $allow_download ? esc_url( add_query_arg( 'breznflow_download', $post_id, home_url( '/' ) ) ) : '', + 'show_share' => $allow_share, + 'show_embed' => $allow_embed, + 'show_get_json' => $allow_get_json, + 'permalink' => $allow_share ? esc_url( get_permalink() ) : '', + 'anchor_id' => 'breznflow-' . $post_id, + 'workflow_title' => esc_html( $post->post_title ), + 'node_count' => (int) get_post_meta( $post_id, '_breznflow_node_count', true ), + 'is_ai_powered' => (bool) get_post_meta( $post_id, '_breznflow_has_ai_nodes', true ), + 'blog_name' => esc_html( get_bloginfo( 'name' ) ), + 'blog_url' => esc_url( home_url( '/' ) ), + 'embed_url' => $allow_embed ? esc_url( add_query_arg( 'breznflow_embed', $post_id, home_url( '/' ) ) ) : '', + 'theme' => $theme, + ); + + // Build HTML placeholder. + $html = ''; + + if ( $show_title ) { + $html .= '

' . esc_html( $post->post_title ) . '

'; + } + + if ( 'info' === $mode ) { + $node_summary = get_post_meta( $post_id, '_breznflow_node_summary', true ); + $node_count = (int) get_post_meta( $post_id, '_breznflow_node_count', true ); + $has_ai = (int) get_post_meta( $post_id, '_breznflow_has_ai_nodes', true ); + $categorized = array( + 'counts' => (array) json_decode( $node_summary ? $node_summary : '{}', true ), + 'has_ai' => (bool) $has_ai, + 'total' => $node_count, + ); + $html .= InfoBoxBuilder::build( $categorized ); + } else { + $html .= '
' + . '' + . '
' + . '
' + . '
'; + } + + return $html; + } + + /** + * Outputs accumulated workflow data as a single wp_localize_script call. + * Hooked to wp_footer priority 1 (before scripts). + */ + public function output_script_data(): void { + if ( empty( self::$render_queue ) ) { + return; + } + + wp_localize_script( 'breznflow-renderer', 'breznflowData', self::$render_queue ); + wp_localize_script( 'breznflow-renderer', 'breznflowIcons', NodeTypeRegistry::get_registry() ); + wp_localize_script( 'breznflow-renderer', 'breznflowI18n', self::get_js_i18n() ); + } + + /** + * Returns translatable strings for the frontend renderer JS. + * + * @return array + */ + public static function get_js_i18n(): array { + return array( + 'share' => __( 'Share', 'breznflow' ), + 'embed' => __( 'Embed', 'breznflow' ), + 'getJson' => __( 'Get JSON', 'breznflow' ), + 'copy' => __( 'Copy', 'breznflow' ), + 'copied' => __( 'Copied!', 'breznflow' ), + 'error' => __( 'Error', 'breznflow' ), + 'close' => __( 'Close', 'breznflow' ), + 'zoomIn' => __( 'Zoom in', 'breznflow' ), + 'zoomOut' => __( 'Zoom out', 'breznflow' ), + 'resetView' => __( 'Reset view', 'breznflow' ), + 'fullscreen' => __( 'Fullscreen', 'breznflow' ), + 'minimap' => __( 'Minimap', 'breznflow' ), + 'highlightInDiagram' => __( 'Highlight in diagram', 'breznflow' ), + 'articleLink' => __( 'Article Link', 'breznflow' ), + 'anchorLink' => __( 'Workflow Anchor Link', 'breznflow' ), + 'embedDesc' => __( 'Embed this workflow on any website:', 'breznflow' ), + 'optionalParams' => __( 'Optional URL parameters:', 'breznflow' ), + 'code' => __( 'Code', 'breznflow' ), + 'credential' => __( 'Credential', 'breznflow' ), + 'type' => __( 'Type', 'breznflow' ), + 'aiPowered' => __( 'AI-powered', 'breznflow' ), + 'more' => __( 'more', 'breznflow' ), + 'node' => __( 'node', 'breznflow' ), + 'nodes' => __( 'nodes', 'breznflow' ), + 'line' => __( 'line', 'breznflow' ), + 'lines' => __( 'lines', 'breznflow' ), + ); + } + + /** + * Outputs Schema.org HowTo structured data if enabled. + */ + public function maybe_output_schema(): void { + if ( ! is_singular() ) { + return; + } + + $settings = get_option( 'breznflow_settings', array() ); + if ( empty( $settings['schema_howto'] ) ) { + return; + } + + // Schema output is handled per-shortcode; here we hook for future expansion. + } + + /** + * Enqueues renderer assets. + * + * @since 1.0.0 + * @param array $settings Plugin settings array (reserved for future use). + * @return void + */ + private function enqueue_assets( array $settings ): void { + wp_enqueue_style( 'breznflow-renderer', BREZNFLOW_URL . 'assets/renderer.css', array(), BREZNFLOW_VERSION ); + wp_enqueue_script( 'breznflow-renderer', BREZNFLOW_URL . 'assets/renderer.js', array(), BREZNFLOW_VERSION, true ); + + // Enqueue built-in theme CSS files. + foreach ( ThemeRegistry::BUILTIN as $id => $name ) { + wp_enqueue_style( + 'breznflow-theme-' . $id, + ThemeRegistry::get_builtin_url( $id ), + array( 'breznflow-renderer' ), + BREZNFLOW_VERSION + ); + } + + // Output custom themes as inline CSS. + $custom_css = ThemeRegistry::get_custom_theme_css(); + if ( $custom_css ) { + wp_add_inline_style( 'breznflow-renderer', $custom_css ); + } + } +} diff --git a/languages/breznflow-de_DE.mo b/languages/breznflow-de_DE.mo new file mode 100644 index 0000000..3e54aff Binary files /dev/null and b/languages/breznflow-de_DE.mo differ diff --git a/languages/breznflow-de_DE.po b/languages/breznflow-de_DE.po new file mode 100644 index 0000000..8a1b725 --- /dev/null +++ b/languages/breznflow-de_DE.po @@ -0,0 +1,892 @@ +# German translation for BreznFlow +# Copyright (C) 2025 NoSchmarrn.dev +# This file is distributed under the same license as the BreznFlow plugin. +msgid "" +msgstr "" +"Project-Id-Version: BreznFlow 1.3.0\n" +"Report-Msgid-Bugs-To: https://noschmarrn.dev/\n" +"POT-Creation-Date: 2026-03-30T00:00:00+00:00\n" +"PO-Revision-Date: 2026-03-30T00:00:00+00:00\n" +"Last-Translator: NoSchmarrn.dev \n" +"Language-Team: German \n" +"Language: de_DE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: BreznFlow 1.3.0\n" +"X-Domain: breznflow\n" + +#. Post type labels +#: includes/PostType.php +msgid "Workflows" +msgstr "Workflows" + +#: includes/PostType.php +msgid "Workflow" +msgstr "Workflow" + +#: includes/PostType.php includes/Admin/AdminMenu.php includes/Admin/views/list.php includes/Admin/views/wizard-step-1.php +msgid "Add Workflow" +msgstr "Workflow hinzufügen" + +#: includes/PostType.php +msgid "Add New Workflow" +msgstr "Neuen Workflow hinzufügen" + +#: includes/PostType.php +msgid "Edit Workflow" +msgstr "Workflow bearbeiten" + +#: includes/PostType.php +msgid "New Workflow" +msgstr "Neuer Workflow" + +#: includes/PostType.php +msgid "View Workflow" +msgstr "Workflow ansehen" + +#: includes/PostType.php +msgid "Search Workflows" +msgstr "Workflows suchen" + +#: includes/PostType.php +msgid "No workflows found" +msgstr "Keine Workflows gefunden" + +#: includes/PostType.php +msgid "No workflows found in trash" +msgstr "Keine Workflows im Papierkorb gefunden" + +#: includes/PostType.php includes/Admin/AdminMenu.php +msgid "BreznFlow" +msgstr "BreznFlow" + +#. Taxonomy labels +#: includes/PostType.php +msgid "Workflow Categories" +msgstr "Workflow-Kategorien" + +#: includes/PostType.php +msgid "Workflow Category" +msgstr "Workflow-Kategorie" + +#: includes/PostType.php +msgid "Search Categories" +msgstr "Kategorien suchen" + +#: includes/PostType.php +msgid "All Categories" +msgstr "Alle Kategorien" + +#: includes/PostType.php +msgid "Parent Category" +msgstr "Übergeordnete Kategorie" + +#: includes/PostType.php +msgid "Parent Category:" +msgstr "Übergeordnete Kategorie:" + +#: includes/PostType.php +msgid "Edit Category" +msgstr "Kategorie bearbeiten" + +#: includes/PostType.php +msgid "Update Category" +msgstr "Kategorie aktualisieren" + +#: includes/PostType.php +msgid "Add New Category" +msgstr "Neue Kategorie hinzufügen" + +#: includes/PostType.php +msgid "New Category Name" +msgstr "Neuer Kategoriename" + +#: includes/PostType.php +msgid "Categories" +msgstr "Kategorien" + +#. Validator messages +#: includes/Security/WorkflowValidator.php +msgid "Workflow JSON exceeds the 2MB size limit." +msgstr "Workflow-JSON überschreitet das 2-MB-Limit." + +#: includes/Security/WorkflowValidator.php +#, php-format +msgid "Invalid JSON: %s" +msgstr "Ungültiges JSON: %s" + +#: includes/Security/WorkflowValidator.php +msgid "Workflow must be a JSON object." +msgstr "Der Workflow muss ein JSON-Objekt sein." + +#: includes/Security/WorkflowValidator.php +msgid "Workflow must have a non-empty \"name\" field." +msgstr "Der Workflow muss ein nicht-leeres \"name\"-Feld haben." + +#: includes/Security/WorkflowValidator.php +msgid "Workflow must have a non-empty \"nodes\" array." +msgstr "Der Workflow muss ein nicht-leeres \"nodes\"-Array haben." + +#: includes/Security/WorkflowValidator.php +msgid "Workflow must have a \"connections\" object." +msgstr "Der Workflow muss ein \"connections\"-Objekt haben." + +#: includes/Security/WorkflowValidator.php +#, php-format +msgid "Workflow has too many nodes (max %d)." +msgstr "Der Workflow hat zu viele Nodes (max. %d)." + +#: includes/Security/WorkflowValidator.php +#, php-format +msgid "Connection references unknown node: \"%s\"." +msgstr "Verbindung referenziert unbekannten Node: \"%s\"." + +#: includes/Security/WorkflowValidator.php +#, php-format +msgid "Node %d must be an object." +msgstr "Node %d muss ein Objekt sein." + +#: includes/Security/WorkflowValidator.php +#, php-format +msgid "Node %d has an invalid or missing \"id\" (must be UUID format)." +msgstr "Node %d hat eine ungültige oder fehlende \"id\" (UUID-Format erforderlich)." + +#: includes/Security/WorkflowValidator.php +#, php-format +msgid "Node %d has an invalid or missing \"name\"." +msgstr "Node %d hat einen ungültigen oder fehlenden \"name\"." + +#: includes/Security/WorkflowValidator.php +#, php-format +msgid "Node %d has an invalid or missing \"type\"." +msgstr "Node %d hat einen ungültigen oder fehlenden \"type\"." + +#: includes/Security/WorkflowValidator.php +#, php-format +msgid "Node %d has an invalid or missing \"position\" ([x, y] required)." +msgstr "Node %d hat eine ungültige oder fehlende \"position\" ([x, y] erforderlich)." + +#: includes/Security/WorkflowValidator.php +#, php-format +msgid "Node %d has an invalid or missing \"typeVersion\"." +msgstr "Node %d hat eine ungültige oder fehlende \"typeVersion\"." + +#. InfoBox +#: includes/Features/InfoBoxBuilder.php +#, php-format +msgid "%d node" +msgid_plural "%d nodes" +msgstr[0] "%d Node" +msgstr[1] "%d Nodes" + +#: includes/Features/InfoBoxBuilder.php +msgid "more type" +msgid_plural "more types" +msgstr[0] "weiterer Typ" +msgstr[1] "weitere Typen" + +#: includes/Features/InfoBoxBuilder.php includes/Shortcode.php includes/Admin/views/wizard-step-2.php +msgid "AI-powered" +msgstr "KI-gestützt" + +#. Admin menu +#: includes/Admin/AdminMenu.php +msgid "All Workflows" +msgstr "Alle Workflows" + +#: includes/Admin/AdminMenu.php includes/Admin/views/settings.php +msgid "Settings" +msgstr "Einstellungen" + +#: includes/Admin/AdminMenu.php +msgid "Themes" +msgstr "Themes" + +#. Wizard page +#: includes/Admin/WizardPage.php includes/Admin/ThemesPage.php +msgid "Insufficient permissions." +msgstr "Unzureichende Berechtigungen." + +#: includes/Admin/WizardPage.php +msgid "No JSON provided." +msgstr "Kein JSON angegeben." + +#: includes/Admin/WizardPage.php +msgid "No URL provided." +msgstr "Keine URL angegeben." + +#: includes/Admin/WizardPage.php +msgid "This URL is not allowed." +msgstr "Diese URL ist nicht erlaubt." + +#: includes/Admin/WizardPage.php +msgid "Empty response from URL." +msgstr "Leere Antwort von der URL." + +#: includes/Admin/WizardPage.php +msgid "Unnamed Node" +msgstr "Unbenannter Node" + +#: includes/Admin/WizardPage.php +msgid "Failed to create workflow post." +msgstr "Workflow-Beitrag konnte nicht erstellt werden." + +#: includes/Admin/WizardPage.php includes/Admin/views/wizard-step-2.php +msgid "Invalid workflow ID." +msgstr "Ungültige Workflow-ID." + +#: includes/Admin/WizardPage.php includes/Admin/views/wizard-step-2.php includes/Admin/views/wizard-step-3.php +msgid "Invalid workflow." +msgstr "Ungültiger Workflow." + +#: includes/Admin/WizardPage.php +msgid "Validating..." +msgstr "Wird validiert…" + +#: includes/Admin/WizardPage.php +msgid "Valid n8n workflow" +msgstr "Gültiger n8n-Workflow" + +#: includes/Admin/WizardPage.php +msgid "Invalid workflow" +msgstr "Ungültiger Workflow" + +#: includes/Admin/WizardPage.php +msgid "Fetching URL..." +msgstr "URL wird abgerufen…" + +#: includes/Admin/WizardPage.php includes/Shortcode.php includes/Admin/WorkflowListTable.php includes/Admin/views/wizard-step-2.php +msgid "Copy" +msgstr "Kopieren" + +#: includes/Admin/WizardPage.php includes/Shortcode.php +msgid "Copied!" +msgstr "Kopiert!" + +#: includes/Admin/WizardPage.php +msgid "Validate JSON" +msgstr "JSON validieren" + +#: includes/Admin/WizardPage.php includes/Admin/views/wizard-step-1.php +msgid "Fetch" +msgstr "Abrufen" + +#: includes/Admin/WizardPage.php +msgid "Fetch failed" +msgstr "Abruf fehlgeschlagen" + +#: includes/Admin/WizardPage.php +msgid "Please paste a workflow JSON first." +msgstr "Bitte zuerst ein Workflow-JSON einfügen." + +#. Workflow list table +#: includes/Admin/WorkflowListTable.php +msgid "Title" +msgstr "Titel" + +#: includes/Admin/WorkflowListTable.php +msgid "Nodes" +msgstr "Nodes" + +#: includes/Admin/WorkflowListTable.php +msgid "AI" +msgstr "KI" + +#: includes/Admin/WorkflowListTable.php +msgid "Mode" +msgstr "Modus" + +#: includes/Admin/WorkflowListTable.php +msgid "Views" +msgstr "Aufrufe" + +#: includes/Admin/WorkflowListTable.php +msgid "Shortcode" +msgstr "Shortcode" + +#: includes/Admin/WorkflowListTable.php +msgid "Date" +msgstr "Datum" + +#: includes/Admin/WorkflowListTable.php includes/Admin/views/themes.php +msgid "Delete" +msgstr "Löschen" + +#: includes/Admin/WorkflowListTable.php +msgid "Contains AI nodes" +msgstr "Enthält KI-Nodes" + +#: includes/Admin/WorkflowListTable.php +msgid "Draft" +msgstr "Entwurf" + +#: includes/Admin/WorkflowListTable.php +msgid "Edit" +msgstr "Bearbeiten" + +#. Workflow list page +#: includes/Admin/views/list.php +#, php-format +msgid "%d workflow moved to trash." +msgid_plural "%d workflows moved to trash." +msgstr[0] "%d Workflow in den Papierkorb verschoben." +msgstr[1] "%d Workflows in den Papierkorb verschoben." + +#: includes/Admin/views/list.php +#, php-format +msgid "Workflow published! Use shortcode: %s" +msgstr "Workflow veröffentlicht! Shortcode verwenden: %s" + +#: includes/Admin/views/list.php +msgid "Workflow published!" +msgstr "Workflow veröffentlicht!" + +#. Wizard step 1 +#: includes/Admin/views/wizard-step-1.php includes/Admin/views/wizard-step-2.php includes/Admin/views/wizard-step-3.php +msgid "1. Import JSON" +msgstr "1. JSON importieren" + +#: includes/Admin/views/wizard-step-1.php includes/Admin/views/wizard-step-2.php includes/Admin/views/wizard-step-3.php +msgid "2. Configure" +msgstr "2. Konfigurieren" + +#: includes/Admin/views/wizard-step-1.php includes/Admin/views/wizard-step-2.php includes/Admin/views/wizard-step-3.php +msgid "3. Preview & Publish" +msgstr "3. Vorschau & Veröffentlichen" + +#: includes/Admin/views/wizard-step-1.php +msgid "Please provide a workflow JSON." +msgstr "Bitte ein Workflow-JSON angeben." + +#: includes/Admin/views/wizard-step-1.php +msgid "Import n8n Workflow" +msgstr "n8n-Workflow importieren" + +#: includes/Admin/views/wizard-step-1.php +msgid "Import from URL (optional)" +msgstr "Von URL importieren (optional)" + +#: includes/Admin/views/wizard-step-1.php +msgid "Or paste JSON directly below." +msgstr "Oder JSON direkt unten einfügen." + +#: includes/Admin/views/wizard-step-1.php +msgid "Upload JSON file" +msgstr "JSON-Datei hochladen" + +#: includes/Admin/views/wizard-step-1.php +msgid "n8n Workflow JSON" +msgstr "n8n-Workflow-JSON" + +#: includes/Admin/views/wizard-step-1.php +msgid "Continue to Settings" +msgstr "Weiter zu Einstellungen" + +#. Wizard step 2 +#: includes/Admin/views/wizard-step-2.php +msgid "Configure Workflow" +msgstr "Workflow konfigurieren" + +#: includes/Admin/views/wizard-step-2.php +#, php-format +msgid "%d nodes" +msgstr "%d Nodes" + +#: includes/Admin/views/wizard-step-2.php +msgid "Basic Settings" +msgstr "Grundeinstellungen" + +#: includes/Admin/views/wizard-step-2.php +msgid "Workflow Title" +msgstr "Workflow-Titel" + +#: includes/Admin/views/wizard-step-2.php +msgid "Display Mode" +msgstr "Anzeigemodus" + +#: includes/Admin/views/wizard-step-2.php +msgid "Visual (diagram + infobox)" +msgstr "Visuell (Diagramm + Infobox)" + +#: includes/Admin/views/wizard-step-2.php +msgid "Info only (infobox, no diagram)" +msgstr "Nur Info (Infobox, kein Diagramm)" + +#: includes/Admin/views/wizard-step-2.php +msgid "Compact (diagram only, no toolbar)" +msgstr "Kompakt (nur Diagramm, keine Toolbar)" + +#: includes/Admin/views/wizard-step-2.php +msgid "Default Zoom" +msgstr "Standard-Zoom" + +#: includes/Admin/views/wizard-step-2.php +msgid "Display Options" +msgstr "Anzeigeoptionen" + +#: includes/Admin/views/wizard-step-2.php +msgid "Show title above diagram" +msgstr "Titel über dem Diagramm anzeigen" + +#: includes/Admin/views/wizard-step-2.php +msgid "Show node info box" +msgstr "Node-Infobox anzeigen" + +#: includes/Admin/views/wizard-step-2.php +msgid "Allow JSON download" +msgstr "JSON-Download erlauben" + +#: includes/Admin/views/wizard-step-2.php +msgid "Allow iframe embed" +msgstr "iFrame-Einbettung erlauben" + +#: includes/Admin/views/wizard-step-2.php +msgid "Show minimap button" +msgstr "Minimap-Button anzeigen" + +#: includes/Admin/views/wizard-step-2.php +msgid "Viewer Theme" +msgstr "Viewer-Theme" + +#: includes/Admin/views/wizard-step-2.php +msgid "Shortcode Preview" +msgstr "Shortcode-Vorschau" + +#: includes/Admin/views/wizard-step-2.php +msgid "Security Masking Log" +msgstr "Sicherheits-Maskierungsprotokoll" + +#: includes/Admin/views/wizard-step-2.php +#, php-format +msgid "%d item was sanitized" +msgid_plural "%d items were sanitized" +msgstr[0] "%d Eintrag wurde bereinigt" +msgstr[1] "%d Einträge wurden bereinigt" + +#: includes/Admin/views/wizard-step-2.php +msgid "← Back" +msgstr "← Zurück" + +#: includes/Admin/views/wizard-step-2.php +msgid "Continue to Preview" +msgstr "Weiter zur Vorschau" + +#. Wizard step 3 +#: includes/Admin/views/wizard-step-3.php +msgid "Preview & Publish" +msgstr "Vorschau & Veröffentlichen" + +#: includes/Admin/views/wizard-step-3.php +msgid "Note:" +msgstr "Hinweis:" + +#: includes/Admin/views/wizard-step-3.php +msgid "This workflow contains Code nodes. JavaScript code is displayed as-is and cannot be automatically scanned for hardcoded secrets. Please review the code in the node detail panel before publishing." +msgstr "Dieser Workflow enthält Code-Nodes. JavaScript-Code wird unverändert angezeigt und kann nicht automatisch auf fest codierte Geheimnisse geprüft werden. Bitte überprüfen Sie den Code im Node-Detailpanel vor der Veröffentlichung." + +#: includes/Admin/views/wizard-step-3.php +msgid "Workflow Preview" +msgstr "Workflow-Vorschau" + +#: includes/Admin/views/wizard-step-3.php +#, php-format +msgid "Showing %1$d nodes from \"%2$s\"." +msgstr "Zeige %1$d Nodes von \"%2$s\"." + +#: includes/Admin/views/wizard-step-3.php +msgid "Security Summary" +msgstr "Sicherheitsübersicht" + +#: includes/Admin/views/wizard-step-3.php +#, php-format +msgid "%d value was masked for security." +msgid_plural "%d values were masked for security." +msgstr[0] "%d Wert wurde aus Sicherheitsgründen maskiert." +msgstr[1] "%d Werte wurden aus Sicherheitsgründen maskiert." + +#: includes/Admin/views/wizard-step-3.php +msgid "Publish" +msgstr "Veröffentlichen" + +#: includes/Admin/views/wizard-step-3.php +msgid "Use the shortcode below in any post or page to embed this workflow." +msgstr "Verwenden Sie den folgenden Shortcode in einem Beitrag oder einer Seite, um diesen Workflow einzubetten." + +#: includes/Admin/views/wizard-step-3.php +msgid "← Edit Settings" +msgstr "← Einstellungen bearbeiten" + +#: includes/Admin/views/wizard-step-3.php +msgid "Publish Workflow" +msgstr "Workflow veröffentlichen" + +#. Settings page +#: includes/Admin/views/settings.php +msgid "BreznFlow Settings" +msgstr "BreznFlow-Einstellungen" + +#: includes/Admin/views/settings.php +msgid "Display Defaults" +msgstr "Anzeige-Standardwerte" + +#: includes/Admin/views/settings.php +msgid "Default Mode" +msgstr "Standardmodus" + +#: includes/Admin/views/settings.php +msgid "Visual" +msgstr "Visuell" + +#: includes/Admin/views/settings.php +msgid "Info" +msgstr "Info" + +#: includes/Admin/views/settings.php +msgid "Compact" +msgstr "Kompakt" + +#: includes/Admin/views/settings.php +msgid "Default Zoom (%)" +msgstr "Standard-Zoom (%)" + +#: includes/Admin/views/settings.php +msgid "Large Workflow Threshold" +msgstr "Schwellenwert für große Workflows" + +#: includes/Admin/views/settings.php +msgid "Workflows with this many nodes or more start zoomed in at the trigger node (0 = disabled)." +msgstr "Workflows mit dieser Anzahl an Nodes oder mehr starten hereingezoomt am Trigger-Node (0 = deaktiviert)." + +#: includes/Admin/views/settings.php +msgid "Max Code Lines" +msgstr "Max. Code-Zeilen" + +#: includes/Admin/views/settings.php +msgid "Maximum lines of code shown in node detail panel." +msgstr "Maximale Anzahl an Code-Zeilen im Node-Detailpanel." + +#: includes/Admin/views/settings.php +msgid "Show by Default" +msgstr "Standardmäßig anzeigen" + +#: includes/Admin/views/settings.php +msgid "Show workflow title" +msgstr "Workflow-Titel anzeigen" + +#: includes/Admin/views/settings.php +msgid "Workflow Theme" +msgstr "Workflow-Theme" + +#: includes/Admin/views/settings.php +msgid "Default Theme" +msgstr "Standard-Theme" + +#: includes/Admin/views/settings.php +msgid "Can be overridden per workflow or via shortcode attr theme=\"light\". Custom themes: import via BreznFlow → Themes." +msgstr "Kann pro Workflow oder per Shortcode-Attribut theme=\"light\" überschrieben werden. Eigene Themes: Import über BreznFlow → Themes." + +#: includes/Admin/views/settings.php +msgid "Action Bar" +msgstr "Aktionsleiste" + +#: includes/Admin/views/settings.php includes/Shortcode.php +msgid "Share" +msgstr "Teilen" + +#: includes/Admin/views/settings.php +msgid "Visitors can share workflow links" +msgstr "Besucher können Workflow-Links teilen" + +#: includes/Admin/views/settings.php includes/Shortcode.php +msgid "Embed" +msgstr "Einbetten" + +#: includes/Admin/views/settings.php +msgid "Allow iframe embedding (creates public embed URL)" +msgstr "iFrame-Einbettung erlauben (erstellt öffentliche Embed-URL)" + +#: includes/Admin/views/settings.php includes/Shortcode.php +msgid "Get JSON" +msgstr "JSON anzeigen" + +#: includes/Admin/views/settings.php +msgid "Visitors can view/copy workflow JSON" +msgstr "Besucher können Workflow-JSON ansehen/kopieren" + +#: includes/Admin/views/settings.php +msgid "Allow Download" +msgstr "Download erlauben" + +#: includes/Admin/views/settings.php +msgid "Allow visitors to download sanitized workflow JSON" +msgstr "Besuchern den Download des bereinigten Workflow-JSON erlauben" + +#: includes/Admin/views/settings.php +msgid "Download Button Label" +msgstr "Download-Button-Beschriftung" + +#: includes/Admin/views/settings.php +msgid "Features" +msgstr "Funktionen" + +#: includes/Admin/views/settings.php +msgid "View Counting" +msgstr "Aufrufzählung" + +#: includes/Admin/views/settings.php +msgid "Track shortcode render count per workflow" +msgstr "Shortcode-Aufrufe pro Workflow zählen" + +#: includes/Admin/views/settings.php +msgid "Related Workflows" +msgstr "Verwandte Workflows" + +#: includes/Admin/views/settings.php +msgid "Enable related workflows by shared node types" +msgstr "Verwandte Workflows anhand gemeinsamer Node-Typen aktivieren" + +#: includes/Admin/views/settings.php +msgid "Schema.org HowTo" +msgstr "Schema.org HowTo" + +#: includes/Admin/views/settings.php +msgid "Output Schema.org HowTo structured data in page head" +msgstr "Schema.org-HowTo-Strukturdaten im Seitenkopf ausgeben" + +#. Settings defaults +#: includes/Admin/SettingsPage.php +msgid "Download JSON" +msgstr "JSON herunterladen" + +#. Themes page +#: includes/Admin/views/themes.php +msgid "BreznFlow — Themes" +msgstr "BreznFlow — Themes" + +#: includes/Admin/views/themes.php +msgid "Theme imported successfully." +msgstr "Theme erfolgreich importiert." + +#: includes/Admin/views/themes.php +msgid "Theme deleted." +msgstr "Theme gelöscht." + +#: includes/Admin/views/themes.php +msgid "Invalid file type. Please upload a .breznflow.json file." +msgstr "Ungültiger Dateityp. Bitte eine .breznflow.json-Datei hochladen." + +#: includes/Admin/views/themes.php +msgid "File upload failed. Please try again." +msgstr "Datei-Upload fehlgeschlagen. Bitte erneut versuchen." + +#: includes/Admin/views/themes.php +msgid "Could not read the uploaded file." +msgstr "Die hochgeladene Datei konnte nicht gelesen werden." + +#: includes/Admin/views/themes.php +msgid "File is not valid JSON." +msgstr "Die Datei enthält kein gültiges JSON." + +#: includes/Admin/views/themes.php +msgid "Theme validation failed." +msgstr "Theme-Validierung fehlgeschlagen." + +#: includes/Admin/views/themes.php +msgid "An error occurred." +msgstr "Ein Fehler ist aufgetreten." + +#: includes/Admin/views/themes.php +msgid "Import Custom Theme" +msgstr "Eigenes Theme importieren" + +#: includes/Admin/views/themes.php +msgid "Upload a .breznflow.json file containing a valid theme definition. The file must include all 41 color tokens." +msgstr "Laden Sie eine .breznflow.json-Datei mit einer gültigen Theme-Definition hoch. Die Datei muss alle 41 Farb-Token enthalten." + +#: includes/Admin/views/themes.php +msgid "Theme File" +msgstr "Theme-Datei" + +#: includes/Admin/views/themes.php +msgid "Import Theme" +msgstr "Theme importieren" + +#: includes/Admin/views/themes.php +msgid "Built-in Themes" +msgstr "Integrierte Themes" + +#: includes/Admin/views/themes.php +msgid "Name" +msgstr "Name" + +#: includes/Admin/views/themes.php +msgid "ID" +msgstr "ID" + +#: includes/Admin/views/themes.php +msgid "Type" +msgstr "Typ" + +#: includes/Admin/views/themes.php +msgid "Built-in" +msgstr "Integriert" + +#: includes/Admin/views/themes.php +msgid "Built-in themes are read-only and updated with the plugin." +msgstr "Integrierte Themes sind schreibgeschützt und werden mit dem Plugin aktualisiert." + +#: includes/Admin/views/themes.php +msgid "Custom Themes" +msgstr "Eigene Themes" + +#: includes/Admin/views/themes.php +msgid "No custom themes imported yet." +msgstr "Noch keine eigenen Themes importiert." + +#: includes/Admin/views/themes.php +msgid "Version" +msgstr "Version" + +#: includes/Admin/views/themes.php +msgid "Actions" +msgstr "Aktionen" + +#: includes/Admin/views/themes.php +msgid "Delete this theme?" +msgstr "Dieses Theme löschen?" + +#. Theme importer validation +#: includes/Features/ThemeImporter.php +#, php-format +msgid "Missing required field: %s" +msgstr "Erforderliches Feld fehlt: %s" + +#: includes/Features/ThemeImporter.php +#, php-format +msgid "Unexpected fields: %s" +msgstr "Unerwartete Felder: %s" + +#: includes/Features/ThemeImporter.php +msgid "Theme ID must contain only lowercase letters, numbers, and hyphens." +msgstr "Die Theme-ID darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten." + +#: includes/Features/ThemeImporter.php +#, php-format +msgid "Theme ID \"%s\" is reserved for built-in themes." +msgstr "Die Theme-ID \"%s\" ist für integrierte Themes reserviert." + +#: includes/Features/ThemeImporter.php +msgid "Theme name must not be empty." +msgstr "Der Theme-Name darf nicht leer sein." + +#: includes/Features/ThemeImporter.php +msgid "Theme name must be 80 characters or fewer." +msgstr "Der Theme-Name darf maximal 80 Zeichen lang sein." + +#: includes/Features/ThemeImporter.php +msgid "Theme version must be an integer." +msgstr "Die Theme-Version muss eine Ganzzahl sein." + +#: includes/Features/ThemeImporter.php +msgid "Tokens must be an object." +msgstr "Tokens müssen ein Objekt sein." + +#: includes/Features/ThemeImporter.php +#, php-format +msgid "Unknown tokens: %s" +msgstr "Unbekannte Tokens: %s" + +#: includes/Features/ThemeImporter.php +#, php-format +msgid "Missing tokens: %s" +msgstr "Fehlende Tokens: %s" + +#: includes/Features/ThemeImporter.php +#, php-format +msgid "Invalid color value for \"%1$s\": %2$s" +msgstr "Ungültiger Farbwert für \"%1$s\": %2$s" + +#. Embed handler +#: includes/EmbedHandler.php +msgid "Source:" +msgstr "Quelle:" + +#. Frontend renderer JS strings — Share, Embed, Get JSON are defined above in settings.php context + +#: includes/Shortcode.php +msgid "Error" +msgstr "Fehler" + +#: includes/Shortcode.php +msgid "Close" +msgstr "Schließen" + +#: includes/Shortcode.php +msgid "Zoom in" +msgstr "Vergrößern" + +#: includes/Shortcode.php +msgid "Zoom out" +msgstr "Verkleinern" + +#: includes/Shortcode.php +msgid "Reset view" +msgstr "Ansicht zurücksetzen" + +#: includes/Shortcode.php +msgid "Fullscreen" +msgstr "Vollbild" + +#: includes/Shortcode.php +msgid "Minimap" +msgstr "Minimap" + +#: includes/Shortcode.php +msgid "Highlight in diagram" +msgstr "Im Diagramm hervorheben" + +#: includes/Shortcode.php +msgid "Article Link" +msgstr "Artikel-Link" + +#: includes/Shortcode.php +msgid "Workflow Anchor Link" +msgstr "Workflow-Anker-Link" + +#: includes/Shortcode.php +msgid "Embed this workflow on any website:" +msgstr "Diesen Workflow auf einer beliebigen Website einbetten:" + +#: includes/Shortcode.php +msgid "Optional URL parameters:" +msgstr "Optionale URL-Parameter:" + +#: includes/Shortcode.php +msgid "Code" +msgstr "Code" + +#: includes/Shortcode.php +msgid "Credential" +msgstr "Zugangsdaten" + +#: includes/Shortcode.php +msgid "more" +msgstr "weitere" + +#: includes/Shortcode.php +msgid "node" +msgstr "Node" + +#: includes/Shortcode.php includes/Admin/WizardPage.php +msgid "nodes" +msgstr "Nodes" + +#: includes/Shortcode.php +msgid "line" +msgstr "Zeile" + +#: includes/Shortcode.php +msgid "lines" +msgstr "Zeilen" diff --git a/languages/breznflow.pot b/languages/breznflow.pot new file mode 100644 index 0000000..6f4103b --- /dev/null +++ b/languages/breznflow.pot @@ -0,0 +1,900 @@ +# Copyright (C) 2025 NoSchmarrn.dev +# This file is distributed under the same license as the BreznFlow plugin. +msgid "" +msgstr "" +"Project-Id-Version: BreznFlow 1.3.0\n" +"Report-Msgid-Bugs-To: https://noschmarrn.dev/\n" +"POT-Creation-Date: 2026-03-30T00:00:00+00:00\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"X-Generator: BreznFlow 1.3.0\n" +"X-Domain: breznflow\n" + +#. Post type labels +#: includes/PostType.php +msgid "Workflows" +msgstr "" + +#: includes/PostType.php +msgid "Workflow" +msgstr "" + +#: includes/PostType.php includes/Admin/AdminMenu.php includes/Admin/views/list.php includes/Admin/views/wizard-step-1.php +msgid "Add Workflow" +msgstr "" + +#: includes/PostType.php +msgid "Add New Workflow" +msgstr "" + +#: includes/PostType.php +msgid "Edit Workflow" +msgstr "" + +#: includes/PostType.php +msgid "New Workflow" +msgstr "" + +#: includes/PostType.php +msgid "View Workflow" +msgstr "" + +#: includes/PostType.php +msgid "Search Workflows" +msgstr "" + +#: includes/PostType.php +msgid "No workflows found" +msgstr "" + +#: includes/PostType.php +msgid "No workflows found in trash" +msgstr "" + +#: includes/PostType.php includes/Admin/AdminMenu.php +msgid "BreznFlow" +msgstr "" + +#. Taxonomy labels +#: includes/PostType.php +msgid "Workflow Categories" +msgstr "" + +#: includes/PostType.php +msgid "Workflow Category" +msgstr "" + +#: includes/PostType.php +msgid "Search Categories" +msgstr "" + +#: includes/PostType.php +msgid "All Categories" +msgstr "" + +#: includes/PostType.php +msgid "Parent Category" +msgstr "" + +#: includes/PostType.php +msgid "Parent Category:" +msgstr "" + +#: includes/PostType.php +msgid "Edit Category" +msgstr "" + +#: includes/PostType.php +msgid "Update Category" +msgstr "" + +#: includes/PostType.php +msgid "Add New Category" +msgstr "" + +#: includes/PostType.php +msgid "New Category Name" +msgstr "" + +#: includes/PostType.php +msgid "Categories" +msgstr "" + +#. Validator messages +#: includes/Security/WorkflowValidator.php +msgid "Workflow JSON exceeds the 2MB size limit." +msgstr "" + +#: includes/Security/WorkflowValidator.php +#, php-format +msgid "Invalid JSON: %s" +msgstr "" + +#: includes/Security/WorkflowValidator.php +msgid "Workflow must be a JSON object." +msgstr "" + +#: includes/Security/WorkflowValidator.php +msgid "Workflow must have a non-empty \"name\" field." +msgstr "" + +#: includes/Security/WorkflowValidator.php +msgid "Workflow must have a non-empty \"nodes\" array." +msgstr "" + +#: includes/Security/WorkflowValidator.php +msgid "Workflow must have a \"connections\" object." +msgstr "" + +#: includes/Security/WorkflowValidator.php +#, php-format +msgid "Workflow has too many nodes (max %d)." +msgstr "" + +#: includes/Security/WorkflowValidator.php +#, php-format +msgid "Connection references unknown node: \"%s\"." +msgstr "" + +#: includes/Security/WorkflowValidator.php +#, php-format +msgid "Node %d must be an object." +msgstr "" + +#: includes/Security/WorkflowValidator.php +#, php-format +msgid "Node %d has an invalid or missing \"id\" (must be UUID format)." +msgstr "" + +#: includes/Security/WorkflowValidator.php +#, php-format +msgid "Node %d has an invalid or missing \"name\"." +msgstr "" + +#: includes/Security/WorkflowValidator.php +#, php-format +msgid "Node %d has an invalid or missing \"type\"." +msgstr "" + +#: includes/Security/WorkflowValidator.php +#, php-format +msgid "Node %d has an invalid or missing \"position\" ([x, y] required)." +msgstr "" + +#: includes/Security/WorkflowValidator.php +#, php-format +msgid "Node %d has an invalid or missing \"typeVersion\"." +msgstr "" + +#. InfoBox +#: includes/Features/InfoBoxBuilder.php +#, php-format +msgid "%d node" +msgid_plural "%d nodes" +msgstr[0] "" +msgstr[1] "" + +#: includes/Features/InfoBoxBuilder.php +msgid "more type" +msgid_plural "more types" +msgstr[0] "" +msgstr[1] "" + +#: includes/Features/InfoBoxBuilder.php includes/Shortcode.php includes/Admin/views/wizard-step-2.php +msgid "AI-powered" +msgstr "" + +#. Admin menu +#: includes/Admin/AdminMenu.php +msgid "All Workflows" +msgstr "" + +#: includes/Admin/AdminMenu.php includes/Admin/views/settings.php +msgid "Settings" +msgstr "" + +#: includes/Admin/AdminMenu.php +msgid "Themes" +msgstr "" + +#. Wizard page +#: includes/Admin/WizardPage.php includes/Admin/ThemesPage.php +msgid "Insufficient permissions." +msgstr "" + +#: includes/Admin/WizardPage.php +msgid "No JSON provided." +msgstr "" + +#: includes/Admin/WizardPage.php +msgid "No URL provided." +msgstr "" + +#: includes/Admin/WizardPage.php +msgid "This URL is not allowed." +msgstr "" + +#: includes/Admin/WizardPage.php +msgid "Empty response from URL." +msgstr "" + +#: includes/Admin/WizardPage.php +msgid "Unnamed Node" +msgstr "" + +#: includes/Admin/WizardPage.php +msgid "Failed to create workflow post." +msgstr "" + +#: includes/Admin/WizardPage.php includes/Admin/views/wizard-step-2.php +msgid "Invalid workflow ID." +msgstr "" + +#: includes/Admin/WizardPage.php includes/Admin/views/wizard-step-2.php includes/Admin/views/wizard-step-3.php +msgid "Invalid workflow." +msgstr "" + +#: includes/Admin/WizardPage.php +msgid "Validating..." +msgstr "" + +#: includes/Admin/WizardPage.php +msgid "Valid n8n workflow" +msgstr "" + +#: includes/Admin/WizardPage.php +msgid "Invalid workflow" +msgstr "" + +#: includes/Admin/WizardPage.php +msgid "Fetching URL..." +msgstr "" + +#: includes/Admin/WizardPage.php includes/Shortcode.php includes/Admin/WorkflowListTable.php includes/Admin/views/wizard-step-2.php +msgid "Copy" +msgstr "" + +#: includes/Admin/WizardPage.php includes/Shortcode.php +msgid "Copied!" +msgstr "" + +#: includes/Admin/WizardPage.php +msgid "Validate JSON" +msgstr "" + +#: includes/Admin/WizardPage.php includes/Admin/views/wizard-step-1.php +msgid "Fetch" +msgstr "" + +#: includes/Admin/WizardPage.php +msgid "Fetch failed" +msgstr "" + +#: includes/Admin/WizardPage.php +msgid "Please paste a workflow JSON first." +msgstr "" + +#. Workflow list table +#: includes/Admin/WorkflowListTable.php +msgid "Title" +msgstr "" + +#: includes/Admin/WorkflowListTable.php +msgid "Nodes" +msgstr "" + +#: includes/Admin/WorkflowListTable.php +msgid "AI" +msgstr "" + +#: includes/Admin/WorkflowListTable.php +msgid "Mode" +msgstr "" + +#: includes/Admin/WorkflowListTable.php +msgid "Views" +msgstr "" + +#: includes/Admin/WorkflowListTable.php +msgid "Shortcode" +msgstr "" + +#: includes/Admin/WorkflowListTable.php +msgid "Date" +msgstr "" + +#: includes/Admin/WorkflowListTable.php includes/Admin/views/themes.php +msgid "Delete" +msgstr "" + +#: includes/Admin/WorkflowListTable.php +msgid "Contains AI nodes" +msgstr "" + +#: includes/Admin/WorkflowListTable.php +msgid "Draft" +msgstr "" + +#: includes/Admin/WorkflowListTable.php +msgid "Edit" +msgstr "" + +#. Workflow list page +#: includes/Admin/views/list.php +#, php-format +msgid "%d workflow moved to trash." +msgid_plural "%d workflows moved to trash." +msgstr[0] "" +msgstr[1] "" + +#: includes/Admin/views/list.php +#, php-format +msgid "Workflow published! Use shortcode: %s" +msgstr "" + +#: includes/Admin/views/list.php +msgid "Workflow published!" +msgstr "" + +#. Wizard step 1 +#: includes/Admin/views/wizard-step-1.php includes/Admin/views/wizard-step-2.php includes/Admin/views/wizard-step-3.php +msgid "1. Import JSON" +msgstr "" + +#: includes/Admin/views/wizard-step-1.php includes/Admin/views/wizard-step-2.php includes/Admin/views/wizard-step-3.php +msgid "2. Configure" +msgstr "" + +#: includes/Admin/views/wizard-step-1.php includes/Admin/views/wizard-step-2.php includes/Admin/views/wizard-step-3.php +msgid "3. Preview & Publish" +msgstr "" + +#: includes/Admin/views/wizard-step-1.php +msgid "Please provide a workflow JSON." +msgstr "" + +#: includes/Admin/views/wizard-step-1.php +msgid "Import n8n Workflow" +msgstr "" + +#: includes/Admin/views/wizard-step-1.php +msgid "Import from URL (optional)" +msgstr "" + +#: includes/Admin/views/wizard-step-1.php +msgid "Or paste JSON directly below." +msgstr "" + +#: includes/Admin/views/wizard-step-1.php +msgid "Upload JSON file" +msgstr "" + +#: includes/Admin/views/wizard-step-1.php +msgid "n8n Workflow JSON" +msgstr "" + +#: includes/Admin/views/wizard-step-1.php +msgid "Continue to Settings" +msgstr "" + +#. Wizard step 2 +#: includes/Admin/views/wizard-step-2.php +msgid "Configure Workflow" +msgstr "" + +#: includes/Admin/views/wizard-step-2.php +#, php-format +msgid "%d nodes" +msgstr "" + +#: includes/Admin/views/wizard-step-2.php +msgid "Basic Settings" +msgstr "" + +#: includes/Admin/views/wizard-step-2.php +msgid "Workflow Title" +msgstr "" + +#: includes/Admin/views/wizard-step-2.php +msgid "Display Mode" +msgstr "" + +#: includes/Admin/views/wizard-step-2.php +msgid "Visual (diagram + infobox)" +msgstr "" + +#: includes/Admin/views/wizard-step-2.php +msgid "Info only (infobox, no diagram)" +msgstr "" + +#: includes/Admin/views/wizard-step-2.php +msgid "Compact (diagram only, no toolbar)" +msgstr "" + +#: includes/Admin/views/wizard-step-2.php +msgid "Default Zoom" +msgstr "" + +#: includes/Admin/views/wizard-step-2.php +msgid "Display Options" +msgstr "" + +#: includes/Admin/views/wizard-step-2.php +msgid "Show title above diagram" +msgstr "" + +#: includes/Admin/views/wizard-step-2.php +msgid "Show node info box" +msgstr "" + +#: includes/Admin/views/wizard-step-2.php +msgid "Allow JSON download" +msgstr "" + +#: includes/Admin/views/wizard-step-2.php +msgid "Allow iframe embed" +msgstr "" + +#: includes/Admin/views/wizard-step-2.php +msgid "Show minimap button" +msgstr "" + +#: includes/Admin/views/wizard-step-2.php +msgid "Viewer Theme" +msgstr "" + +#: includes/Admin/views/wizard-step-2.php +msgid "Shortcode Preview" +msgstr "" + +#: includes/Admin/views/wizard-step-2.php +msgid "Security Masking Log" +msgstr "" + +#: includes/Admin/views/wizard-step-2.php +#, php-format +msgid "%d item was sanitized" +msgid_plural "%d items were sanitized" +msgstr[0] "" +msgstr[1] "" + +#: includes/Admin/views/wizard-step-2.php +msgid "← Back" +msgstr "" + +#: includes/Admin/views/wizard-step-2.php +msgid "Continue to Preview" +msgstr "" + +#. Wizard step 3 +#: includes/Admin/views/wizard-step-3.php +msgid "Preview & Publish" +msgstr "" + +#: includes/Admin/views/wizard-step-3.php +msgid "Note:" +msgstr "" + +#: includes/Admin/views/wizard-step-3.php +msgid "This workflow contains Code nodes. JavaScript code is displayed as-is and cannot be automatically scanned for hardcoded secrets. Please review the code in the node detail panel before publishing." +msgstr "" + +#: includes/Admin/views/wizard-step-3.php +msgid "Workflow Preview" +msgstr "" + +#: includes/Admin/views/wizard-step-3.php +#, php-format +msgid "Showing %1$d nodes from \"%2$s\"." +msgstr "" + +#: includes/Admin/views/wizard-step-3.php +msgid "Security Summary" +msgstr "" + +#: includes/Admin/views/wizard-step-3.php +#, php-format +msgid "%d value was masked for security." +msgid_plural "%d values were masked for security." +msgstr[0] "" +msgstr[1] "" + +#: includes/Admin/views/wizard-step-3.php +msgid "Publish" +msgstr "" + +#: includes/Admin/views/wizard-step-3.php +msgid "Use the shortcode below in any post or page to embed this workflow." +msgstr "" + +#: includes/Admin/views/wizard-step-3.php +msgid "← Edit Settings" +msgstr "" + +#: includes/Admin/views/wizard-step-3.php +msgid "Publish Workflow" +msgstr "" + +#. Settings page +#: includes/Admin/views/settings.php +msgid "BreznFlow Settings" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Display Defaults" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Default Mode" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Visual" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Info" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Compact" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Default Zoom (%)" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Large Workflow Threshold" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Workflows with this many nodes or more start zoomed in at the trigger node (0 = disabled)." +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Max Code Lines" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Maximum lines of code shown in node detail panel." +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Show by Default" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Show workflow title" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Workflow Theme" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Default Theme" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Can be overridden per workflow or via shortcode attr theme=\"light\". Custom themes: import via BreznFlow → Themes." +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Action Bar" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Share" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Visitors can share workflow links" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Embed" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Allow iframe embedding (creates public embed URL)" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Get JSON" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Visitors can view/copy workflow JSON" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Allow Download" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Allow visitors to download sanitized workflow JSON" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Download Button Label" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Features" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "View Counting" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Track shortcode render count per workflow" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Related Workflows" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Enable related workflows by shared node types" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Schema.org HowTo" +msgstr "" + +#: includes/Admin/views/settings.php +msgid "Output Schema.org HowTo structured data in page head" +msgstr "" + +#. Settings defaults +#: includes/Admin/SettingsPage.php +msgid "Download JSON" +msgstr "" + +#. Themes page +#: includes/Admin/views/themes.php +msgid "BreznFlow — Themes" +msgstr "" + +#: includes/Admin/views/themes.php +msgid "Theme imported successfully." +msgstr "" + +#: includes/Admin/views/themes.php +msgid "Theme deleted." +msgstr "" + +#: includes/Admin/views/themes.php +msgid "Invalid file type. Please upload a .breznflow.json file." +msgstr "" + +#: includes/Admin/views/themes.php +msgid "File upload failed. Please try again." +msgstr "" + +#: includes/Admin/views/themes.php +msgid "Could not read the uploaded file." +msgstr "" + +#: includes/Admin/views/themes.php +msgid "File is not valid JSON." +msgstr "" + +#: includes/Admin/views/themes.php +msgid "Theme validation failed." +msgstr "" + +#: includes/Admin/views/themes.php +msgid "An error occurred." +msgstr "" + +#: includes/Admin/views/themes.php +msgid "Import Custom Theme" +msgstr "" + +#: includes/Admin/views/themes.php +msgid "Upload a .breznflow.json file containing a valid theme definition. The file must include all 41 color tokens." +msgstr "" + +#: includes/Admin/views/themes.php +msgid "Theme File" +msgstr "" + +#: includes/Admin/views/themes.php +msgid "Import Theme" +msgstr "" + +#: includes/Admin/views/themes.php +msgid "Built-in Themes" +msgstr "" + +#: includes/Admin/views/themes.php +msgid "Name" +msgstr "" + +#: includes/Admin/views/themes.php +msgid "ID" +msgstr "" + +#: includes/Admin/views/themes.php +msgid "Type" +msgstr "" + +#: includes/Admin/views/themes.php +msgid "Built-in" +msgstr "" + +#: includes/Admin/views/themes.php +msgid "Built-in themes are read-only and updated with the plugin." +msgstr "" + +#: includes/Admin/views/themes.php +msgid "Custom Themes" +msgstr "" + +#: includes/Admin/views/themes.php +msgid "No custom themes imported yet." +msgstr "" + +#: includes/Admin/views/themes.php +msgid "Version" +msgstr "" + +#: includes/Admin/views/themes.php +msgid "Actions" +msgstr "" + +#: includes/Admin/views/themes.php +msgid "Delete this theme?" +msgstr "" + +#. Theme importer validation +#: includes/Features/ThemeImporter.php +#, php-format +msgid "Missing required field: %s" +msgstr "" + +#: includes/Features/ThemeImporter.php +#, php-format +msgid "Unexpected fields: %s" +msgstr "" + +#: includes/Features/ThemeImporter.php +msgid "Theme ID must contain only lowercase letters, numbers, and hyphens." +msgstr "" + +#: includes/Features/ThemeImporter.php +#, php-format +msgid "Theme ID \"%s\" is reserved for built-in themes." +msgstr "" + +#: includes/Features/ThemeImporter.php +msgid "Theme name must not be empty." +msgstr "" + +#: includes/Features/ThemeImporter.php +msgid "Theme name must be 80 characters or fewer." +msgstr "" + +#: includes/Features/ThemeImporter.php +msgid "Theme version must be an integer." +msgstr "" + +#: includes/Features/ThemeImporter.php +msgid "Tokens must be an object." +msgstr "" + +#: includes/Features/ThemeImporter.php +#, php-format +msgid "Unknown tokens: %s" +msgstr "" + +#: includes/Features/ThemeImporter.php +#, php-format +msgid "Missing tokens: %s" +msgstr "" + +#: includes/Features/ThemeImporter.php +#, php-format +msgid "Invalid color value for \"%1$s\": %2$s" +msgstr "" + +#. Embed handler +#: includes/EmbedHandler.php +msgid "Source:" +msgstr "" + +#. Frontend renderer JS strings +#: includes/Shortcode.php +msgid "Share" +msgstr "" + +#: includes/Shortcode.php +msgid "Embed" +msgstr "" + +#: includes/Shortcode.php +msgid "Get JSON" +msgstr "" + +#: includes/Shortcode.php +msgid "Error" +msgstr "" + +#: includes/Shortcode.php +msgid "Close" +msgstr "" + +#: includes/Shortcode.php +msgid "Zoom in" +msgstr "" + +#: includes/Shortcode.php +msgid "Zoom out" +msgstr "" + +#: includes/Shortcode.php +msgid "Reset view" +msgstr "" + +#: includes/Shortcode.php +msgid "Fullscreen" +msgstr "" + +#: includes/Shortcode.php +msgid "Minimap" +msgstr "" + +#: includes/Shortcode.php +msgid "Highlight in diagram" +msgstr "" + +#: includes/Shortcode.php +msgid "Article Link" +msgstr "" + +#: includes/Shortcode.php +msgid "Workflow Anchor Link" +msgstr "" + +#: includes/Shortcode.php +msgid "Embed this workflow on any website:" +msgstr "" + +#: includes/Shortcode.php +msgid "Optional URL parameters:" +msgstr "" + +#: includes/Shortcode.php +msgid "Code" +msgstr "" + +#: includes/Shortcode.php +msgid "Credential" +msgstr "" + +#: includes/Shortcode.php +msgid "more" +msgstr "" + +#: includes/Shortcode.php +msgid "node" +msgstr "" + +#: includes/Shortcode.php includes/Admin/WizardPage.php +msgid "nodes" +msgstr "" + +#: includes/Shortcode.php +msgid "line" +msgstr "" + +#: includes/Shortcode.php +msgid "lines" +msgstr "" diff --git a/languages/messages.mo b/languages/messages.mo new file mode 100644 index 0000000..3e54aff Binary files /dev/null and b/languages/messages.mo differ diff --git a/readme.txt b/readme.txt new file mode 100644 index 0000000..091f7ca --- /dev/null +++ b/readme.txt @@ -0,0 +1,123 @@ +=== BreznFlow === +Contributors: noschmarrn +Tags: n8n, workflow, automation, diagram, svg +Requires at least: 6.0 +Tested up to: 6.9 +Stable tag: 1.0.0 +Requires PHP: 8.0 +License: GPLv2 or later +License URI: https://www.gnu.org/licenses/gpl-2.0.html + +Visually display n8n automation workflows in posts and pages with an interactive SVG diagram, node detail panel, and automatic sensitive data masking. + +== Description == + +BreznFlow lets you embed beautiful, interactive diagrams of your n8n automation workflows into WordPress posts and pages. + +**Key Features:** + +* **Interactive SVG Diagram** — zoom, pan, and click nodes to see their configuration +* **Node Detail Panel** — click any node to see its parameters below the diagram +* **Sensitive Data Masking** — API keys, tokens, and secrets in URL parameters are automatically replaced with [REDACTED] before storage +* **Node Type Registry** — 80+ node types with brand colors and icons (OpenAI, Slack, GitHub, and more) +* **InfoBox** — shows "3x HTTP Request, 2x Code" node summary +* **AI Detection** — automatically detects and badges AI-powered workflows +* **Multiple Display Modes** — visual (diagram), info (node counts only), compact (diagram without toolbar) +* **Shortcode System** — `[breznflow id="X"]` with per-shortcode attribute overrides +* **Download Button** — let visitors download the sanitized (masked) JSON +* **3-Step Import Wizard** — paste JSON, configure display, preview and publish +* **Related Workflows** — shows similar workflows by shared node types +* **View Counting** — tracks how many times each workflow has been displayed +* **Zero Dependencies** — vanilla JavaScript, no external CDN, no tracking + +**How to use:** + +1. Go to BreznFlow → Add Workflow +2. Paste your n8n workflow JSON export (or upload a .json file) +3. Configure display settings +4. Preview the diagram and publish +5. Add `[breznflow id="X"]` to any post or page + +**Security:** + +BreznFlow never stores your raw workflow JSON. Before saving, it validates the JSON against the n8n schema, sanitizes all strings, and masks detected secrets (API keys in URL parameters, high-entropy condition values). The stored JSON is always the sanitized version. + +JavaScript code in Code nodes (`jsCode`) is displayed as plain text and is never executed in the browser. + +== Installation == + +1. Upload the `breznflow` folder to `/wp-content/plugins/` +2. Activate the plugin in WordPress admin +3. Go to BreznFlow in your admin menu +4. Add your first workflow via the 3-step wizard + +== Frequently Asked Questions == + += Where do I get a workflow JSON? = + +In n8n, open your workflow and use the menu: Workflow → Export → Download JSON. + += Are my API keys safe? = + +BreznFlow automatically detects and replaces common secret patterns (API keys in URL parameters, high-entropy condition values) with `[REDACTED]` before storing. The masking log in the wizard shows exactly what was masked and why. JavaScript code in Code nodes is NOT automatically scanned — review it manually before publishing. + += Can I embed multiple workflows on one page? = + +Yes. Use `[breznflow id="1"]`, `[breznflow id="2"]`, etc. The JavaScript is loaded only once regardless of how many shortcodes are on the page. + += What n8n version is supported? = + +The plugin was developed against n8n workflow JSON exports and supports the standard export format. It has been tested with workflows from n8n 1.x. + += Can visitors download the workflow JSON? = + +Yes, if you enable the download option. Only the sanitized (masked) JSON is available for download — never the original. + +== External Services == + +This plugin optionally connects to external services if you choose to use the "Import from URL" feature in the workflow import wizard. + += Import from URL = + +If you choose to import a workflow by pasting a URL instead of uploading or pasting JSON directly, the plugin will make an HTTP request to that URL using WordPress's built-in `wp_remote_get()` function. + +* **When:** Only when you click the "Fetch" button in the Add Workflow wizard +* **What is sent:** Only the URL you provide — no WordPress data, no user data +* **To whom:** Whatever server hosts the URL you provide +* **Privacy policy:** Depends on the server you connect to + +No data is transmitted automatically. No data is sent during normal page loads or to visitors browsing your site. + +For security, requests to private/internal network addresses (localhost, LAN ranges, cloud metadata endpoints) are blocked. + +== Screenshots == + +1. 3-step import wizard — Step 1: paste or upload your n8n JSON +2. Step 2: configure display settings and preview the shortcode +3. Step 3: live SVG preview with security masking summary +4. Frontend diagram with node detail panel open +5. Compact mode showing only the node info box +6. Workflow list in admin with shortcode copy button + +== Changelog == + += 1.0.0 = +* Interactive SVG renderer with zoom, pan, and node detail panel +* 3-step import wizard with JSON validation and sensitive data masking +* 80+ node type registry with brand colors and icons +* Shortcode `[breznflow]` with mode, zoom, and display attributes +* Auto-fit zoom for large workflows (configurable threshold) +* Minimap toggle per workflow and via shortcode attribute +* 5 built-in themes (Dark, Light, Minimal, Tech, Brezn) plus custom theme import +* Action bar with share, embed, get JSON, and download buttons +* Embed handler for standalone iframe embedding +* Download handler for sanitized JSON export +* Sensitive data masking (API keys, tokens, secrets) with mask log +* View counter and related workflows by shared node types +* Schema.org HowTo structured data support +* Zero dependencies — vanilla JavaScript, no external CDN, no tracking + +== Upgrade Notice == + += 1.0.0 = +Initial release. diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..12acd31 --- /dev/null +++ b/uninstall.php @@ -0,0 +1,56 @@ + 'breznflow_workflow', + 'post_status' => 'any', + 'posts_per_page' => -1, + 'fields' => 'ids', + ) +); + +foreach ( $breznflow_post_ids as $breznflow_post_id ) { + wp_delete_post( $breznflow_post_id, true ); +} + +// Delete plugin settings. +delete_option( 'breznflow_settings' ); + +// Delete taxonomy terms. +$breznflow_terms = get_terms( + array( + 'taxonomy' => 'breznflow_category', + 'hide_empty' => false, + ) +); + +if ( ! is_wp_error( $breznflow_terms ) ) { + foreach ( $breznflow_terms as $breznflow_term ) { + wp_delete_term( $breznflow_term->term_id, 'breznflow_category' ); + } +} + +// Flush transients. +global $wpdb; +// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- uninstall cleanup, no caching needed. +$wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", + '_transient_breznflow_%', + '_transient_timeout_breznflow_%' + ) +);