Files
JSON-Studio/json-workbench.html
T
2026-09-16 10:59:24 +02:00

2591 lines
122 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JSON Studio &amp; MariaDB Workbench</title>
<!--
============================================================
OFFLINE-BETRIEB
------------------------------------------------------------
Alle externen Bibliotheken werden zuerst aus einem lokalen
Ordner "lib/" geladen. Erst wenn das fehlschlägt, greift die
Seite auf das CDN zurück. Zum Download einfach jede Adresse
im Browser öffnen und mit "Speichern unter" unter dem
genannten Dateinamen in lib/ ablegen:
lib/papaparse.min.js
<- https://cdn.jsdelivr.net/npm/papaparse@5.4.1/papaparse.min.js
lib/xlsx.full.min.js
<- https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js
lib/js-yaml.min.js
<- https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js
lib/jsoneditor/standalone.js
<- https://cdn.jsdelivr.net/npm/vanilla-jsoneditor@3.11.0/standalone.js
Die exakt gleichen Adressen stehen auch weiter unten im
Skript in der Konstante LIBS, falls sich hier oben mal etwas
verschiebt.
WICHTIG: Der JSON-Editor ist ein ES-Modul. Browser verbieten
das Laden lokaler Module über file:// (CORS). Für den echten
Offline-Betrieb muss der Ordner deshalb über einen Webserver
ausgeliefert werden - ein beliebiges Verzeichnis in CloudPanel,
Nextcloud o. ä. genügt. Per Doppelklick aus dem Dateisystem
geöffnet, lädt der Editor weiterhin nur über das CDN.
Welche Quelle tatsächlich verwendet wurde, steht unter
"Anleitung -> Offline-Betrieb".
============================================================
-->
<script>
// Fällt der Bau der Seite aus irgendeinem Grund komplett aus (z. B.
// weil ein sehr alter Browser <script type="module"> nicht kennt),
// bleibt sonst nur ein dauerhaft leerer Ladebildschirm stehen. Diese
// Zeile schaltet ihn nach einer Wartezeit auf eine klare Fehlermeldung
// um, damit Nutzer nicht vor einem Rätsel sitzen.
setTimeout(function () {
var el = document.getElementById('bootOverlay');
if (el && el.style.display !== 'none') {
document.getElementById('bootStatus').innerHTML =
'Die Anwendung konnte nicht geladen werden.<br>' +
'Bitte einen aktuellen Browser verwenden und die Internetverbindung pr&uuml;fen.';
}
}, 15000);
</script>
<style>
:root {
--bg-color: #eef0f3;
--card-bg: #ffffff;
--panel-bg: #f6f7f9;
--text-color: #1b1c1f;
--text-muted: #6b6d74;
--border-color: #dde0e5;
--header-bg: #0b0c0e;
--header-text: #f2f2f3;
--bar-bg: #f0f1f4;
--bar-bg-active: #e6e8ec;
--bar-text: #1b1c1f;
--bar-border: #dde0e5;
--center-bg: #e7e9ed;
--modal-bg: #ffffff;
--accent: #ff7a1a;
--accent-hover: #e8690e;
--danger: #e5484d;
--danger-bg: rgba(229, 72, 77, 0.10);
--success: #2fa86b;
--success-bg: rgba(47, 168, 107, 0.10);
--info: #3b78b0;
--overlay-hover: rgba(0, 0, 0, 0.06);
--scrollbar-thumb: #c7cad1;
--shadow: 0 2px 10px rgba(20, 20, 25, 0.08);
--shadow-lg: 0 16px 40px rgba(20, 20, 25, 0.20);
}
body.dark-mode {
--bg-color: #0a0a0c;
--card-bg: #16171a;
--panel-bg: #1c1d21;
--text-color: #e7e8ea;
--text-muted: #8b8d93;
--border-color: #2a2b30;
--header-bg: #000000;
--header-text: #f2f2f3;
--bar-bg: #1c1d21;
--bar-bg-active: #232429;
--bar-text: #e7e8ea;
--bar-border: #2a2b30;
--center-bg: #131316;
--modal-bg: #17181b;
--info: #6cb6ff;
--overlay-hover: rgba(255, 255, 255, 0.07);
--scrollbar-thumb: #3a3b40;
--shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 20px 48px rgba(0, 0, 0, 0.55);
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; height: 100%; width: 100%;
font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-color); color: var(--text-color);
overflow: hidden; transition: background-color 0.2s ease, color 0.2s ease;
}
* { scrollbar-width: thin; scrollbar-color: var(--scrollbar-thumb) transparent; }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb { background-color: var(--scrollbar-thumb); border-radius: 8px; border: 2px solid transparent; background-clip: content-box; }
a { color: inherit; }
code { font-family: "SFMono-Regular", Consolas, monospace; background: var(--overlay-hover); padding: 1px 5px; border-radius: 4px; font-size: 0.92em; }
.eyebrow {
font-size: 11px; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--text-muted);
}
#pageWrapper { display: flex; flex-direction: column; height: 100vh; padding: 12px; gap: 10px; }
/* Header */
.main-header {
background-color: var(--header-bg); color: var(--header-text);
padding: 10px 16px; border-radius: 10px; border-bottom: 2px solid var(--accent);
display: flex; justify-content: space-between; align-items: center;
gap: 12px; box-shadow: var(--shadow); flex-wrap: wrap;
}
.main-title { font-size: 17px; font-weight: 800; letter-spacing: 0.02em; display: flex; align-items: center; gap: 10px; }
.main-title .title-json { color: var(--header-text); }
.main-title .title-studio { color: var(--accent); }
.main-title .title-sub {
font-size: 11px; font-weight: 600; color: #9a9ca3; letter-spacing: 0.06em;
text-transform: uppercase; border-left: 1px solid #35363b; padding-left: 10px; margin-left: 2px;
}
.header-actions { display: flex; align-items: center; gap: 6px; }
.icon-btn {
background: transparent; border: 1px solid #35363b; color: #c7c8cc;
width: 34px; height: 34px; border-radius: 8px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 15px; line-height: 1; padding: 0; flex-shrink: 0;
}
.icon-btn:hover { border-color: var(--accent); color: var(--accent); background: rgba(255,255,255,0.04); }
.icon-btn.is-on { border-color: var(--accent); color: var(--accent); }
/* Main Container */
#appContainer {
display: flex; flex-direction: row; flex: 1; width: 100%; min-height: 0;
border: 1px solid var(--border-color); border-radius: 10px;
overflow: hidden; background-color: var(--card-bg); box-shadow: var(--shadow);
}
.pane {
flex: 1 1 0; height: 100%; min-width: 0;
display: flex; flex-direction: column; position: relative;
overflow: hidden; background-color: var(--card-bg);
}
/* Aktiver Editor: dezent hellere Leiste statt farbigem Balken */
.pane.is-active .top-bar { background-color: var(--bar-bg-active); }
.top-bar {
background-color: var(--bar-bg); border-bottom: 1px solid var(--bar-border);
height: 40px; display: flex; justify-content: space-between; align-items: center;
padding: 0 8px; color: var(--bar-text); font-size: 13px;
user-select: none; flex-shrink: 0; gap: 8px;
}
.doc-title-container { display: flex; align-items: center; gap: 5px; font-weight: 700; min-width: 0; }
.doc-title {
outline: none; border-bottom: 1px dashed transparent; padding: 1px 4px; border-radius: 4px;
max-width: 150px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--bar-text);
}
.doc-title:focus { background: var(--overlay-hover); border-bottom-color: var(--accent); }
.doc-title-edit { color: var(--text-muted); cursor: pointer; font-size: 12px; }
.doc-title-edit:hover { color: var(--accent); }
.bar-actions { display: flex; align-items: center; gap: 2px; min-width: 0; }
.nav-btn, .dropdown-btn {
background: transparent; border: none; color: var(--bar-text);
font-size: 12px; font-weight: 600; padding: 6px 8px; border-radius: 6px;
cursor: pointer; display: flex; align-items: center; gap: 5px; white-space: nowrap;
}
.nav-btn:hover, .dropdown-btn:hover { background-color: var(--overlay-hover); color: var(--accent); }
.dropdown { position: relative; display: inline-block; }
.dropdown-content {
display: none; position: absolute; right: 0; top: 100%; margin-top: 4px;
background-color: var(--modal-bg); border: 1px solid var(--border-color);
min-width: 260px; box-shadow: var(--shadow-lg); border-radius: 8px; z-index: 1000; padding: 6px 0;
}
.dropdown:hover .dropdown-content, .dropdown:focus-within .dropdown-content { display: block; }
.dropdown-content a, .dropdown-content label {
color: var(--text-color); padding: 8px 14px; text-decoration: none;
display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 500; cursor: pointer;
}
.dropdown-content a:hover, .dropdown-content label:hover { background-color: var(--overlay-hover); color: var(--accent); }
.dropdown-content .divider { height: 1px; background: var(--border-color); margin: 5px 0; }
.dropdown-content .group-label {
padding: 6px 14px 3px; font-size: 10px; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--text-muted);
}
/* ---------------------------------------------------------------
JSON-Editor: Farben an die App angleichen. Die interne
Werkzeugleiste (text/tree/table) benutzt --jse-theme-color als
Hintergrund und bekommt deshalb dieselben Werte wie .top-bar.
--------------------------------------------------------------- */
.editor-wrapper {
width: 100%; height: calc(100% - 40px); overflow: hidden;
--jse-theme-color: #f0f1f4 !important;
--jse-theme-color-highlight: #e4e6ea !important;
--jse-menu-color: #1b1c1f !important;
}
.jse-menu { border-bottom: 1px solid var(--bar-border) !important; padding: 0 6px !important; }
.jse-menu .jse-button, .jse-menu .jse-group-button { border-radius: 6px !important; }
.jse-menu .jse-button:hover, .jse-menu .jse-group-button:hover {
background-color: var(--overlay-hover) !important; color: var(--accent) !important;
}
.jse-menu .jse-group-button.jse-selected,
.jse-menu .jse-group-button[class*="selected"],
.jse-menu button[class*="group-button"][class*="selected"] {
background-color: var(--accent) !important; border-color: var(--accent) !important; color: #ffffff !important;
}
.jse-menu .jse-separator { background-color: var(--bar-border) !important; }
body.dark-mode .editor-wrapper,
body.dark-mode .jse-main,
body.dark-mode .jse-contents,
body.dark-mode .jse-tree-mode,
body.dark-mode .jse-text-mode,
body.dark-mode .jse-table-mode,
body.dark-mode .jse-welcome,
body.dark-mode .jse-theme-dark {
--jse-bg-color: #16171a !important;
--jse-main-border: 1px solid #2a2b30 !important;
--jse-theme-color: #1c1d21 !important;
--jse-theme-color-highlight: #24252b !important;
--jse-menu-color: #e7e8ea !important;
--jse-text-color: #e7e8ea !important;
--jse-text-color-min: #8b8d93 !important;
--jse-key-color: #6cb6ff !important;
--jse-value-color-string: #9fd6a0 !important;
--jse-value-color-number: #f7b267 !important;
--jse-value-color-boolean: #f47067 !important;
--jse-value-color-null: #8b8d93 !important;
--jse-hover-background-color: #22232a !important;
--jse-selection-background-color: #2a3340 !important;
--jse-active-line-background-color: rgba(255,255,255,0.04) !important;
--jse-panel-background: #1c1d21 !important;
--jse-panel-background-hover: #24252b !important;
--jse-panel-color: #e7e8ea !important;
--jse-panel-color-readonly: #8b8d93 !important;
--jse-panel-border: 1px solid #2a2b30 !important;
--jse-panel-button-color: #c7c8cc !important;
--jse-panel-button-color-highlight: #ffffff !important;
--jse-panel-button-background: transparent !important;
--jse-panel-button-background-highlight: rgba(255,255,255,0.08) !important;
--jse-navigation-bar-background: #1c1d21 !important;
--jse-navigation-bar-background-highlight: #24252b !important;
--jse-navigation-bar-dropdown-color: #e7e8ea !important;
--jse-context-menu-background: #1c1d21 !important;
--jse-context-menu-background-highlight: #24252b !important;
--jse-context-menu-color: #e7e8ea !important;
--jse-context-menu-pointer-background: #1c1d21 !important;
--jse-context-menu-pointer-background-highlight: #26272d !important;
--jse-context-menu-pointer-color: #e7e8ea !important;
--jse-context-menu-separator-color: #2a2b30 !important;
--jse-context-menu-hover-background-color: #26272d !important;
--jse-modal-background: #16171a !important;
--jse-modal-overlay-background: rgba(0,0,0,0.6) !important;
--jse-input-background: #101114 !important;
--jse-button-background: #26272d !important;
--jse-button-background-highlight: #33343b !important;
--jse-button-color: #e7e8ea !important;
--jse-tooltip-background: #1c1d21 !important;
--jse-tooltip-color: #e7e8ea !important;
--jse-tooltip-error-background: #3a1414 !important;
--jse-tooltip-error-color: #ff9a9a !important;
--jse-search-match-background: rgba(108,182,255,0.22) !important;
--jse-search-match-color: #e7e8ea !important;
--jse-search-match-outline: 1px solid #6cb6ff !important;
--jse-search-match-active-background: #3b78b0 !important;
--jse-search-match-active-color: #ffffff !important;
--jse-search-match-active-outline: 1px solid #8fcaff !important;
--jse-tag-background: #24252b !important;
--jse-tag-color: #e7e8ea !important;
--jse-collapsed-items-background-color: #1c1d21 !important;
--jse-collapsed-items-selected-background-color: #24252b !important;
--jse-collapsed-items-link-color: #6cb6ff !important;
--jse-collapsed-items-link-color-highlight: #8fcaff !important;
--jse-collapsed-items-selected-link-color: #ffffff !important;
--jse-delimiter-color: #55565c !important;
--jse-edit-outline: 2px solid #6cb6ff !important;
--jse-messages-info-background: #1c1d21 !important;
--jse-messages-info-color: #8fcaff !important;
--jse-messages-success-background: #123321 !important;
--jse-messages-success-color: #6bd8a1 !important;
--jse-messages-error-background: #3a1414 !important;
--jse-messages-error-color: #ff9a9a !important;
--jse-error-color: #e5484d !important;
--jse-warning-color: #f7b267 !important;
--jse-a-color: #6cb6ff !important;
background-color: #16171a !important;
color: #e7e8ea !important;
}
body.dark-mode .jse-main svg, body.dark-mode .jse-contents svg,
body.dark-mode .jse-menu svg, body.dark-mode .jse-header svg {
fill: currentColor !important; color: inherit;
}
.jse-welcome-title, .jse-welcome-description { display: none !important; }
/* Center Bar */
.center-bar {
width: 86px; min-width: 86px; background-color: var(--center-bg);
border-left: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 16px; cursor: col-resize; user-select: none; z-index: 10; flex-shrink: 0;
}
.center-section { display: flex; flex-direction: column; align-items: center; gap: 5px; width: 100%; }
.center-section .eyebrow { text-align: center; font-size: 10px; }
.btn-group { display: flex; gap: 4px; }
.center-bar button {
cursor: pointer; border: 1px solid var(--border-color);
background: var(--card-bg); color: var(--text-color);
padding: 5px 9px; border-radius: 6px; font-size: 12px; font-weight: 700;
}
.center-bar button:hover { background: var(--overlay-hover); color: var(--accent); border-color: var(--accent); }
#btnCompare { font-size: 11px; font-weight: 600; padding: 6px 9px; }
.center-hint {
font-size: 15px; color: var(--text-muted); cursor: help; line-height: 1;
}
.center-hint:hover { color: var(--accent); }
/* Modal */
.modal-overlay {
display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6);
z-index: 9999; justify-content: center; align-items: center;
}
.modal {
background: var(--modal-bg); color: var(--text-color);
border: 1px solid var(--border-color); border-radius: 10px;
width: 900px; max-width: 94vw; max-height: 88vh;
display: flex; flex-direction: column; box-shadow: var(--shadow-lg); overflow: hidden;
}
.modal-header {
padding: 14px 18px; border-bottom: 1px solid var(--border-color);
display: flex; justify-content: space-between; align-items: center;
font-weight: 700; background: var(--bar-bg);
}
.modal-close {
background: transparent; border: none; color: var(--text-muted);
font-size: 18px; cursor: pointer; line-height: 1; padding: 4px 8px; border-radius: 6px;
}
.modal-close:hover { color: var(--danger); background: var(--overlay-hover); }
.modal-body { padding: 18px; overflow-y: auto; flex: 1; font-size: 13px; }
.modal-footer {
padding: 12px 18px; border-top: 1px solid var(--border-color);
display: flex; justify-content: flex-end; gap: 8px; background: var(--bar-bg); flex-wrap: wrap;
}
.preview-table-container {
max-height: 250px; overflow: auto; margin-bottom: 16px;
border: 1px solid var(--border-color); border-radius: 8px;
}
table.sql-preview-table { width: 100%; border-collapse: collapse; font-family: "SFMono-Regular", Consolas, monospace; font-size: 12px; }
table.sql-preview-table th {
background-color: var(--accent); color: #ffffff; padding: 7px 10px;
text-align: left; position: sticky; top: 0; font-weight: 700;
}
table.sql-preview-table td { padding: 6px 10px; border-bottom: 1px solid var(--border-color); color: var(--text-color); }
table.sql-preview-table tr:nth-child(even) { background-color: var(--overlay-hover); }
pre.sql-code {
background: var(--panel-bg); padding: 14px; border-radius: 8px;
border: 1px solid var(--border-color); font-family: "SFMono-Regular", Consolas, monospace;
white-space: pre-wrap; overflow-x: auto; margin: 0;
color: var(--text-color); font-size: 12.5px; line-height: 1.5;
}
.modal-section-title {
font-weight: 700; margin-bottom: 8px; font-size: 12.5px;
padding-top: 4px;
}
.modal-hint { color: var(--text-muted); font-size: 12px; margin: -2px 0 12px; line-height: 1.6; }
.diff-row { padding: 10px 12px; margin-bottom: 8px; border-radius: 8px; background: var(--danger-bg); border-left: 3px solid var(--danger); }
.diff-row code { background: var(--overlay-hover); padding: 1px 5px; border-radius: 4px; }
.diff-empty { color: var(--success); font-weight: 700; padding: 14px; text-align: center; background: var(--success-bg); border-radius: 8px; }
.btn-action { background: var(--accent); color: #ffffff; border: none; padding: 8px 16px; border-radius: 7px; cursor: pointer; font-weight: 700; font-size: 12.5px; }
.btn-action:hover { background: var(--accent-hover); }
.btn-secondary { background: transparent; color: var(--text-color); border: 1px solid var(--border-color); padding: 8px 16px; border-radius: 7px; cursor: pointer; font-weight: 600; font-size: 12.5px; }
.btn-secondary:hover { background: var(--overlay-hover); }
.form-row { margin-bottom: 15px; }
.form-row label.field-label {
display: block; font-size: 11px; font-weight: 700; letter-spacing: 0.06em;
text-transform: uppercase; color: var(--text-muted); margin-bottom: 6px;
}
.form-row input[type="text"], .form-row input[type="url"], .form-row input[type="password"],
.form-row select, .form-row textarea {
width: 100%; padding: 8px 10px; border-radius: 7px; border: 1px solid var(--border-color);
background: var(--panel-bg); color: var(--text-color); font-size: 13px; font-family: inherit;
}
.form-row textarea { font-family: "SFMono-Regular", Consolas, monospace; min-height: 90px; resize: vertical; }
.form-row input:focus, .form-row select:focus, .form-row textarea:focus { outline: none; border-color: var(--accent); }
.form-row .field-hint { font-size: 11.5px; color: var(--text-muted); margin-top: 5px; line-height: 1.55; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
@media (max-width: 620px) { .form-grid { grid-template-columns: 1fr; } }
.list-row {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 10px 12px; border: 1px solid var(--border-color); border-radius: 8px;
margin-bottom: 8px; background: var(--panel-bg);
}
.list-meta { min-width: 0; }
.list-name { font-weight: 700; font-size: 13px; }
.list-info { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 430px; }
.list-actions { display: flex; gap: 6px; flex-shrink: 0; }
.btn-mini {
background: transparent; border: 1px solid var(--border-color); color: var(--text-color);
padding: 5px 10px; border-radius: 6px; cursor: pointer; font-size: 11.5px; font-weight: 600;
}
.btn-mini:hover { border-color: var(--accent); color: var(--accent); }
.btn-mini.danger:hover { border-color: var(--danger); color: var(--danger); }
kbd {
background: var(--panel-bg); border: 1px solid var(--border-color); border-bottom-width: 2px;
border-radius: 5px; padding: 2px 7px; font-family: "SFMono-Regular", Consolas, monospace;
font-size: 11.5px; color: var(--text-color);
}
table.shortcut-table { width: 100%; border-collapse: collapse; }
table.shortcut-table td { padding: 8px 4px; border-bottom: 1px solid var(--border-color); font-size: 13px; vertical-align: top; }
table.shortcut-table td:first-child { width: 170px; white-space: nowrap; }
/* Anleitung */
.help-nav { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
.help-tab {
background: transparent; border: 1px solid var(--border-color); color: var(--text-color);
padding: 6px 12px; border-radius: 20px; cursor: pointer; font-size: 12px; font-weight: 600;
}
.help-tab:hover { border-color: var(--accent); color: var(--accent); }
.help-tab.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.help-panel { display: none; line-height: 1.7; font-size: 13px; }
.help-panel.active { display: block; }
.help-panel h4 { margin: 18px 0 6px; font-size: 13px; }
.help-panel h4:first-child { margin-top: 0; }
.help-panel p, .help-panel li { color: var(--text-color); }
.help-panel ul { margin: 6px 0; padding-left: 20px; }
.help-panel li { margin-bottom: 5px; }
.status-ok { color: var(--success); font-weight: 700; }
.status-warn { color: var(--accent); font-weight: 700; }
#toastStack { position: fixed; bottom: 20px; right: 20px; z-index: 10000; display: flex; flex-direction: column; gap: 8px; align-items: flex-end; } .toast {
background: var(--modal-bg); color: var(--text-color); border: 1px solid var(--border-color);
border-left: 3px solid var(--accent); border-radius: 8px; padding: 10px 14px;
box-shadow: var(--shadow-lg); font-size: 13px; max-width: 360px; animation: toastIn 0.18s ease;
}
.toast.success { border-left-color: var(--success); }
.toast.error { border-left-color: var(--danger); }
@keyframes toastIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
#bootOverlay {
position: fixed; inset: 0; background: var(--bg-color); z-index: 20000;
display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 14px;
font-size: 13px; color: var(--text-muted);
}
.boot-logo { font-size: 20px; font-weight: 800; color: var(--text-color); }
.boot-logo span { color: var(--accent); }
/* Fußzeile mit den Pflichtangaben */
.legal-bar {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; flex-wrap: wrap;
font-size: 11.5px; color: var(--text-muted);
padding: 0 4px; flex-shrink: 0;
}
.legal-bar a { color: var(--text-muted); text-decoration: none; }
.legal-bar a:hover { color: var(--accent); text-decoration: underline; }
.legal-links { display: flex; gap: 14px; flex-wrap: wrap; }
@media (max-width: 1100px) {
.nav-btn span.btn-text, .dropdown-btn span.btn-text { display: none; }
}
@media (max-width: 760px) {
.main-title .title-sub { display: none; }
.doc-title { max-width: 70px; }
.center-bar { width: 62px; min-width: 62px; }
.center-section .eyebrow { display: none; }
}
</style>
</head>
<body class="dark-mode">
<div id="bootOverlay">
<div class="boot-logo">JSON<span>STUDIO</span></div>
<div id="bootStatus">Bibliotheken werden geladen&hellip;</div>
</div>
<div id="pageWrapper">
<header class="main-header">
<div class="main-title">
<span class="title-json">JSON</span><span class="title-studio">STUDIO</span>
<span class="title-sub">MariaDB Workbench</span>
</div>
<div class="header-actions">
<button class="icon-btn" onclick="openHelpModal()" title="Anleitung &ouml;ffnen">&#10068;</button>
<button class="icon-btn" onclick="openShortcutsModal()" title="Tastaturk&uuml;rzel (Strg+/)">&#9000;</button>
<button class="icon-btn" onclick="openDbModal()" title="Datenbank-Profile verwalten">&#128451;&#65039;</button>
<button class="icon-btn" onclick="openSettingsModal()" title="Einstellungen, Sicherung &amp; Wiederherstellung">&#9881;&#65039;</button>
<button class="icon-btn" id="themeToggle" onclick="cycleTheme()" title="Zwischen Dunkel- und Hellmodus wechseln">&#127769;</button>
</div>
</header>
<div id="appContainer">
<!-- LINKER EDITOR -->
<div class="pane is-active" id="leftPane" data-side="left">
<div class="top-bar">
<div class="doc-title-container">
<span class="doc-title" id="titleLeft" contenteditable="true"
title="Name des Dokuments - wird als Dateiname beim Export und als Tabellenname beim SQL-Export verwendet">Dokument 1</span>
<span class="doc-title-edit" onclick="document.getElementById('titleLeft').focus()" title="Namen bearbeiten">&#9998;</span>
</div>
<div class="bar-actions">
<button class="nav-btn" onclick="clearEditor('left')" title="Editor leeren und neu beginnen">&#128196; <span class="btn-text">Neu</span></button>
<div class="dropdown">
<button class="dropdown-btn" title="Daten aus einer Datei, URL oder Datenbank einlesen">&#128193; <span class="btn-text">&Ouml;ffnen</span> &#9662;</button>
<div class="dropdown-content">
<div class="group-label">Datei einlesen</div>
<label title="Eine JSON- oder Textdatei laden">&#128196; JSON / Text (.json, .txt)<input type="file" accept=".json,.txt" onchange="importJsonFile(event, 'left')" hidden></label>
<label title="Tabelle aus einer CSV-Datei laden">&#128202; CSV (.csv)<input type="file" accept=".csv" onchange="importCsvFile(event, 'left')" hidden></label>
<label title="Excel-Arbeitsmappe laden - mehrere Bl&auml;tter werden zu einem Objekt">&#128200; Excel (.xlsx, .xls)<input type="file" accept=".xlsx,.xls" onchange="importExcelFile(event, 'left')" hidden></label>
<label title="YAML-Datei laden, auch mit mehreren Dokumenten">&#128209; YAML (.yaml, .yml)<input type="file" accept=".yaml,.yml" onchange="importYamlFile(event, 'left')" hidden></label>
<label title="XML laden - Attribute werden zu @-Feldern">&#129521; XML (.xml)<input type="file" accept=".xml" onchange="importXmlFile(event, 'left')" hidden></label>
<label title="SQL-Dump mit INSERT-Anweisungen wieder in JSON zur&uuml;ckverwandeln">&#128451;&#65039; SQL-Dump (.sql)<input type="file" accept=".sql,.txt" onchange="importSqlFile(event, 'left')" hidden></label>
<div class="divider"></div>
<a href="#" onclick="openUrlImportModal('left'); return false;" title="Daten direkt von einer API-Adresse laden">&#127760; Von URL laden&hellip;</a>
<a href="#" onclick="openDbLoadModal('left'); return false;" title="Tabelle aus einer angebundenen Datenbank laden">&#128451;&#65039; Aus Datenbank laden&hellip;</a>
</div>
</div>
<div class="dropdown">
<button class="dropdown-btn" title="Daten als Datei speichern oder weiterverarbeiten">&#128190; <span class="btn-text">Export</span> &#9662;</button>
<div class="dropdown-content">
<div class="group-label">Datei speichern</div>
<a href="#" onclick="exportJson('left'); return false;" title="Als formatierte JSON-Datei speichern">&#128196; JSON (.json)</a>
<a href="#" onclick="exportCsv('left'); return false;" title="Als CSV speichern - Trennzeichen in den Einstellungen">&#128202; CSV (.csv)</a>
<a href="#" onclick="exportExcel('left'); return false;" title="Als Excel-Arbeitsmappe speichern">&#128200; Excel (.xlsx)</a>
<a href="#" onclick="exportYaml('left'); return false;" title="Als YAML speichern">&#128209; YAML (.yaml)</a>
<a href="#" onclick="exportXml('left'); return false;" title="Als XML speichern - Tag-Namen in den Einstellungen">&#129521; XML (.xml)</a>
<div class="divider"></div>
<div class="group-label">Erzeugen &amp; &Uuml;bertragen</div>
<a href="#" onclick="openSqlModal('left'); return false;" title="CREATE TABLE und INSERTs f&uuml;r MariaDB erzeugen">&#128451;&#65039; MariaDB Tabelle &amp; SQL</a>
<a href="#" onclick="openSchemaModal('left'); return false;" title="Aus den Daten ein JSON-Schema ableiten">&#128220; JSON-Schema ableiten</a>
<a href="#" onclick="openDbWriteModal('left'); return false;" title="Daten in eine angebundene Datenbank schreiben">&#128228; In Datenbank schreiben&hellip;</a>
</div>
</div>
<div class="dropdown">
<button class="dropdown-btn" title="Zwischenablage, Snippets und Webhook">&#128295; <span class="btn-text">Werkzeuge</span> &#9662;</button>
<div class="dropdown-content">
<div class="group-label">Zwischenablage</div>
<a href="#" onclick="copyToClip('left', 'formatted'); return false;" title="Einger&uuml;ckter JSON-Text">&#128203; Formatierter JSON-Text</a>
<a href="#" onclick="copyToClip('left', 'compact'); return false;" title="JSON ohne Leerzeichen und Umbr&uuml;che">&#128203; Kompakter JSON-Text</a>
<a href="#" onclick="copyToClip('left', 'csv'); return false;" title="Als CSV in die Zwischenablage">&#128203; Als CSV</a>
<a href="#" onclick="copyToClip('left', 'yaml'); return false;" title="Als YAML in die Zwischenablage">&#128203; Als YAML</a>
<div class="divider"></div>
<div class="group-label">Weiteres</div>
<a href="#" onclick="openSnippetModal('left'); return false;" title="H&auml;ufig gebrauchte Vorlagen speichern und einf&uuml;gen">&#11088; Snippet-Bibliothek&hellip;</a>
<a href="#" onclick="openWebhookModal('left'); return false;" title="Inhalt per POST an einen Webhook senden, z. B. n8n">&#128640; An Webhook senden&hellip;</a>
</div>
</div>
<button class="nav-btn" onclick="toggleFullscreen('leftPane')" title="Diesen Editor im Vollbild anzeigen">&#10530; <span class="btn-text">Vollbild</span></button>
</div>
</div>
<div id="jsoneditorLeft" class="editor-wrapper jse-theme-dark"></div>
</div>
<!-- CENTER RESIZER -->
<div class="center-bar" id="centerBar" title="Ziehen, um die Aufteilung zu &auml;ndern - Doppelklick setzt sie zur&uuml;ck">
<div class="center-section">
<span class="eyebrow">Kopieren</span>
<div class="btn-group">
<button id="btnCopyRightToLeft" title="Inhalt von rechts nach links kopieren (1:1)">&lt;</button>
<button id="btnCopyLeftToRight" title="Inhalt von links nach rechts kopieren (1:1)">&gt;</button>
</div>
</div>
<div class="center-section">
<span class="eyebrow">Flatten</span>
<div class="btn-group">
<button id="btnTransRightToLeft" title="Rechts verschachtelte Objekte zu flachen Feldern aufl&ouml;sen und nach links &uuml;bertragen">&lt;</button>
<button id="btnTransLeftToRight" title="Links verschachtelte Objekte zu flachen Feldern aufl&ouml;sen und nach rechts &uuml;bertragen">&gt;</button>
</div>
<span class="center-hint" onclick="openHelpModal('flatten')" title="Was macht Flatten? Hier klicken f&uuml;r die Erkl&auml;rung">&#10068;</span>
</div>
<div class="center-section">
<span class="eyebrow">Vergleich</span>
<button id="btnCompare" title="Beide Dokumente Feld f&uuml;r Feld vergleichen (Strg+Enter)">&#9744; Vergleichen</button>
</div>
</div>
<!-- RECHTER EDITOR -->
<div class="pane" id="rightPane" data-side="right">
<div class="top-bar">
<div class="doc-title-container">
<span class="doc-title" id="titleRight" contenteditable="true"
title="Name des Dokuments - wird als Dateiname beim Export und als Tabellenname beim SQL-Export verwendet">Dokument 2</span>
<span class="doc-title-edit" onclick="document.getElementById('titleRight').focus()" title="Namen bearbeiten">&#9998;</span>
</div>
<div class="bar-actions">
<button class="nav-btn" onclick="clearEditor('right')" title="Editor leeren und neu beginnen">&#128196; <span class="btn-text">Neu</span></button>
<div class="dropdown">
<button class="dropdown-btn" title="Daten aus einer Datei, URL oder Datenbank einlesen">&#128193; <span class="btn-text">&Ouml;ffnen</span> &#9662;</button>
<div class="dropdown-content">
<div class="group-label">Datei einlesen</div>
<label title="Eine JSON- oder Textdatei laden">&#128196; JSON / Text (.json, .txt)<input type="file" accept=".json,.txt" onchange="importJsonFile(event, 'right')" hidden></label>
<label title="Tabelle aus einer CSV-Datei laden">&#128202; CSV (.csv)<input type="file" accept=".csv" onchange="importCsvFile(event, 'right')" hidden></label>
<label title="Excel-Arbeitsmappe laden - mehrere Bl&auml;tter werden zu einem Objekt">&#128200; Excel (.xlsx, .xls)<input type="file" accept=".xlsx,.xls" onchange="importExcelFile(event, 'right')" hidden></label>
<label title="YAML-Datei laden, auch mit mehreren Dokumenten">&#128209; YAML (.yaml, .yml)<input type="file" accept=".yaml,.yml" onchange="importYamlFile(event, 'right')" hidden></label>
<label title="XML laden - Attribute werden zu @-Feldern">&#129521; XML (.xml)<input type="file" accept=".xml" onchange="importXmlFile(event, 'right')" hidden></label>
<label title="SQL-Dump mit INSERT-Anweisungen wieder in JSON zur&uuml;ckverwandeln">&#128451;&#65039; SQL-Dump (.sql)<input type="file" accept=".sql,.txt" onchange="importSqlFile(event, 'right')" hidden></label>
<div class="divider"></div>
<a href="#" onclick="openUrlImportModal('right'); return false;" title="Daten direkt von einer API-Adresse laden">&#127760; Von URL laden&hellip;</a>
<a href="#" onclick="openDbLoadModal('right'); return false;" title="Tabelle aus einer angebundenen Datenbank laden">&#128451;&#65039; Aus Datenbank laden&hellip;</a>
</div>
</div>
<div class="dropdown">
<button class="dropdown-btn" title="Daten als Datei speichern oder weiterverarbeiten">&#128190; <span class="btn-text">Export</span> &#9662;</button>
<div class="dropdown-content">
<div class="group-label">Datei speichern</div>
<a href="#" onclick="exportJson('right'); return false;" title="Als formatierte JSON-Datei speichern">&#128196; JSON (.json)</a>
<a href="#" onclick="exportCsv('right'); return false;" title="Als CSV speichern - Trennzeichen in den Einstellungen">&#128202; CSV (.csv)</a>
<a href="#" onclick="exportExcel('right'); return false;" title="Als Excel-Arbeitsmappe speichern">&#128200; Excel (.xlsx)</a>
<a href="#" onclick="exportYaml('right'); return false;" title="Als YAML speichern">&#128209; YAML (.yaml)</a>
<a href="#" onclick="exportXml('right'); return false;" title="Als XML speichern - Tag-Namen in den Einstellungen">&#129521; XML (.xml)</a>
<div class="divider"></div>
<div class="group-label">Erzeugen &amp; &Uuml;bertragen</div>
<a href="#" onclick="openSqlModal('right'); return false;" title="CREATE TABLE und INSERTs f&uuml;r MariaDB erzeugen">&#128451;&#65039; MariaDB Tabelle &amp; SQL</a>
<a href="#" onclick="openSchemaModal('right'); return false;" title="Aus den Daten ein JSON-Schema ableiten">&#128220; JSON-Schema ableiten</a>
<a href="#" onclick="openDbWriteModal('right'); return false;" title="Daten in eine angebundene Datenbank schreiben">&#128228; In Datenbank schreiben&hellip;</a>
</div>
</div>
<div class="dropdown">
<button class="dropdown-btn" title="Zwischenablage, Snippets und Webhook">&#128295; <span class="btn-text">Werkzeuge</span> &#9662;</button>
<div class="dropdown-content">
<div class="group-label">Zwischenablage</div>
<a href="#" onclick="copyToClip('right', 'formatted'); return false;" title="Einger&uuml;ckter JSON-Text">&#128203; Formatierter JSON-Text</a>
<a href="#" onclick="copyToClip('right', 'compact'); return false;" title="JSON ohne Leerzeichen und Umbr&uuml;che">&#128203; Kompakter JSON-Text</a>
<a href="#" onclick="copyToClip('right', 'csv'); return false;" title="Als CSV in die Zwischenablage">&#128203; Als CSV</a>
<a href="#" onclick="copyToClip('right', 'yaml'); return false;" title="Als YAML in die Zwischenablage">&#128203; Als YAML</a>
<div class="divider"></div>
<div class="group-label">Weiteres</div>
<a href="#" onclick="openSnippetModal('right'); return false;" title="H&auml;ufig gebrauchte Vorlagen speichern und einf&uuml;gen">&#11088; Snippet-Bibliothek&hellip;</a>
<a href="#" onclick="openWebhookModal('right'); return false;" title="Inhalt per POST an einen Webhook senden, z. B. n8n">&#128640; An Webhook senden&hellip;</a>
</div>
</div>
<button class="nav-btn" onclick="toggleFullscreen('rightPane')" title="Diesen Editor im Vollbild anzeigen">&#10530; <span class="btn-text">Vollbild</span></button>
</div>
</div>
<div id="jsoneditorRight" class="editor-wrapper jse-theme-dark"></div>
</div>
</div>
<!--
Fußzeile: Impressum und Datenschutz sind bei einem öffentlich
erreichbaren Angebot in Deutschland Pflicht. Die Pfade gehen davon aus,
dass das Werkzeug in einem Unterordner der Domain liegt und die
Rechtstexte im Wurzelverzeichnis - bei abweichender Struktur anpassen.
Beim reinen Offline-Betrieb auf dem eigenen Rechner kann der ganze
Block entfallen.
-->
<div class="legal-bar">
<div class="legal-links">
<a href="../impressum.html">Impressum</a>
<a href="../datenschutz.html">Datenschutz</a>
<a href="#" onclick="openHelpModal('lizenzen'); return false;">Lizenzen &amp; Quellcode</a>
<a href="#" onclick="openHelpModal(); return false;">Anleitung</a>
</div>
<div>Alle Daten bleiben in deinem Browser &middot; MIT-Lizenz</div>
</div>
</div>
<div class="modal-overlay" id="modalOverlay">
<div class="modal">
<div class="modal-header">
<span id="modalTitle">Modal</span>
<button class="modal-close" onclick="closeModal()" title="Dialog schlie&szlig;en (Esc)">&times;</button>
</div>
<div class="modal-body" id="modalContent"></div>
<div class="modal-footer" id="modalFooter"></div>
</div>
</div>
<div id="toastStack"></div>
<script type="module">
/* ===========================================================
0. Bibliotheken laden: erst lokal, sonst CDN
=========================================================== */
const LIBS = {
papa: { local: 'lib/papaparse.min.js', cdn: 'https://cdn.jsdelivr.net/npm/papaparse@5.4.1/papaparse.min.js' },
xlsx: { local: 'lib/xlsx.full.min.js', cdn: 'https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js' },
yaml: { local: 'lib/js-yaml.min.js', cdn: 'https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js' },
// Version bewusst fest angegeben statt "immer die neueste": eine
// unversionierte Adresse kann sich unangekündigt ändern und Dateien
// umbenennen oder entfernen - genau das ist einmal passiert (die
// früher separate standalone.css gibt es seit Version 3 nicht mehr,
// die Stile stecken jetzt direkt im JS-Bundle).
jseJs: { local: './lib/jsoneditor/standalone.js', cdn: 'https://cdn.jsdelivr.net/npm/vanilla-jsoneditor@3.11.0/standalone.js' }
};
window.libStatus = {};
const bootStatus = document.getElementById('bootStatus');
function loadScript(key) {
const { local, cdn } = LIBS[key];
return new Promise(resolve => {
const tryUrl = (url, source, onFail) => {
const s = document.createElement('script');
s.src = url;
s.onload = () => { window.libStatus[key] = source; resolve(source); };
s.onerror = onFail;
document.head.appendChild(s);
};
tryUrl(local, 'lokal', () => {
tryUrl(cdn, 'CDN', () => { window.libStatus[key] = 'fehlgeschlagen'; resolve('fehlgeschlagen'); });
});
});
}
bootStatus.textContent = 'Bibliotheken werden geladen\u2026';
await Promise.all([loadScript('papa'), loadScript('xlsx'), loadScript('yaml')]);
let createJSONEditor;
try {
({ createJSONEditor } = await import(LIBS.jseJs.local));
window.libStatus.jseJs = 'lokal';
} catch (e) {
try {
({ createJSONEditor } = await import(LIBS.jseJs.cdn));
window.libStatus.jseJs = 'CDN';
} catch (e2) {
window.libStatus.jseJs = 'fehlgeschlagen';
bootStatus.innerHTML = 'Der JSON-Editor konnte weder lokal noch \u00fcber das CDN geladen werden.<br>Bitte Internetverbindung oder den Ordner <code>lib/</code> pr\u00fcfen.';
throw e2;
}
}
document.getElementById('bootOverlay').style.display = 'none';
/* ===========================================================
1. Grundlagen: Toasts, Einstellungen, Modal
=========================================================== */
window.showToast = function(message, type) {
const stack = document.getElementById('toastStack');
const el = document.createElement('div');
el.className = 'toast' + (type ? ' ' + type : '');
el.textContent = message;
stack.appendChild(el);
setTimeout(() => {
el.style.transition = 'opacity 0.25s ease';
el.style.opacity = '0';
setTimeout(() => el.remove(), 250);
}, 3400);
};
const DEFAULT_SETTINGS = {
csvDelimiter: '',
csvExportDelimiter: ';',
webhookUrl: '',
xmlRootName: 'root',
xmlItemName: 'item',
splitRatio: 0.5
};
function loadSettings() {
try {
const raw = localStorage.getItem('wb_settings');
return raw ? Object.assign({}, DEFAULT_SETTINGS, JSON.parse(raw)) : Object.assign({}, DEFAULT_SETTINGS);
} catch (e) { return Object.assign({}, DEFAULT_SETTINGS); }
}
function saveSettings(s) {
window.wbSettings = s;
try { localStorage.setItem('wb_settings', JSON.stringify(s)); } catch (e) { /* ignoriert */ }
}
window.wbSettings = loadSettings();
function escapeHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
window.escapeHtml = escapeHtml;
window.openModal = function(title, bodyHtml, actions) {
document.getElementById('modalTitle').innerHTML = title;
document.getElementById('modalContent').innerHTML = bodyHtml;
const footer = document.getElementById('modalFooter');
footer.innerHTML = '';
const closeBtn = document.createElement('button');
closeBtn.className = 'btn-secondary';
closeBtn.textContent = 'Schließen';
closeBtn.onclick = window.closeModal;
footer.appendChild(closeBtn);
(actions || []).forEach(a => {
const b = document.createElement('button');
b.className = a.primary ? 'btn-action' : 'btn-secondary';
b.textContent = a.label;
if (a.title) b.title = a.title;
b.onclick = a.handler;
footer.appendChild(b);
});
document.getElementById('modalOverlay').style.display = 'flex';
};
window.closeModal = function() { document.getElementById('modalOverlay').style.display = 'none'; };
document.getElementById('modalOverlay').addEventListener('click', (e) => {
if (e.target.id === 'modalOverlay') window.closeModal();
});
/* ===========================================================
2. Editoren
=========================================================== */
function loadSavedContent(key) {
try {
const saved = localStorage.getItem(key);
return saved ? JSON.parse(saved) : { json: {} };
} catch (e) { return { json: {} }; }
}
window.saveAppState = function() {
try {
if (window.editorLeft) localStorage.setItem('wb_left_content', JSON.stringify(window.editorLeft.get()));
if (window.editorRight) localStorage.setItem('wb_right_content', JSON.stringify(window.editorRight.get()));
localStorage.setItem('wb_left_title', document.getElementById('titleLeft').innerText.trim());
localStorage.setItem('wb_right_title', document.getElementById('titleRight').innerText.trim());
localStorage.setItem('wb_dark_mode', document.body.classList.contains('dark-mode') ? 'true' : 'false');
} catch (e) { console.warn('LocalStorage Schreibfehler:', e); }
};
const savedTitleLeft = localStorage.getItem('wb_left_title');
const savedTitleRight = localStorage.getItem('wb_right_title');
if (savedTitleLeft) document.getElementById('titleLeft').innerText = savedTitleLeft;
if (savedTitleRight) document.getElementById('titleRight').innerText = savedTitleRight;
const savedDarkMode = localStorage.getItem('wb_dark_mode');
const isDarkDefault = savedDarkMode !== null ? savedDarkMode === 'true' : true;
window.editorLeft = createJSONEditor({
target: document.getElementById('jsoneditorLeft'),
props: { content: loadSavedContent('wb_left_content'), mode: 'tree', onChange: () => window.saveAppState() }
});
window.editorRight = createJSONEditor({
target: document.getElementById('jsoneditorRight'),
props: { content: loadSavedContent('wb_right_content'), mode: 'tree', onChange: () => window.saveAppState() }
});
window.activeSide = 'left';
['left', 'right'].forEach(side => {
const pane = document.getElementById(side + 'Pane');
pane.addEventListener('focusin', () => setActiveSide(side));
pane.addEventListener('mousedown', () => setActiveSide(side));
});
function setActiveSide(side) {
window.activeSide = side;
document.getElementById('leftPane').classList.toggle('is-active', side === 'left');
document.getElementById('rightPane').classList.toggle('is-active', side === 'right');
}
function ed(side) { return side === 'right' ? window.editorRight : window.editorLeft; }
function titleId(side) { return side === 'right' ? 'titleRight' : 'titleLeft'; }
function docName(side) { return document.getElementById(titleId(side)).innerText.trim() || 'daten'; }
window.ed = ed;
document.getElementById('titleLeft').addEventListener('input', () => window.saveAppState());
document.getElementById('titleRight').addEventListener('input', () => window.saveAppState());
/* ===========================================================
3. Theme
=========================================================== */
window.applyTheme = function(isDark) {
const leftElem = document.getElementById('jsoneditorLeft');
const rightElem = document.getElementById('jsoneditorRight');
const btn = document.getElementById('themeToggle');
document.body.classList.toggle('dark-mode', isDark);
leftElem.classList.toggle('jse-theme-dark', isDark);
rightElem.classList.toggle('jse-theme-dark', isDark);
// Symbol zeigt, wohin der Klick führt
btn.innerHTML = isDark ? '&#9728;&#65039;' : '&#127769;';
btn.title = isDark ? 'Zum Hellmodus wechseln' : 'Zum Dunkelmodus wechseln';
btn.classList.toggle('is-on', !isDark);
window.saveAppState();
};
window.cycleTheme = function() { window.applyTheme(!document.body.classList.contains('dark-mode')); };
window.applyTheme(isDarkDefault);
/* ===========================================================
4. Daten-Helfer
=========================================================== */
window.getJsonData = function(side) {
const editor = ed(side);
if (!editor) return null;
try {
const content = editor.get();
if (content.json !== undefined) return content.json;
if (content.text !== undefined && content.text !== '') return JSON.parse(content.text);
} catch (e) { return null; }
return null;
};
function setData(side, json) { ed(side).set({ json: json }); window.saveAppState(); }
window.setData = setData;
function toRows(data) {
let items = Array.isArray(data) ? data : [data];
if (items.length > 0 && (typeof items[0] !== 'object' || items[0] === null)) {
items = items.map((val, idx) => ({ id: idx + 1, wert: val }));
}
return items;
}
function downloadBlob(content, filename, contentType) {
const blob = new Blob([content], { type: contentType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
function readFileAsText(file, callback) {
const reader = new FileReader();
reader.onload = (e) => callback(e.target.result);
reader.readAsText(file);
}
window.clearEditor = function(side) { ed(side).set({ text: '' }); window.saveAppState(); };
window.toggleFullscreen = function(paneId) {
const elem = document.getElementById(paneId);
if (!document.fullscreenElement) elem.requestFullscreen().catch(err => window.showToast(err.message, 'error'));
else document.exitFullscreen();
};
/* ===========================================================
5. Import
=========================================================== */
window.importJsonFile = function(event, side) {
const file = event.target.files[0];
if (!file) return;
readFileAsText(file, (text) => {
try { setData(side, JSON.parse(text)); }
catch (err) { ed(side).set({ text: text }); window.saveAppState(); }
window.showToast('Geladen: ' + file.name, 'success');
});
event.target.value = '';
};
window.importCsvFile = function(event, side) {
const file = event.target.files[0];
if (!file) return;
Papa.parse(file, {
header: true, dynamicTyping: true, skipEmptyLines: true,
delimiter: window.wbSettings.csvDelimiter || '',
complete: (res) => {
setData(side, res.data);
const d = res.meta && res.meta.delimiter ? res.meta.delimiter : '?';
window.showToast('CSV importiert: ' + file.name + ' (Trennzeichen "' + d + '")', 'success');
},
error: (err) => window.showToast('CSV-Fehler: ' + err.message, 'error')
});
event.target.value = '';
};
window.importExcelFile = function(event, side) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const wb = XLSX.read(e.target.result, { type: 'array' });
if (wb.SheetNames.length > 1) {
const out = {};
wb.SheetNames.forEach(n => { out[n] = XLSX.utils.sheet_to_json(wb.Sheets[n], { defval: null }); });
setData(side, out);
window.showToast('Excel importiert: ' + file.name + ' (' + wb.SheetNames.length + ' Bl\u00e4tter)', 'success');
} else {
const name = wb.SheetNames[0];
setData(side, XLSX.utils.sheet_to_json(wb.Sheets[name], { defval: null }));
window.showToast('Excel importiert: ' + file.name + ' (' + name + ')', 'success');
}
} catch (err) { window.showToast('Excel-Fehler: ' + err.message, 'error'); }
};
reader.readAsArrayBuffer(file);
event.target.value = '';
};
window.importYamlFile = function(event, side) {
const file = event.target.files[0];
if (!file) return;
readFileAsText(file, (text) => {
try {
const docs = jsyaml.loadAll(text);
setData(side, docs.length === 1 ? docs[0] : docs);
window.showToast('YAML importiert: ' + file.name, 'success');
} catch (err) { window.showToast('YAML-Fehler: ' + err.message, 'error'); }
});
event.target.value = '';
};
window.importXmlFile = function(event, side) {
const file = event.target.files[0];
if (!file) return;
readFileAsText(file, (text) => {
try {
const doc = new DOMParser().parseFromString(text, 'application/xml');
if (doc.querySelector('parsererror')) throw new Error('XML ist nicht wohlgeformt.');
const root = doc.documentElement;
setData(side, { [root.nodeName]: xmlNodeToJson(root) });
window.showToast('XML importiert: ' + file.name, 'success');
} catch (err) { window.showToast('XML-Fehler: ' + err.message, 'error'); }
});
event.target.value = '';
};
function xmlNodeToJson(node) {
const obj = {};
let hasAttrs = false;
for (const attr of Array.from(node.attributes || [])) {
obj['@' + attr.name] = castValue(attr.value);
hasAttrs = true;
}
const elementChildren = Array.from(node.childNodes).filter(n => n.nodeType === 1);
const textContent = Array.from(node.childNodes).filter(n => n.nodeType === 3)
.map(n => n.nodeValue.trim()).join('').trim();
if (elementChildren.length === 0) {
if (!hasAttrs) return castValue(textContent);
if (textContent) obj['#text'] = castValue(textContent);
return obj;
}
elementChildren.forEach(child => {
const val = xmlNodeToJson(child);
const key = child.nodeName;
if (obj[key] === undefined) obj[key] = val;
else if (Array.isArray(obj[key])) obj[key].push(val);
else obj[key] = [obj[key], val];
});
if (textContent) obj['#text'] = castValue(textContent);
return obj;
}
function castValue(str) {
if (str === '' || str === null || str === undefined) return null;
if (str === 'true') return true;
if (str === 'false') return false;
if (/^-?\d+$/.test(str)) return parseInt(str, 10);
if (/^-?\d*\.\d+$/.test(str)) return parseFloat(str);
return str;
}
/* ---------- SQL-Dump einlesen ---------- */
window.importSqlFile = function(event, side) {
const file = event.target.files[0];
if (!file) return;
readFileAsText(file, (text) => {
try {
const tables = parseSqlDump(text);
const names = Object.keys(tables);
if (names.length === 0) throw new Error('Keine INSERT-Anweisungen gefunden.');
setData(side, names.length === 1 ? tables[names[0]] : tables);
window.showToast('SQL importiert: ' + names.length + ' Tabelle(n), ' +
names.reduce((s, n) => s + tables[n].length, 0) + ' Zeilen', 'success');
} catch (err) { window.showToast('SQL-Fehler: ' + err.message, 'error'); }
});
event.target.value = '';
};
function parseSqlDump(sql) {
const tables = {};
const createCols = {};
const createRe = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"[]?([\w$]+)[`"\]]?\s*\(([\s\S]*?)\)\s*(?:ENGINE|DEFAULT|;)/gi;
let cm;
while ((cm = createRe.exec(sql)) !== null) {
const cols = [];
splitTopLevel(cm[2], ',').forEach(line => {
const t = line.trim();
if (/^(PRIMARY|UNIQUE|KEY|INDEX|CONSTRAINT|FOREIGN|CHECK)\b/i.test(t)) return;
const m = t.match(/^[`"[]?([\w$]+)[`"\]]?\s+/);
if (m) cols.push(m[1]);
});
if (cols.length) createCols[cm[1]] = cols;
}
const insertRe = /INSERT\s+(?:IGNORE\s+)?INTO\s+[`"[]?([\w$]+)[`"\]]?\s*(\(([^)]*)\))?\s*VALUES\s*([\s\S]*?);/gi;
let im;
while ((im = insertRe.exec(sql)) !== null) {
const tableName = im[1];
let cols = null;
if (im[3]) cols = splitTopLevel(im[3], ',').map(c => c.trim().replace(/^[`"[]|[`"\]]$/g, ''));
else if (createCols[tableName]) cols = createCols[tableName];
if (!tables[tableName]) tables[tableName] = [];
splitTuples(im[4]).forEach(tuple => {
const values = splitTopLevel(tuple, ',').map(v => parseSqlValue(v.trim()));
const row = {};
if (cols) cols.forEach((c, i) => { row[c] = values[i] !== undefined ? values[i] : null; });
else values.forEach((v, i) => { row['spalte_' + (i + 1)] = v; });
tables[tableName].push(row);
});
}
return tables;
}
function splitTuples(str) {
const out = [];
let depth = 0, current = '', inStr = false, quote = '';
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (inStr) {
current += ch;
if (ch === quote) { if (str[i + 1] === quote) current += str[++i]; else inStr = false; }
else if (ch === '\\' && i + 1 < str.length) current += str[++i];
continue;
}
if (ch === "'" || ch === '"') { inStr = true; quote = ch; current += ch; continue; }
if (ch === '(') { depth++; if (depth === 1) { current = ''; continue; } }
if (ch === ')') { depth--; if (depth === 0) { out.push(current); current = ''; continue; } }
if (depth > 0) current += ch;
}
return out;
}
function splitTopLevel(str, sep) {
const out = [];
let depth = 0, current = '', inStr = false, quote = '';
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (inStr) {
current += ch;
if (ch === quote) { if (str[i + 1] === quote) current += str[++i]; else inStr = false; }
else if (ch === '\\' && i + 1 < str.length) current += str[++i];
continue;
}
if (ch === "'" || ch === '"') { inStr = true; quote = ch; current += ch; continue; }
if (ch === '(') depth++;
if (ch === ')') depth--;
if (ch === sep && depth === 0) { out.push(current); current = ''; continue; }
current += ch;
}
if (current.trim() !== '') out.push(current);
return out;
}
function parseSqlValue(raw) {
const v = raw.trim();
if (/^NULL$/i.test(v)) return null;
if (/^TRUE$/i.test(v)) return true;
if (/^FALSE$/i.test(v)) return false;
if (/^'/.test(v) || /^"/.test(v)) {
const q = v[0];
let inner = v.slice(1, -1);
inner = inner.split(q + q).join(q);
inner = inner.replace(/\\(['"\\nrt])/g, (m, c) => ({ n: '\n', r: '\r', t: '\t' }[c] || c));
const t = inner.trim();
if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) {
try { return JSON.parse(t); } catch (e) { /* bleibt Text */ }
}
return inner;
}
if (/^-?\d+$/.test(v)) return parseInt(v, 10);
if (/^-?\d*\.\d+(e[-+]?\d+)?$/i.test(v)) return parseFloat(v);
return v;
}
/* ---------- URL-Import ---------- */
window.openUrlImportModal = function(side) {
const body = `
<div class="form-row">
<label class="field-label">URL des Endpunkts</label>
<input type="url" id="urlImportInput" placeholder="https://api.example.com/daten.json">
<div class="field-hint">
Die Daten werden direkt aus dem Browser geladen. Der Server muss deshalb CORS erlauben
(Header <code>Access-Control-Allow-Origin</code>) &ndash; sonst blockiert der Browser die Anfrage.
Erkannt werden JSON, YAML, XML und CSV anhand des Inhaltstyps bzw. der Endung.
</div>
</div>
<div class="form-row">
<label class="field-label">Optionaler Authorization-Header</label>
<input type="text" id="urlImportAuth" placeholder="z. B. Bearer abc123">
<div class="field-hint">Leer lassen, wenn der Endpunkt keine Anmeldung braucht. Wird nicht gespeichert.</div>
</div>`;
window.openModal('&#127760; Von URL laden', body, [
{ label: 'Laden', primary: true, handler: () => doUrlImport(side) }
]);
setTimeout(() => document.getElementById('urlImportInput').focus(), 50);
};
async function doUrlImport(side) {
const url = document.getElementById('urlImportInput').value.trim();
const auth = document.getElementById('urlImportAuth').value.trim();
if (!url) return window.showToast('Bitte eine URL eingeben.', 'error');
window.showToast('Lade Daten\u2026');
try {
const headers = {};
if (auth) headers['Authorization'] = auth;
const res = await fetch(url, { headers });
if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + res.statusText);
const ctype = (res.headers.get('content-type') || '').toLowerCase();
const text = await res.text();
let parsed;
if (ctype.includes('json') || /\.json(\?|$)/i.test(url)) parsed = JSON.parse(text);
else if (ctype.includes('yaml') || /\.ya?ml(\?|$)/i.test(url)) {
const docs = jsyaml.loadAll(text);
parsed = docs.length === 1 ? docs[0] : docs;
} else if (ctype.includes('xml') || /\.xml(\?|$)/i.test(url)) {
const doc = new DOMParser().parseFromString(text, 'application/xml');
if (doc.querySelector('parsererror')) throw new Error('XML ist nicht wohlgeformt.');
parsed = { [doc.documentElement.nodeName]: xmlNodeToJson(doc.documentElement) };
} else if (ctype.includes('csv') || /\.csv(\?|$)/i.test(url)) {
parsed = Papa.parse(text, { header: true, dynamicTyping: true, skipEmptyLines: true,
delimiter: window.wbSettings.csvDelimiter || '' }).data;
} else {
try { parsed = JSON.parse(text); } catch (e) { parsed = jsyaml.load(text); }
}
setData(side, parsed);
window.closeModal();
window.showToast('Von URL geladen.', 'success');
} catch (err) {
window.showToast('Ladefehler: ' + err.message, 'error');
}
}
/* ===========================================================
6. Export
=========================================================== */
window.exportJson = function(side) {
const data = window.getJsonData(side);
if (data === null) return window.showToast('Kein g\u00fcltiges JSON zum Speichern.', 'error');
downloadBlob(JSON.stringify(data, null, 2), docName(side) + '.json', 'application/json');
window.showToast('Gespeichert: ' + docName(side) + '.json', 'success');
};
window.exportCsv = function(side) {
const data = window.getJsonData(side);
if (data === null) return window.showToast('Editor ist leer.', 'error');
try {
const csv = Papa.unparse(toRows(data), { delimiter: window.wbSettings.csvExportDelimiter || ',' });
downloadBlob('\ufeff' + csv, docName(side) + '.csv', 'text/csv;charset=utf-8;');
window.showToast('Gespeichert: ' + docName(side) + '.csv', 'success');
} catch (e) { window.showToast('Export-Fehler: ' + e.message, 'error'); }
};
window.exportExcel = function(side) {
const data = window.getJsonData(side);
if (data === null) return window.showToast('Editor ist leer.', 'error');
try {
const ws = XLSX.utils.json_to_sheet(toRows(data));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, (docName(side) || 'Tabelle1').slice(0, 31));
XLSX.writeFile(wb, docName(side) + '.xlsx');
window.showToast('Gespeichert: ' + docName(side) + '.xlsx', 'success');
} catch (e) { window.showToast('Excel-Export-Fehler: ' + e.message, 'error'); }
};
window.exportYaml = function(side) {
const data = window.getJsonData(side);
if (data === null) return window.showToast('Editor ist leer.', 'error');
try {
downloadBlob(jsyaml.dump(data, { indent: 2, lineWidth: 120, noRefs: true }),
docName(side) + '.yaml', 'text/yaml;charset=utf-8;');
window.showToast('Gespeichert: ' + docName(side) + '.yaml', 'success');
} catch (e) { window.showToast('YAML-Export-Fehler: ' + e.message, 'error'); }
};
window.exportXml = function(side) {
const data = window.getJsonData(side);
if (data === null) return window.showToast('Editor ist leer.', 'error');
try {
const xml = '<?xml version="1.0" encoding="UTF-8"?>\n' +
jsonToXml(data, window.wbSettings.xmlRootName || 'root', 0);
downloadBlob(xml, docName(side) + '.xml', 'application/xml;charset=utf-8;');
window.showToast('Gespeichert: ' + docName(side) + '.xml', 'success');
} catch (e) { window.showToast('XML-Export-Fehler: ' + e.message, 'error'); }
};
function xmlEscape(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function xmlSafeName(name) {
let n = String(name).replace(/[^\w.\-]/g, '_');
if (/^[^A-Za-z_]/.test(n)) n = '_' + n;
return n;
}
function jsonToXml(value, nodeName, depth) {
const pad = ' '.repeat(depth);
const itemName = window.wbSettings.xmlItemName || 'item';
const tag = xmlSafeName(nodeName);
if (value === null || value === undefined) return pad + '<' + tag + '/>\n';
if (Array.isArray(value)) {
let out = pad + '<' + tag + '>\n';
value.forEach(v => { out += jsonToXml(v, itemName, depth + 1); });
return out + pad + '</' + tag + '>\n';
}
if (typeof value === 'object') {
const attrs = [], children = [];
let textContent = null;
for (const [k, v] of Object.entries(value)) {
if (k.startsWith('@')) attrs.push(xmlSafeName(k.slice(1)) + '="' + xmlEscape(v) + '"');
else if (k === '#text') textContent = v;
else children.push([k, v]);
}
const attrStr = attrs.length ? ' ' + attrs.join(' ') : '';
if (children.length === 0) {
if (textContent === null) return pad + '<' + tag + attrStr + '/>\n';
return pad + '<' + tag + attrStr + '>' + xmlEscape(textContent) + '</' + tag + '>\n';
}
let out = pad + '<' + tag + attrStr + '>\n';
if (textContent !== null) out += ' '.repeat(depth + 1) + xmlEscape(textContent) + '\n';
children.forEach(([k, v]) => { out += jsonToXml(v, k, depth + 1); });
return out + pad + '</' + tag + '>\n';
}
return pad + '<' + tag + '>' + xmlEscape(value) + '</' + tag + '>\n';
}
window.copyToClip = function(side, format) {
const data = window.getJsonData(side);
if (data === null) return window.showToast('Nichts zum Kopieren vorhanden.', 'error');
let text = '';
try {
if (format === 'formatted') text = JSON.stringify(data, null, 2);
else if (format === 'compact') text = JSON.stringify(data);
else if (format === 'csv') text = Papa.unparse(toRows(data), { delimiter: window.wbSettings.csvExportDelimiter || ',' });
else if (format === 'yaml') text = jsyaml.dump(data, { indent: 2, noRefs: true });
} catch (e) { return window.showToast('Fehler: ' + e.message, 'error'); }
navigator.clipboard.writeText(text).then(() => window.showToast('In Zwischenablage kopiert.', 'success'));
};
/* ===========================================================
7. MariaDB-SQL erzeugen
=========================================================== */
function inferSqlType(val) {
if (typeof val === 'number') return Number.isInteger(val) ? 'INT' : 'DOUBLE';
if (typeof val === 'boolean') return 'TINYINT(1)';
if (typeof val === 'object' && val !== null) return 'JSON';
if (typeof val === 'string') {
if (!isNaN(Date.parse(val)) && val.length > 10 && val.includes('-')) return 'DATETIME';
return val.length > 255 ? 'TEXT' : 'VARCHAR(255)';
}
return 'VARCHAR(255)';
}
function formatSqlValue(val) {
if (val === undefined || val === null) return 'NULL';
if (typeof val === 'number') return val;
if (typeof val === 'boolean') return val ? 1 : 0;
if (typeof val === 'object') return `'${JSON.stringify(val).replace(/'/g, "''")}'`;
return `'${String(val).replace(/'/g, "''")}'`;
}
function buildSql(tableName, items) {
const columns = {};
items.forEach(item => {
if (typeof item === 'object' && item !== null) {
for (const [key, val] of Object.entries(item)) {
if (!columns[key]) columns[key] = inferSqlType(val);
else if (columns[key] === 'INT' && typeof val === 'number' && !Number.isInteger(val)) columns[key] = 'DOUBLE';
}
}
});
const colNames = Object.keys(columns);
let sql = `-- MariaDB Tabellenstruktur f\u00fcr \`${tableName}\`\n`;
sql += `CREATE TABLE IF NOT EXISTS \`${tableName}\` (\n`;
sql += ` \`id\` INT AUTO_INCREMENT PRIMARY KEY,\n`;
sql += colNames.map(c => ` \`${c}\` ${columns[c]}`).join(',\n');
sql += `\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n\n`;
sql += `-- Daten-Imports f\u00fcr \`${tableName}\`\n`;
items.forEach(item => {
const vals = colNames.map(c => formatSqlValue(item[c]));
sql += `INSERT INTO \`${tableName}\` (\`${colNames.join('`, `')}\`) VALUES (${vals.join(', ')});\n`;
});
return { sql, columns, colNames };
}
window.openSqlModal = function(side) {
const data = window.getJsonData(side);
if (data === null) return window.showToast('Bitte zuerst g\u00fcltige Daten in den Editor laden.', 'error');
const tableName = docName(side).toLowerCase().replace(/[^a-z0-9_]/g, '_');
const items = toRows(data);
const { sql, columns, colNames } = buildSql(tableName, items);
let htmlTable = '<div class="preview-table-container"><table class="sql-preview-table"><thead><tr><th>id (PK)</th>';
colNames.forEach(c => { htmlTable += '<th>' + escapeHtml(c) + ' (' + columns[c] + ')</th>'; });
htmlTable += '</tr></thead><tbody>';
items.forEach((item, idx) => {
htmlTable += '<tr><td>' + (idx + 1) + '</td>';
colNames.forEach(c => {
const v = item[c];
const d = (typeof v === 'object' && v !== null) ? JSON.stringify(v) : String(v === undefined || v === null ? '' : v);
htmlTable += '<td>' + escapeHtml(d) + '</td>';
});
htmlTable += '</tr>';
});
htmlTable += '</tbody></table></div>';
const body = `
<div class="modal-section-title">&#128202; Visuelle MariaDB Tabellen-Vorschau</div>
${htmlTable}
<div class="modal-section-title">&#128196; Generiertes SQL-Skript</div>
<pre class="sql-code">${escapeHtml(sql)}</pre>`;
window.openModal('&#128451;&#65039; MariaDB Workbench: ' + escapeHtml(tableName), body, [
{ label: 'Als .sql speichern', handler: () => {
downloadBlob(sql, tableName + '.sql', 'application/sql');
window.showToast('Gespeichert: ' + tableName + '.sql', 'success');
} },
{ label: 'SQL kopieren', primary: true, handler: () => {
navigator.clipboard.writeText(sql).then(() => window.showToast('SQL-Skript kopiert.', 'success'));
} }
]);
};
/* ===========================================================
8. JSON-Schema
=========================================================== */
function inferSchema(value) {
if (value === null) return { type: 'null' };
if (Array.isArray(value)) {
if (value.length === 0) return { type: 'array', items: {} };
return { type: 'array', items: mergeSchemas(value.map(inferSchema)) };
}
if (typeof value === 'object') {
const properties = {}, required = [];
for (const [k, v] of Object.entries(value)) { properties[k] = inferSchema(v); required.push(k); }
const out = { type: 'object', properties };
if (required.length) out.required = required;
return out;
}
if (typeof value === 'number') return { type: Number.isInteger(value) ? 'integer' : 'number' };
if (typeof value === 'boolean') return { type: 'boolean' };
if (typeof value === 'string') {
const s = { type: 'string' };
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)) s.format = 'date-time';
else if (/^\d{4}-\d{2}-\d{2}$/.test(value)) s.format = 'date';
else if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) s.format = 'email';
else if (/^https?:\/\/\S+$/i.test(value)) s.format = 'uri';
return s;
}
return {};
}
function mergeSchemas(schemas) {
const types = [...new Set(schemas.map(s => s.type))];
if (types.length === 1 && types[0] === 'object') {
const allProps = {}, counts = {};
schemas.forEach(s => {
Object.entries(s.properties || {}).forEach(([k, v]) => {
allProps[k] = allProps[k] ? mergeSchemas([allProps[k], v]) : v;
counts[k] = (counts[k] || 0) + 1;
});
});
const required = Object.keys(counts).filter(k => counts[k] === schemas.length);
const out = { type: 'object', properties: allProps };
if (required.length) out.required = required;
return out;
}
if (types.length === 1 && types[0] === 'array') {
const itemSchemas = schemas.map(s => s.items).filter(s => s && s.type);
return { type: 'array', items: itemSchemas.length ? mergeSchemas(itemSchemas) : {} };
}
if (types.length === 1) {
const formats = [...new Set(schemas.map(s => s.format).filter(Boolean))];
const out = { type: types[0] };
if (formats.length === 1) out.format = formats[0];
return out;
}
if (types.length === 2 && types.includes('integer') && types.includes('number')) return { type: 'number' };
return { type: types };
}
window.openSchemaModal = function(side) {
const data = window.getJsonData(side);
if (data === null) return window.showToast('Bitte zuerst g\u00fcltige Daten in den Editor laden.', 'error');
const schema = Object.assign(
{ $schema: 'https://json-schema.org/draft/2020-12/schema', title: docName(side) },
inferSchema(data)
);
const schemaText = JSON.stringify(schema, null, 2);
const otherSide = side === 'left' ? 'right' : 'left';
const body = `
<div class="modal-section-title">&#128220; Abgeleitetes JSON-Schema</div>
<div class="modal-hint">
Aus den vorhandenen Daten erzeugt (Draft 2020-12). Bei Listen werden alle Elemente ausgewertet;
als Pflichtfeld gilt nur, was in jedem Element vorkommt. Als Startpunkt gedacht &ndash;
Wertebereiche, Muster und Beschreibungen erg&auml;nzt du bei Bedarf selbst.
</div>
<pre class="sql-code">${escapeHtml(schemaText)}</pre>`;
window.openModal('&#128220; JSON-Schema: ' + escapeHtml(docName(side)), body, [
{ label: 'In anderen Editor', handler: () => {
setData(otherSide, schema); window.closeModal();
window.showToast('Schema in den anderen Editor \u00fcbertragen.', 'success');
} },
{ label: 'Als .json speichern', handler: () => {
downloadBlob(schemaText, docName(side) + '.schema.json', 'application/json');
window.showToast('Schema gespeichert.', 'success');
} },
{ label: 'Kopieren', primary: true, handler: () => {
navigator.clipboard.writeText(schemaText).then(() => window.showToast('Schema kopiert.', 'success'));
} }
]);
};
/* ===========================================================
9. Snippets
=========================================================== */
function loadSnippets() {
try { return JSON.parse(localStorage.getItem('wb_snippets') || '[]'); } catch (e) { return []; }
}
function storeSnippets(list) {
try { localStorage.setItem('wb_snippets', JSON.stringify(list)); }
catch (e) { window.showToast('Snippet konnte nicht gespeichert werden (Speicher voll?).', 'error'); }
}
window.openSnippetModal = function(side) { window.snippetSide = side; renderSnippetModal(); };
function renderSnippetModal() {
const side = window.snippetSide;
const snippets = loadSnippets();
let list;
if (snippets.length === 0) {
list = '<div class="modal-hint">Noch keine Snippets gespeichert. Sichere unten den aktuellen Editor-Inhalt als wiederverwendbare Vorlage.</div>';
} else {
list = snippets.map(s => `
<div class="list-row">
<div class="list-meta">
<div class="list-name">${escapeHtml(s.name)}</div>
<div class="list-info">${escapeHtml(s.preview)} &middot; ${s.size} Zeichen &middot; ${escapeHtml(s.date)}</div>
</div>
<div class="list-actions">
<button class="btn-mini" onclick="insertSnippet('${s.id}')" title="Diesen Inhalt in den Editor laden">Einf&uuml;gen</button>
<button class="btn-mini danger" onclick="deleteSnippet('${s.id}')" title="Snippet dauerhaft entfernen">L&ouml;schen</button>
</div>
</div>`).join('');
}
const body = `
<div class="modal-section-title">&#11088; Gespeicherte Snippets</div>
${list}
<div style="height:14px"></div>
<div class="modal-section-title">Aktuellen Inhalt sichern</div>
<div class="form-row">
<label class="field-label">Name des Snippets</label>
<input type="text" id="snippetNameInput" placeholder="z. B. Bestell-Grundger&uuml;st">
<div class="field-hint">
Wird im Browser gespeichert und steht in beiden Editoren zur Verf&uuml;gung.
Eingef&uuml;gt wird immer in &bdquo;${escapeHtml(docName(side))}&ldquo;.
</div>
</div>`;
window.openModal('&#11088; Snippet-Bibliothek', body, [
{ label: 'Aktuellen Inhalt speichern', primary: true, handler: saveCurrentAsSnippet }
]);
}
function saveCurrentAsSnippet() {
const side = window.snippetSide;
const data = window.getJsonData(side);
if (data === null) return window.showToast('Editor enth\u00e4lt keine g\u00fcltigen Daten.', 'error');
const name = (document.getElementById('snippetNameInput').value || '').trim() || docName(side);
const text = JSON.stringify(data);
const snippets = loadSnippets();
snippets.unshift({
id: 's' + Date.now(), name, content: text, size: text.length,
preview: text.slice(0, 70).replace(/\s+/g, ' '),
date: new Date().toLocaleDateString('de-DE')
});
storeSnippets(snippets);
window.showToast('Snippet gespeichert: ' + name, 'success');
renderSnippetModal();
}
window.insertSnippet = function(id) {
const snippet = loadSnippets().find(s => s.id === id);
if (!snippet) return window.showToast('Snippet nicht gefunden.', 'error');
try {
setData(window.snippetSide, JSON.parse(snippet.content));
window.closeModal();
window.showToast('Snippet eingef\u00fcgt: ' + snippet.name, 'success');
} catch (e) { window.showToast('Snippet ist besch\u00e4digt: ' + e.message, 'error'); }
};
window.deleteSnippet = function(id) {
storeSnippets(loadSnippets().filter(s => s.id !== id));
window.showToast('Snippet gel\u00f6scht.');
renderSnippetModal();
};
/* ===========================================================
10. Webhook
=========================================================== */
window.openWebhookModal = function(side) {
const body = `
<div class="form-row">
<label class="field-label">Webhook-URL</label>
<input type="url" id="webhookUrlInput" value="${escapeHtml(window.wbSettings.webhookUrl || '')}"
placeholder="https://n8n.example.de/webhook/mein-workflow">
<div class="field-hint">
Der Editor-Inhalt wird per <code>POST</code> als <code>application/json</code> gesendet.
Der Zielserver muss CORS erlauben, sonst blockiert der Browser die Anfrage.
Die URL wird lokal gespeichert.
</div>
</div>
<div class="form-row">
<label class="field-label">Optionaler Authorization-Header</label>
<input type="text" id="webhookAuthInput" placeholder="z. B. Bearer abc123">
<div class="field-hint">Wird nicht gespeichert.</div>
</div>
<div class="form-row">
<label class="field-label">Antwort des Servers</label>
<pre class="sql-code" id="webhookResponse" style="min-height:60px">&ndash; noch nichts gesendet &ndash;</pre>
</div>`;
window.openModal('&#128640; An Webhook senden &ndash; ' + escapeHtml(docName(side)), body, [
{ label: 'Senden', primary: true, handler: () => doWebhookSend(side) }
]);
};
async function doWebhookSend(side) {
const url = document.getElementById('webhookUrlInput').value.trim();
const auth = document.getElementById('webhookAuthInput').value.trim();
const out = document.getElementById('webhookResponse');
const data = window.getJsonData(side);
if (!url) return window.showToast('Bitte eine Webhook-URL eingeben.', 'error');
if (data === null) return window.showToast('Editor enth\u00e4lt keine g\u00fcltigen Daten.', 'error');
const s = window.wbSettings; s.webhookUrl = url; saveSettings(s);
out.textContent = 'Sende\u2026';
try {
const headers = { 'Content-Type': 'application/json' };
if (auth) headers['Authorization'] = auth;
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(data) });
const text = await res.text();
out.textContent = 'HTTP ' + res.status + ' ' + res.statusText + '\n\n' + (text || '(leere Antwort)');
if (res.ok) window.showToast('Erfolgreich gesendet (HTTP ' + res.status + ').', 'success');
else window.showToast('Server antwortete mit HTTP ' + res.status + '.', 'error');
} catch (err) {
out.textContent = 'Fehler: ' + err.message +
'\n\nHäufigste Ursache: Der Zielserver sendet keine CORS-Header.';
window.showToast('Senden fehlgeschlagen: ' + err.message, 'error');
}
}
/* ===========================================================
11. Datenbank-Profile (über HTTP-Brücke)
Ein Browser kann sich nicht direkt mit MySQL/MariaDB
verbinden - dafür fehlt ihm der TCP-Zugriff. Diese Seite
spricht deshalb mit einer kleinen HTTP-Brücke, die auf dem
Server läuft (mitgelieferte db-bridge.php oder ein
n8n-Webhook). Protokoll siehe Anleitung.
=========================================================== */
function loadProfiles() {
try { return JSON.parse(localStorage.getItem('wb_db_profiles') || '[]'); } catch (e) { return []; }
}
function storeProfiles(list) {
try { localStorage.setItem('wb_db_profiles', JSON.stringify(list)); }
catch (e) { window.showToast('Profil konnte nicht gespeichert werden.', 'error'); }
}
window.loadProfiles = loadProfiles;
async function dbCall(profile, payload) {
const headers = { 'Content-Type': 'application/json' };
if (profile.apiKey) headers['X-Api-Key'] = profile.apiKey;
const body = Object.assign({ database: profile.database || undefined }, payload);
const res = await fetch(profile.endpoint, { method: 'POST', headers, body: JSON.stringify(body) });
const text = await res.text();
let json;
try { json = JSON.parse(text); }
catch (e) { throw new Error('Antwort ist kein JSON (HTTP ' + res.status + '): ' + text.slice(0, 200)); }
if (!res.ok || json.error) throw new Error(json.error || ('HTTP ' + res.status));
return json;
}
window.openDbModal = function() { renderDbModal(); };
function renderDbModal() {
const profiles = loadProfiles();
let list;
if (profiles.length === 0) {
list = '<div class="modal-hint">Noch kein Profil angelegt. Jedes Profil zeigt auf eine HTTP-Br&uuml;cke, die die eigentliche Datenbankverbindung herstellt &ndash; wie das funktioniert, steht in der Anleitung unter &bdquo;Datenbank&ldquo;.</div>';
} else {
list = profiles.map(p => `
<div class="list-row">
<div class="list-meta">
<div class="list-name">${escapeHtml(p.name)}</div>
<div class="list-info">${escapeHtml(p.endpoint)}${p.database ? ' &middot; DB: ' + escapeHtml(p.database) : ''}</div>
</div>
<div class="list-actions">
<button class="btn-mini" onclick="testProfile('${p.id}')" title="Verbindung pr&uuml;fen und Tabellen abrufen">Testen</button>
<button class="btn-mini danger" onclick="deleteProfile('${p.id}')" title="Profil entfernen">L&ouml;schen</button>
</div>
</div>`).join('');
}
const body = `
<div class="modal-section-title">&#128451;&#65039; Angelegte Datenbank-Profile</div>
${list}
<div id="dbTestResult"></div>
<div style="height:14px"></div>
<div class="modal-section-title">Neues Profil anlegen</div>
<div class="form-grid">
<div class="form-row">
<label class="field-label">Anzeigename</label>
<input type="text" id="dbName" placeholder="z. B. Shop-Datenbank">
</div>
<div class="form-row">
<label class="field-label">Datenbankname (optional)</label>
<input type="text" id="dbDatabase" placeholder="z. B. shop_live">
</div>
</div>
<div class="form-row">
<label class="field-label">Endpunkt der HTTP-Br&uuml;cke</label>
<input type="url" id="dbEndpoint" placeholder="https://tools.example.de/db-bridge.php">
<div class="field-hint">
Der Browser kann sich nicht direkt zu MariaDB verbinden. Dieser Endpunkt ist ein kleines
Serverskript (oder ein n8n-Webhook), das die Anfragen entgegennimmt und an die Datenbank weiterreicht.
</div>
</div>
<div class="form-row">
<label class="field-label">API-Schl&uuml;ssel (optional)</label>
<input type="password" id="dbApiKey" placeholder="wird als X-Api-Key gesendet">
<div class="field-hint">
Wird im Browser-Speicher abgelegt. Nutze nach M&ouml;glichkeit einen eigenen
Datenbankbenutzer mit eingeschr&auml;nkten Rechten statt eines Administratorkontos.
</div>
</div>`;
window.openModal('&#128451;&#65039; Datenbank-Profile', body, [
{ label: 'Profil speichern', primary: true, handler: saveProfile }
]);
}
function saveProfile() {
const name = document.getElementById('dbName').value.trim();
const endpoint = document.getElementById('dbEndpoint').value.trim();
if (!name || !endpoint) return window.showToast('Name und Endpunkt werden ben\u00f6tigt.', 'error');
const profiles = loadProfiles();
profiles.push({
id: 'p' + Date.now(), name, endpoint,
database: document.getElementById('dbDatabase').value.trim(),
apiKey: document.getElementById('dbApiKey').value.trim()
});
storeProfiles(profiles);
window.showToast('Profil gespeichert: ' + name, 'success');
renderDbModal();
}
window.deleteProfile = function(id) {
storeProfiles(loadProfiles().filter(p => p.id !== id));
window.showToast('Profil gel\u00f6scht.');
renderDbModal();
};
window.testProfile = async function(id) {
const profile = loadProfiles().find(p => p.id === id);
const out = document.getElementById('dbTestResult');
out.innerHTML = '<div class="modal-hint">Verbindung wird gepr\u00fcft\u2026</div>';
try {
const res = await dbCall(profile, { action: 'tables' });
const tables = res.tables || [];
out.innerHTML = '<div class="modal-hint"><span class="status-ok">Verbindung steht.</span> ' +
tables.length + ' Tabelle(n) gefunden: ' + escapeHtml(tables.slice(0, 15).join(', ')) +
(tables.length > 15 ? ' \u2026' : '') + '</div>';
} catch (err) {
out.innerHTML = '<div class="modal-hint"><span style="color:var(--danger);font-weight:700">Fehlgeschlagen:</span> ' +
escapeHtml(err.message) + '</div>';
}
};
/* ---------- Aus Datenbank laden ---------- */
window.openDbLoadModal = function(side) {
const profiles = loadProfiles();
if (profiles.length === 0) {
window.showToast('Bitte zuerst ein Datenbank-Profil anlegen (Symbol oben rechts).', 'error');
return window.openDbModal();
}
const opts = profiles.map(p => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join('');
const body = `
<div class="form-grid">
<div class="form-row">
<label class="field-label">Datenbank-Profil</label>
<select id="dbLoadProfile" onchange="refreshTableList()">${opts}</select>
</div>
<div class="form-row">
<label class="field-label">Tabelle</label>
<select id="dbLoadTable"><option value="">&ndash; Tabellen werden geladen &ndash;</option></select>
</div>
</div>
<div class="form-grid">
<div class="form-row">
<label class="field-label">Maximale Zeilenzahl</label>
<input type="text" id="dbLoadLimit" value="500">
<div class="field-hint">Schutz davor, versehentlich eine Millionen-Zeilen-Tabelle in den Browser zu ziehen.</div>
</div>
<div class="form-row">
<label class="field-label">Ziel</label>
<input type="text" value="${escapeHtml(docName(side))}" disabled>
<div class="field-hint">Der geladene Inhalt ersetzt diesen Editor.</div>
</div>
</div>
<div class="form-row">
<label class="field-label">Eigene SQL-Abfrage statt Tabelle (optional)</label>
<textarea id="dbLoadQuery" placeholder="SELECT id, name, preis FROM artikel WHERE aktiv = 1 ORDER BY name"></textarea>
<div class="field-hint">
Wenn hier etwas steht, wird die Tabellenauswahl ignoriert. Ob lesende Abfragen erlaubt sind,
entscheidet die Br&uuml;cke auf dem Server &ndash; die mitgelieferte l&auml;sst nur SELECT zu.
</div>
</div>
<div id="dbLoadResult"></div>`;
window.openModal('&#128451;&#65039; Aus Datenbank laden &rarr; ' + escapeHtml(docName(side)), body, [
{ label: 'Laden', primary: true, handler: () => doDbLoad(side) }
]);
refreshTableList();
};
window.refreshTableList = async function() {
const sel = document.getElementById('dbLoadTable');
const profile = loadProfiles().find(p => p.id === document.getElementById('dbLoadProfile').value);
sel.innerHTML = '<option value="">&ndash; Tabellen werden geladen &ndash;</option>';
try {
const res = await dbCall(profile, { action: 'tables' });
const tables = res.tables || [];
sel.innerHTML = tables.length
? tables.map(t => `<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).join('')
: '<option value="">&ndash; keine Tabellen gefunden &ndash;</option>';
} catch (err) {
sel.innerHTML = '<option value="">&ndash; Abruf fehlgeschlagen &ndash;</option>';
document.getElementById('dbLoadResult').innerHTML =
'<div class="modal-hint"><span style="color:var(--danger);font-weight:700">Fehler:</span> ' + escapeHtml(err.message) + '</div>';
}
};
async function doDbLoad(side) {
const profile = loadProfiles().find(p => p.id === document.getElementById('dbLoadProfile').value);
const table = document.getElementById('dbLoadTable').value;
const query = document.getElementById('dbLoadQuery').value.trim();
const limit = parseInt(document.getElementById('dbLoadLimit').value, 10) || 500;
const out = document.getElementById('dbLoadResult');
if (!query && !table) return window.showToast('Bitte eine Tabelle w\u00e4hlen oder eine Abfrage eingeben.', 'error');
out.innerHTML = '<div class="modal-hint">Daten werden geladen\u2026</div>';
try {
const payload = query ? { action: 'query', sql: query, limit } : { action: 'select', table, limit };
const res = await dbCall(profile, payload);
const rows = res.rows || [];
setData(side, rows);
if (!query && table) document.getElementById(titleId(side)).innerText = table;
window.saveAppState();
window.closeModal();
window.showToast(rows.length + ' Zeile(n) geladen.', 'success');
} catch (err) {
out.innerHTML = '<div class="modal-hint"><span style="color:var(--danger);font-weight:700">Fehler:</span> ' + escapeHtml(err.message) + '</div>';
}
}
/* ---------- In Datenbank schreiben ---------- */
window.openDbWriteModal = function(side) {
const profiles = loadProfiles();
if (profiles.length === 0) {
window.showToast('Bitte zuerst ein Datenbank-Profil anlegen (Symbol oben rechts).', 'error');
return window.openDbModal();
}
const data = window.getJsonData(side);
if (data === null) return window.showToast('Editor enth\u00e4lt keine g\u00fcltigen Daten.', 'error');
const rows = toRows(data);
const opts = profiles.map(p => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join('');
const suggested = docName(side).toLowerCase().replace(/[^a-z0-9_]/g, '_');
const body = `
<div class="modal-hint">
${rows.length} Zeile(n) aus &bdquo;${escapeHtml(docName(side))}&ldquo; werden &uuml;bertragen.
</div>
<div class="form-grid">
<div class="form-row">
<label class="field-label">Datenbank-Profil</label>
<select id="dbWriteProfile">${opts}</select>
</div>
<div class="form-row">
<label class="field-label">Zieltabelle</label>
<input type="text" id="dbWriteTable" value="${escapeHtml(suggested)}">
</div>
</div>
<div class="form-row">
<label class="field-label">Schreibweise</label>
<select id="dbWriteMode">
<option value="append">Anh&auml;ngen &ndash; vorhandene Zeilen bleiben unber&uuml;hrt</option>
<option value="replace">Ersetzen &ndash; Tabelle vorher leeren (TRUNCATE)</option>
<option value="upsert">Aktualisieren &ndash; vorhandene Zeilen per Schl&uuml;ssel &uuml;berschreiben</option>
</select>
<div class="field-hint">
&bdquo;Ersetzen&ldquo; l&ouml;scht unwiderruflich alle bestehenden Zeilen der Zieltabelle.
Lege vorher eine Sicherung an, wenn du dir nicht sicher bist.
</div>
</div>
<div class="form-row">
<label class="field-label">Tabelle anlegen, falls nicht vorhanden</label>
<select id="dbWriteCreate">
<option value="yes">Ja &ndash; Struktur aus den Daten ableiten</option>
<option value="no">Nein &ndash; Fehler melden, wenn die Tabelle fehlt</option>
</select>
</div>
<div id="dbWriteResult"></div>`;
window.openModal('&#128228; In Datenbank schreiben', body, [
{ label: 'Schreiben', primary: true, handler: () => doDbWrite(side, rows) }
]);
};
async function doDbWrite(side, rows) {
const profile = loadProfiles().find(p => p.id === document.getElementById('dbWriteProfile').value);
const table = document.getElementById('dbWriteTable').value.trim();
const mode = document.getElementById('dbWriteMode').value;
const create = document.getElementById('dbWriteCreate').value === 'yes';
const out = document.getElementById('dbWriteResult');
if (!table) return window.showToast('Bitte eine Zieltabelle angeben.', 'error');
if (mode === 'replace' && !confirm('Wirklich alle bestehenden Zeilen in "' + table + '" löschen und ersetzen?')) return;
out.innerHTML = '<div class="modal-hint">Daten werden geschrieben\u2026</div>';
try {
const res = await dbCall(profile, {
action: 'insert', table, rows, mode, createIfMissing: create,
createSql: create ? buildSql(table, rows).sql.split('\n\n')[0] : undefined
});
out.innerHTML = '<div class="modal-hint"><span class="status-ok">Fertig.</span> ' +
escapeHtml(String(res.inserted !== undefined ? res.inserted + ' Zeile(n) geschrieben.' : 'Vorgang abgeschlossen.')) + '</div>';
window.showToast('In Datenbank geschrieben.', 'success');
} catch (err) {
out.innerHTML = '<div class="modal-hint"><span style="color:var(--danger);font-weight:700">Fehler:</span> ' + escapeHtml(err.message) + '</div>';
}
}
/* ===========================================================
12. Einstellungen inkl. Sicherung / Wiederherstellung
=========================================================== */
window.openSettingsModal = function() {
const s = window.wbSettings;
const sel = (v, val) => v === val ? ' selected' : '';
const body = `
<div class="modal-section-title">&#128202; CSV</div>
<div class="form-grid">
<div class="form-row">
<label class="field-label">Trennzeichen beim Import</label>
<select id="setCsvDelim">
<option value=""${sel(s.csvDelimiter, '')}>Automatisch erkennen (empfohlen)</option>
<option value=";"${sel(s.csvDelimiter, ';')}>Semikolon ; (deutsches Excel)</option>
<option value=","${sel(s.csvDelimiter, ',')}>Komma ,</option>
<option value="&#9;"${sel(s.csvDelimiter, '\t')}>Tabulator</option>
<option value="|"${sel(s.csvDelimiter, '|')}>Pipe |</option>
</select>
</div>
<div class="form-row">
<label class="field-label">Trennzeichen beim Export</label>
<select id="setCsvExportDelim">
<option value=";"${sel(s.csvExportDelimiter, ';')}>Semikolon ; (deutsches Excel)</option>
<option value=","${sel(s.csvExportDelimiter, ',')}>Komma , (internationaler Standard)</option>
<option value="&#9;"${sel(s.csvExportDelimiter, '\t')}>Tabulator</option>
<option value="|"${sel(s.csvExportDelimiter, '|')}>Pipe |</option>
</select>
</div>
</div>
<div class="modal-hint">
Deutsche Excel-Versionen erwarten das Semikolon &ndash; mit Komma landet sonst alles in einer Spalte.
Exporte enthalten zus&auml;tzlich ein BOM, damit Umlaute richtig ankommen.
</div>
<div class="modal-section-title">&#129521; XML</div>
<div class="form-grid">
<div class="form-row">
<label class="field-label">Name des Wurzelelements</label>
<input type="text" id="setXmlRoot" value="${escapeHtml(s.xmlRootName)}">
</div>
<div class="form-row">
<label class="field-label">Elementname f&uuml;r Listeneintr&auml;ge</label>
<input type="text" id="setXmlItem" value="${escapeHtml(s.xmlItemName)}">
</div>
</div>
<div class="modal-section-title">&#128640; Webhook</div>
<div class="form-row">
<label class="field-label">Standard-Webhook-URL</label>
<input type="url" id="setWebhook" value="${escapeHtml(s.webhookUrl)}" placeholder="https://n8n.example.de/webhook/&hellip;">
</div>
<div class="modal-section-title">&#128190; Sicherung &amp; Wiederherstellung</div>
<div class="modal-hint">
Nimmt deine Einstellungen, Snippets und Datenbank-Profile mit auf einen anderen Rechner oder Browser.
Der Export ist eine ganz normale JSON-Datei.
</div>
<div class="form-row">
<label class="field-label">Was soll mitgenommen werden?</label>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;margin-bottom:5px;cursor:pointer">
<input type="checkbox" id="expSettings" checked style="width:auto"> Einstellungen (CSV, XML, Webhook)</label>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;margin-bottom:5px;cursor:pointer">
<input type="checkbox" id="expSnippets" checked style="width:auto"> Snippet-Bibliothek (${loadSnippets().length})</label>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;margin-bottom:5px;cursor:pointer">
<input type="checkbox" id="expProfiles" style="width:auto"> Datenbank-Profile (${loadProfiles().length})</label>
<div class="field-hint">
Datenbank-Profile sind standardm&auml;&szlig;ig abgew&auml;hlt, weil sie API-Schl&uuml;ssel im Klartext enthalten.
Nimm sie nur mit, wenn die Datei sicher verwahrt wird.
</div>
</div>
<div class="form-row">
<label class="field-label">Sicherung einspielen</label>
<input type="file" id="importConfigFile" accept=".json" onchange="importConfig(event)" style="font-size:12px">
<div class="field-hint">Vorhandene Eintr&auml;ge werden erg&auml;nzt, nicht gel&ouml;scht. Snippets und Profile mit gleichem Namen kommen zus&auml;tzlich hinzu.</div>
</div>`;
window.openModal('&#9881;&#65039; Einstellungen', body, [
{ label: 'Sicherung exportieren', handler: exportConfig, title: 'Ausgew\u00e4hlte Bereiche als JSON-Datei speichern' },
{ label: 'Speichern', primary: true, handler: () => {
saveSettings(Object.assign({}, window.wbSettings, {
csvDelimiter: document.getElementById('setCsvDelim').value,
csvExportDelimiter: document.getElementById('setCsvExportDelim').value,
xmlRootName: document.getElementById('setXmlRoot').value.trim() || 'root',
xmlItemName: document.getElementById('setXmlItem').value.trim() || 'item',
webhookUrl: document.getElementById('setWebhook').value.trim()
}));
window.closeModal();
window.showToast('Einstellungen gespeichert.', 'success');
} }
]);
};
function exportConfig() {
const bundle = {
_typ: 'json-studio-konfiguration',
_version: 1,
_erstellt: new Date().toISOString()
};
if (document.getElementById('expSettings').checked) bundle.einstellungen = window.wbSettings;
if (document.getElementById('expSnippets').checked) bundle.snippets = loadSnippets();
if (document.getElementById('expProfiles').checked) bundle.datenbankProfile = loadProfiles();
const stamp = new Date().toISOString().slice(0, 10);
downloadBlob(JSON.stringify(bundle, null, 2), 'json-studio-konfiguration-' + stamp + '.json', 'application/json');
window.showToast('Sicherung gespeichert.', 'success');
}
window.importConfig = function(event) {
const file = event.target.files[0];
if (!file) return;
readFileAsText(file, (text) => {
try {
const bundle = JSON.parse(text);
if (bundle._typ !== 'json-studio-konfiguration') {
throw new Error('Das ist keine Sicherungsdatei dieser Anwendung.');
}
const teile = [];
if (bundle.einstellungen) {
saveSettings(Object.assign({}, DEFAULT_SETTINGS, bundle.einstellungen));
teile.push('Einstellungen');
}
if (Array.isArray(bundle.snippets) && bundle.snippets.length) {
const vorhandene = loadSnippets();
const ids = new Set(vorhandene.map(s => s.id));
const neue = bundle.snippets.filter(s => !ids.has(s.id));
storeSnippets(vorhandene.concat(neue));
teile.push(neue.length + ' Snippet(s)');
}
if (Array.isArray(bundle.datenbankProfile) && bundle.datenbankProfile.length) {
const vorhandene = loadProfiles();
const ids = new Set(vorhandene.map(p => p.id));
const neue = bundle.datenbankProfile.filter(p => !ids.has(p.id));
storeProfiles(vorhandene.concat(neue));
teile.push(neue.length + ' Profil(e)');
}
window.showToast(teile.length ? 'Eingespielt: ' + teile.join(', ') : 'Die Datei enthielt nichts zum Einspielen.', 'success');
window.closeModal();
} catch (err) {
window.showToast('Wiederherstellung fehlgeschlagen: ' + err.message, 'error');
}
});
event.target.value = '';
};
/* ===========================================================
13. Anleitung
=========================================================== */
window.openHelpModal = function(startTab) {
const libLine = (label, key) => {
const st = window.libStatus[key];
const cls = st === 'lokal' ? 'status-ok' : (st === 'CDN' ? 'status-warn' : '');
return '<li>' + label + ': <span class="' + cls + '">' + (st || 'unbekannt') + '</span></li>';
};
const body = `
<div class="help-nav">
<button class="help-tab" data-tab="start" onclick="switchHelp('start')">Erste Schritte</button>
<button class="help-tab" data-tab="formate" onclick="switchHelp('formate')">Import &amp; Export</button>
<button class="help-tab" data-tab="flatten" onclick="switchHelp('flatten')">Mitte: Kopieren, Flatten, Vergleich</button>
<button class="help-tab" data-tab="db" onclick="switchHelp('db')">Datenbank</button>
<button class="help-tab" data-tab="offline" onclick="switchHelp('offline')">Offline-Betrieb</button>
<button class="help-tab" data-tab="lizenzen" onclick="switchHelp('lizenzen')">Lizenzen &amp; Datenschutz</button>
</div>
<div class="help-panel" data-panel="start">
<h4>Wozu die zwei Editoren?</h4>
<p>Links und rechts arbeitest du mit je einem Dokument. Das ist praktisch, um zwei St&auml;nde zu
vergleichen, Daten von einem Format ins andere zu &uuml;berf&uuml;hren oder ein Schema neben den
Rohdaten zu haben. Der zuletzt angeklickte Editor ist der &bdquo;aktive&ldquo; &ndash; seine Leiste ist
etwas heller, und die Tastaturk&uuml;rzel wirken auf ihn.</p>
<h4>Der Dokumentname ist wichtiger, als er aussieht</h4>
<p>Der Name oben links (&bdquo;Dokument 1&ldquo;) wird beim Export zum Dateinamen und beim
SQL-Export zum Tabellennamen. Vergib also am besten gleich einen sprechenden Namen.</p>
<h4>Alles bleibt erhalten</h4>
<p>Inhalte, Namen, Einstellungen, Snippets und Datenbank-Profile liegen im lokalen Speicher
deines Browsers. Nach einem Neustart ist alles wieder da. Es wird nichts an einen fremden
Server gesendet &ndash; au&szlig;er du benutzt bewusst URL-Import, Webhook oder Datenbank.</p>
<p>Weil der Speicher am Browser h&auml;ngt: Beim Wechsel auf einen anderen Rechner nimmst du
deine Konfiguration &uuml;ber <b>Einstellungen &rarr; Sicherung exportieren</b> mit.</p>
<h4>Die drei Ansichten des Editors</h4>
<ul>
<li><b>tree</b> &ndash; Baumansicht zum bequemen Klicken und Bearbeiten</li>
<li><b>text</b> &ndash; roher JSON-Text, gut zum Einf&uuml;gen gr&ouml;&szlig;erer Mengen</li>
<li><b>table</b> &ndash; Tabellenansicht, sinnvoll bei einer Liste gleichartiger Objekte</li>
</ul>
</div>
<div class="help-panel" data-panel="formate">
<h4>Was hereinkommt</h4>
<ul>
<li><b>JSON / Text</b> &ndash; l&auml;sst sich der Text nicht als JSON lesen, landet er unver&auml;ndert im Texteditor, damit du ihn reparieren kannst.</li>
<li><b>CSV</b> &ndash; die erste Zeile gilt als &Uuml;berschrift. Zahlen werden automatisch als Zahlen erkannt.</li>
<li><b>Excel</b> &ndash; bei mehreren Tabellenbl&auml;ttern entsteht ein Objekt mit einem Eintrag je Blatt.</li>
<li><b>YAML</b> &ndash; auch Dateien mit mehreren Dokumenten (getrennt durch <code>---</code>).</li>
<li><b>XML</b> &ndash; Attribute werden zu Feldern mit <code>@</code>-Pr&auml;fix, gemischter Text landet unter <code>#text</code>, wiederholte Tags werden zu Listen.</li>
<li><b>SQL-Dump</b> &ndash; liest <code>INSERT</code>-Anweisungen zur&uuml;ck in JSON. Spaltennamen kommen aus der INSERT-Zeile oder ersatzweise aus <code>CREATE TABLE</code>.</li>
<li><b>URL</b> &ndash; l&auml;dt direkt von einer Adresse. Das Format wird am Inhaltstyp erkannt.</li>
</ul>
<h4>Was hinausgeht</h4>
<ul>
<li><b>CSV / Excel</b> &ndash; brauchen eine Liste gleichartiger Objekte. Ein einzelnes Objekt wird zu einer Zeile, einfache Werte bekommen ersatzweise die Spalten <code>id</code> und <code>wert</code>.</li>
<li><b>XML</b> &ndash; Felder mit <code>@</code> werden wieder zu Attributen. Wurzel- und Listen-Tag stellst du in den Einstellungen ein.</li>
<li><b>MariaDB</b> &ndash; erzeugt <code>CREATE TABLE</code> samt <code>INSERT</code>s. Die Spaltentypen werden aus den Werten geraten; pr&uuml;fe sie, bevor du das Skript produktiv einspielst.</li>
<li><b>JSON-Schema</b> &ndash; leitet eine Strukturbeschreibung ab, die du zur Validierung weiterverwenden kannst.</li>
</ul>
<h4>Verschachtelte Daten in Tabellen</h4>
<p>CSV, Excel und SQL sind flach, JSON ist es nicht. Verschachtelte Objekte werden beim Export
als JSON-Text in eine Zelle geschrieben. Wenn du daraus lieber echte Spalten h&auml;ttest,
benutze vorher <b>Flatten</b> in der Mitte.</p>
</div>
<div class="help-panel" data-panel="flatten">
<h4>Kopieren (&lt; &gt;)</h4>
<p>&Uuml;bertr&auml;gt den Inhalt unver&auml;ndert von einer Seite auf die andere. Der Pfeil zeigt die
Richtung: <code>&gt;</code> schiebt von links nach rechts.</p>
<h4>Flatten (&lt; &gt;) &ndash; wof&uuml;r ist das gut?</h4>
<p>Flatten l&ouml;st Verschachtelungen auf: Aus einem Objekt in einem Objekt werden Felder mit
zusammengesetzten Namen. Aus</p>
<pre class="sql-code">{ "kunde": { "name": "Meier", "ort": "Kiel" } }</pre>
<p>wird</p>
<pre class="sql-code">{ "kunde_name": "Meier", "kunde_ort": "Kiel" }</pre>
<p>Das brauchst du immer dann, wenn die Daten in etwas Flaches sollen &ndash; also f&uuml;r
<b>CSV, Excel oder eine Datenbanktabelle</b>. Ohne Flatten steht in der Zelle ein
JSON-Schnipsel, mit Flatten hast du saubere Einzelspalten. Der typische Ablauf:
links importieren, mit Flatten nach rechts, rechts als Excel exportieren.</p>
<p>Bei einer Liste wird jedes Element einzeln aufgel&ouml;st. Listen <i>innerhalb</i> eines
Objekts bleiben unangetastet &ndash; daf&uuml;r gibt es keine sinnvolle Spaltendarstellung.</p>
<p>Wenn du nur mit ohnehin flachen Daten arbeitest, brauchst du die Funktion schlicht nicht.</p>
<h4>Vergleichen</h4>
<p>Pr&uuml;ft beide Dokumente Feld f&uuml;r Feld und listet jeden Unterschied mit seinem Pfad auf &ndash;
inklusive Feldern, die nur auf einer Seite existieren. Den Bericht kannst du als Markdown-Tabelle
oder CSV mitnehmen.</p>
<h4>Aufteilung &auml;ndern</h4>
<p>Die mittlere Leiste l&auml;sst sich mit gedr&uuml;ckter Maustaste verschieben. Ein Doppelklick
darauf stellt die H&auml;lfte-H&auml;lfte-Aufteilung wieder her.</p>
</div>
<div class="help-panel" data-panel="db">
<h4>Warum eine Br&uuml;cke n&ouml;tig ist</h4>
<p>Ein Browser kann sich nicht direkt mit MariaDB oder MySQL verbinden &ndash; ihm fehlt der
Zugriff auf rohe Netzwerkverbindungen, und das ist auch gut so: Sonst m&uuml;sstest du deine
Datenbank offen ins Internet stellen. Diese Anwendung spricht deshalb mit einem kleinen
Serverskript, das seinerseits die Datenbank anspricht. Das kann die mitgelieferte
<code>db-bridge.php</code> sein oder ein n8n-Webhook mit einem MySQL-Node.</p>
<h4>Einrichtung in drei Schritten</h4>
<ul>
<li>Br&uuml;cke auf dem Server ablegen und Zugangsdaten dort eintragen &ndash; sie stehen dann
serverseitig und nicht im Browser.</li>
<li>Oben rechts auf das Datenbank-Symbol, Profil mit Namen und Endpunkt-URL anlegen.</li>
<li>Auf <b>Testen</b> klicken &ndash; erscheint die Tabellenliste, steht die Verbindung.</li>
</ul>
<h4>Das Protokoll</h4>
<p>Die Br&uuml;cke bekommt ein JSON per <code>POST</code> und antwortet mit JSON:</p>
<pre class="sql-code">{"action":"tables"} &rarr; {"tables":["kunden","artikel"]}
{"action":"select","table":"kunden","limit":500} &rarr; {"rows":[{...}]}
{"action":"query","sql":"SELECT ...","limit":500} &rarr; {"rows":[{...}]}
{"action":"insert","table":"kunden",
"rows":[{...}],"mode":"append"} &rarr; {"inserted":42}</pre>
<p>Ein optionaler API-Schl&uuml;ssel wird als Header <code>X-Api-Key</code> mitgeschickt.
Im Fehlerfall antwortet die Br&uuml;cke mit <code>{"error":"Text"}</code>.</p>
<h4>Bitte vorsichtig damit</h4>
<ul>
<li>Der API-Schl&uuml;ssel liegt im Browser-Speicher &ndash; also im Klartext auf deinem Rechner.
Verwende einen eigenen Datenbankbenutzer mit genau den n&ouml;tigen Rechten, nicht <code>root</code>.</li>
<li>Die Schreibweise <b>Ersetzen</b> leert die Zieltabelle vollst&auml;ndig. Vorher sichern.</li>
<li>Beim Anlegen einer Tabelle werden die Spaltentypen aus den Daten geraten. F&uuml;r produktive
Tabellen legst du die Struktur besser selbst an und schreibst dann nur die Daten hinein.</li>
</ul>
</div>
<div class="help-panel" data-panel="offline">
<h4>Aktuell geladen aus</h4>
<ul>
${libLine('JSON-Editor (Skript inkl. Stile)', 'jseJs')}
${libLine('CSV-Verarbeitung (PapaParse)', 'papa')}
${libLine('Excel (SheetJS)', 'xlsx')}
${libLine('YAML (js-yaml)', 'yaml')}
</ul>
<h4>So wird die Anwendung offline lauff&auml;hig</h4>
<p>Die Seite sucht jede Bibliothek zuerst im Unterordner <code>lib/</code> und geht nur dann
ins Netz, wenn sie dort nichts findet. Lege also neben die HTML-Datei einen Ordner
<code>lib/</code> mit diesen Dateien an:</p>
<pre class="sql-code">lib/papaparse.min.js
lib/xlsx.full.min.js
lib/js-yaml.min.js
lib/jsoneditor/standalone.js</pre>
<p>Die Dateien bekommst du &uuml;ber die Adressen, die im Kopf der HTML-Datei als Kommentar
stehen (direkt daneben notiert) &ndash; einfach im Browser aufrufen und mit
&bdquo;Speichern unter&ldquo; unter dem genannten Namen ablegen. Der JSON-Editor bringt seine
Stile seit Version 3 direkt im Skript mit, eine separate CSS-Datei ist daf&uuml;r nicht
mehr n&ouml;tig.</p>
<h4>Ein Haken, den du kennen solltest</h4>
<p>Der JSON-Editor ist ein ES-Modul. Browser verbieten aus Sicherheitsgr&uuml;nden, solche
Module von <code>file://</code> zu laden. Per Doppelklick aus dem Dateisystem ge&ouml;ffnet,
holt sich die Seite den Editor deshalb weiterhin aus dem Netz &ndash; auch wenn die lokale
Datei vorhanden ist.</p>
<p>Damit der Offline-Betrieb wirklich greift, muss der Ordner &uuml;ber einen Webserver
ausgeliefert werden. Das ist kein Aufwand: ein Verzeichnis in CloudPanel, eine Nextcloud-Freigabe
oder kurz <code>python3 -m http.server</code> im Ordner gen&uuml;gen. Oben siehst du jederzeit,
welche Quelle tats&auml;chlich benutzt wurde.</p>
<p>Die Version des JSON-Editors ist bewusst fest eingetragen (aktuell 3.11.0) statt
&bdquo;immer die neueste&ldquo; zu laden &ndash; eine unversionierte Adresse kann sich
unangek&uuml;ndigt &auml;ndern und Dateien umbenennen oder entfernen, wie es mit der
fr&uuml;heren <code>standalone.css</code> passiert ist.</p>
</div>
<div class="help-panel" data-panel="lizenzen">
<h4>Diese Anwendung</h4>
<p>Ver&ouml;ffentlicht unter der MIT-Lizenz. Du darfst sie frei verwenden, ver&auml;ndern
und weitergeben, solange der Copyright- und Lizenzhinweis erhalten bleibt. Sie wird
ohne Gew&auml;hrleistung bereitgestellt.</p>
<p>Quellcode: <a href="#" onclick="return false;">im Repository hinterlegt</a>
&ndash; die Adresse tr&auml;gst du hier im Quelltext ein, sobald das Repository steht.</p>
<h4>Verwendete Bibliotheken</h4>
<ul>
<li><b>vanilla-jsoneditor</b> (ISC) &ndash; der JSON-Editor selbst, von Jos de Jong</li>
<li><b>PapaParse</b> (MIT) &ndash; CSV lesen und schreiben, von Matthew Holt</li>
<li><b>js-yaml</b> (MIT) &ndash; YAML lesen und schreiben, von Vladimir Zapparov,
Vitaly Puzrin und Mitwirkenden</li>
<li><b>SheetJS Community Edition</b> (Apache-2.0) &ndash; Excel-Dateien, von SheetJS LLC</li>
</ul>
<p>Die vollst&auml;ndigen Lizenztexte liegen dem Quellcode bei. Wenn du die Anwendung
weitergibst, m&uuml;ssen sie mitgeliefert werden &ndash; bei Apache-2.0 zus&auml;tzlich
der Hinweis auf etwaige &Auml;nderungen an den Dateien.</p>
<h4>Was mit deinen Daten passiert</h4>
<p>Alles, was du hier bearbeitest, bleibt in deinem Browser. Es wird nichts an den
Server &uuml;bertragen, von dem diese Seite geladen wurde, und nichts dort gespeichert
oder ausgewertet. Es gibt keine Konten, kein Tracking und keine Analyse-Cookies.</p>
<p>Damit deine Arbeit einen Neustart &uuml;bersteht, werden Inhalte, Namen, Einstellungen,
Snippets und Datenbank-Profile im lokalen Speicher deines Browsers abgelegt &ndash;
ausschlie&szlig;lich auf deinem Ger&auml;t. &Uuml;ber die Website-Einstellungen deines
Browsers kannst du sie jederzeit l&ouml;schen.</p>
<p>Drei Funktionen senden bewusst Daten nach drau&szlig;en, und zwar nur an Adressen,
die du selbst eingibst: <b>Von URL laden</b>, <b>An Webhook senden</b> und die
<b>Datenbankanbindung</b>. Diese Verbindungen laufen direkt zwischen deinem Browser und
dem jeweiligen Ziel.</p>
<p>Ein Hinweis in eigener Sache: Datenbank-Profile enthalten unter Umst&auml;nden
Zugangsschl&uuml;ssel, die unverschl&uuml;sselt im Browser-Speicher liegen. Auf einem
gemeinsam genutzten Rechner solltest du sie nicht speichern.</p>
</div>`;
window.openModal('&#10068; Anleitung', body, []);
switchHelp(startTab || 'start');
};
window.switchHelp = function(tab) {
document.querySelectorAll('.help-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
document.querySelectorAll('.help-panel').forEach(p => p.classList.toggle('active', p.dataset.panel === tab));
};
/* ===========================================================
14. Tastaturkürzel
=========================================================== */
window.openShortcutsModal = function() {
const body = `
<div class="modal-hint">
Die K&uuml;rzel wirken auf den zuletzt angeklickten Editor &ndash; erkennbar an der etwas helleren Leiste.
Innerhalb des Editors gelten zus&auml;tzlich dessen eigene K&uuml;rzel, etwa Strg+F f&uuml;r die Suche.
</div>
<table class="shortcut-table">
<tr><td><kbd>Strg</kbd> + <kbd>S</kbd></td><td>Aktiven Editor als JSON speichern</td></tr>
<tr><td><kbd>Strg</kbd> + <kbd>&#9166;</kbd></td><td>Beide Dokumente vergleichen</td></tr>
<tr><td><kbd>Strg</kbd> + <kbd>K</kbd></td><td>Snippet-Bibliothek &ouml;ffnen</td></tr>
<tr><td><kbd>Strg</kbd> + <kbd>M</kbd></td><td>MariaDB-Tabelle &amp; SQL erzeugen</td></tr>
<tr><td><kbd>Strg</kbd> + <kbd>B</kbd></td><td>Aus Datenbank laden</td></tr>
<tr><td><kbd>Strg</kbd> + <kbd>&#8679;</kbd> + <kbd>S</kbd></td><td>JSON-Schema ableiten</td></tr>
<tr><td><kbd>Strg</kbd> + <kbd>&#8679;</kbd> + <kbd>E</kbd></td><td>Als Excel exportieren</td></tr>
<tr><td><kbd>Strg</kbd> + <kbd>&#8679;</kbd> + <kbd>D</kbd></td><td>Dunkel-/Hellmodus umschalten</td></tr>
<tr><td><kbd>Strg</kbd> + <kbd>/</kbd></td><td>Diese &Uuml;bersicht</td></tr>
<tr><td><kbd>F1</kbd></td><td>Anleitung &ouml;ffnen</td></tr>
<tr><td><kbd>Esc</kbd></td><td>Dialog schlie&szlig;en</td></tr>
</table>`;
window.openModal('&#9000; Tastaturk&uuml;rzel', body, []);
};
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (document.getElementById('modalOverlay').style.display === 'flex') window.closeModal();
return;
}
if (e.key === 'F1') { e.preventDefault(); window.openHelpModal(); return; }
if (!e.ctrlKey && !e.metaKey) return;
const side = window.activeSide;
const key = e.key.toLowerCase();
if (e.shiftKey) {
if (key === 's') { e.preventDefault(); window.openSchemaModal(side); }
else if (key === 'e') { e.preventDefault(); window.exportExcel(side); }
else if (key === 'd') { e.preventDefault(); window.cycleTheme(); }
return;
}
if (key === 's') { e.preventDefault(); window.exportJson(side); }
else if (key === 'enter') { e.preventDefault(); detailedCompare(); }
else if (key === 'k') { e.preventDefault(); window.openSnippetModal(side); }
else if (key === 'm') { e.preventDefault(); window.openSqlModal(side); }
else if (key === 'b') { e.preventDefault(); window.openDbLoadModal(side); }
else if (key === '/') { e.preventDefault(); window.openShortcutsModal(); }
});
/* ===========================================================
15. Kopieren / Flatten / Vergleichen
=========================================================== */
document.getElementById('btnCopyLeftToRight').onclick = () => copyEditorContent('left', 'right');
document.getElementById('btnCopyRightToLeft').onclick = () => copyEditorContent('right', 'left');
document.getElementById('btnTransLeftToRight').onclick = () => transformContent('left', 'right');
document.getElementById('btnTransRightToLeft').onclick = () => transformContent('right', 'left');
document.getElementById('btnCompare').onclick = detailedCompare;
function copyEditorContent(from, to) {
try {
ed(to).set(ed(from).get());
window.saveAppState();
window.showToast('Inhalt kopiert.', 'success');
} catch (e) { window.showToast('Fehler: ' + e.message, 'error'); }
}
function transformContent(from, to) {
try {
const data = window.getJsonData(from);
if (data === null) return window.showToast('Quelle enth\u00e4lt kein g\u00fcltiges JSON.', 'error');
let transformed;
if (Array.isArray(data)) {
transformed = data.map(item => (typeof item === 'object' && item !== null) ? flattenObject(item) : item);
} else if (typeof data === 'object' && data !== null) {
transformed = flattenObject(data);
} else { transformed = data; }
setData(to, transformed);
window.showToast('Verschachtelung aufgel\u00f6st und \u00fcbertragen.', 'success');
} catch (e) { window.showToast('Flatten fehlgeschlagen: ' + e.message, 'error'); }
}
function flattenObject(obj, prefix = '') {
return Object.keys(obj).reduce((acc, k) => {
const pre = prefix.length ? prefix + '_' : '';
if (typeof obj[k] === 'object' && obj[k] !== null && !Array.isArray(obj[k])) {
Object.assign(acc, flattenObject(obj[k], pre + k));
} else { acc[pre + k] = obj[k]; }
return acc;
}, {});
}
function detailedCompare() {
const leftData = window.getJsonData('left');
const rightData = window.getJsonData('right');
if (leftData === null || rightData === null) {
return window.showToast('Beide Editoren m\u00fcssen g\u00fcltiges JSON enthalten.', 'error');
}
const diffs = [];
findDiffs(leftData, rightData, '', diffs);
window.lastDiffs = diffs;
let body;
if (diffs.length === 0) {
body = '<div class="diff-empty">&#10003; Beide JSON-Dokumente sind absolut identisch!</div>';
} else {
body = '<div class="modal-hint">' + diffs.length + ' Unterschied(e) gefunden.</div>' +
diffs.map(d => `
<div class="diff-row">
<b>Pfad:</b> <code>${escapeHtml(d.path)}</code><br>
<b>Links:</b> <code>${escapeHtml(JSON.stringify(d.left))}</code><br>
<b>Rechts:</b> <code>${escapeHtml(JSON.stringify(d.right))}</code>
</div>`).join('');
}
const actions = diffs.length === 0 ? [] : [
{ label: 'Als CSV', handler: exportDiffCsv },
{ label: 'Als Markdown', primary: true, handler: exportDiffMarkdown }
];
window.openModal('&#128269; Detaillierter Vergleichsbericht', body, actions);
}
function exportDiffMarkdown() {
const diffs = window.lastDiffs || [];
const nameL = docName('left'), nameR = docName('right');
let md = '# Vergleichsbericht\n\n';
md += '- **Links:** ' + nameL + '\n- **Rechts:** ' + nameR + '\n';
md += '- **Erstellt:** ' + new Date().toLocaleString('de-DE') + '\n';
md += '- **Unterschiede:** ' + diffs.length + '\n\n';
md += '| Pfad | ' + nameL + ' | ' + nameR + ' |\n|---|---|---|\n';
diffs.forEach(d => {
const cell = (v) => '`' + String(JSON.stringify(v)).replace(/\|/g, '\\|') + '`';
md += '| `' + d.path.replace(/\|/g, '\\|') + '` | ' + cell(d.left) + ' | ' + cell(d.right) + ' |\n';
});
downloadBlob(md, 'vergleich.md', 'text/markdown;charset=utf-8;');
window.showToast('Gespeichert: vergleich.md', 'success');
}
function exportDiffCsv() {
const diffs = window.lastDiffs || [];
const rows = diffs.map(d => ({ pfad: d.path, links: JSON.stringify(d.left), rechts: JSON.stringify(d.right) }));
const csv = Papa.unparse(rows, { delimiter: window.wbSettings.csvExportDelimiter || ',' });
downloadBlob('\ufeff' + csv, 'vergleich.csv', 'text/csv;charset=utf-8;');
window.showToast('Gespeichert: vergleich.csv', 'success');
}
function findDiffs(obj1, obj2, path, diffs) {
if (typeof obj1 !== typeof obj2) { diffs.push({ path: path || 'root', left: obj1, right: obj2 }); return; }
if (typeof obj1 !== 'object' || obj1 === null || obj2 === null) {
if (obj1 !== obj2) diffs.push({ path: path || 'root', left: obj1, right: obj2 });
return;
}
const keys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]);
keys.forEach(key => {
const currentPath = path ? `${path}.${key}` : key;
if (!(key in obj1)) diffs.push({ path: currentPath, left: undefined, right: obj2[key] });
else if (!(key in obj2)) diffs.push({ path: currentPath, left: obj1[key], right: undefined });
else findDiffs(obj1[key], obj2[key], currentPath, diffs);
});
}
/* ===========================================================
16. Splitter - arbeitet mit Verhältnissen statt festen Pixeln,
damit die Aufteilung beim Ändern der Fenstergröße erhalten
bleibt und sich automatisch mitskaliert.
=========================================================== */
const centerBar = document.getElementById('centerBar');
const leftPane = document.getElementById('leftPane');
const rightPane = document.getElementById('rightPane');
const appContainer = document.getElementById('appContainer');
let isDragging = false;
function applyRatio(ratio) {
const r = Math.min(0.85, Math.max(0.15, ratio));
leftPane.style.flex = r + ' 1 0';
rightPane.style.flex = (1 - r) + ' 1 0';
}
applyRatio(window.wbSettings.splitRatio || 0.5);
centerBar.addEventListener('mousedown', (e) => {
if (e.target.tagName === 'BUTTON' || e.target.classList.contains('center-hint')) return;
isDragging = true;
document.body.style.cursor = 'col-resize';
leftPane.style.pointerEvents = 'none';
rightPane.style.pointerEvents = 'none';
});
centerBar.addEventListener('dblclick', () => {
applyRatio(0.5);
const s = window.wbSettings; s.splitRatio = 0.5; saveSettings(s);
window.showToast('Aufteilung zur\u00fcckgesetzt.');
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const rect = appContainer.getBoundingClientRect();
const usable = rect.width - centerBar.offsetWidth;
if (usable <= 0) return;
applyRatio((e.clientX - rect.left - centerBar.offsetWidth / 2) / usable);
});
window.addEventListener('mouseup', () => {
if (!isDragging) return;
isDragging = false;
document.body.style.cursor = '';
leftPane.style.pointerEvents = '';
rightPane.style.pointerEvents = '';
const s = window.wbSettings;
s.splitRatio = parseFloat(leftPane.style.flex) || 0.5;
saveSettings(s);
});
</script>
</body>
</html>