2591 lines
122 KiB
HTML
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 & 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ü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…</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 öffnen">❔</button>
|
|
<button class="icon-btn" onclick="openShortcutsModal()" title="Tastaturkürzel (Strg+/)">⌨</button>
|
|
<button class="icon-btn" onclick="openDbModal()" title="Datenbank-Profile verwalten">🗃️</button>
|
|
<button class="icon-btn" onclick="openSettingsModal()" title="Einstellungen, Sicherung & Wiederherstellung">⚙️</button>
|
|
<button class="icon-btn" id="themeToggle" onclick="cycleTheme()" title="Zwischen Dunkel- und Hellmodus wechseln">🌙</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">✎</span>
|
|
</div>
|
|
<div class="bar-actions">
|
|
<button class="nav-btn" onclick="clearEditor('left')" title="Editor leeren und neu beginnen">📄 <span class="btn-text">Neu</span></button>
|
|
|
|
<div class="dropdown">
|
|
<button class="dropdown-btn" title="Daten aus einer Datei, URL oder Datenbank einlesen">📁 <span class="btn-text">Öffnen</span> ▾</button>
|
|
<div class="dropdown-content">
|
|
<div class="group-label">Datei einlesen</div>
|
|
<label title="Eine JSON- oder Textdatei laden">📄 JSON / Text (.json, .txt)<input type="file" accept=".json,.txt" onchange="importJsonFile(event, 'left')" hidden></label>
|
|
<label title="Tabelle aus einer CSV-Datei laden">📊 CSV (.csv)<input type="file" accept=".csv" onchange="importCsvFile(event, 'left')" hidden></label>
|
|
<label title="Excel-Arbeitsmappe laden - mehrere Blätter werden zu einem Objekt">📈 Excel (.xlsx, .xls)<input type="file" accept=".xlsx,.xls" onchange="importExcelFile(event, 'left')" hidden></label>
|
|
<label title="YAML-Datei laden, auch mit mehreren Dokumenten">📑 YAML (.yaml, .yml)<input type="file" accept=".yaml,.yml" onchange="importYamlFile(event, 'left')" hidden></label>
|
|
<label title="XML laden - Attribute werden zu @-Feldern">🧱 XML (.xml)<input type="file" accept=".xml" onchange="importXmlFile(event, 'left')" hidden></label>
|
|
<label title="SQL-Dump mit INSERT-Anweisungen wieder in JSON zurückverwandeln">🗃️ 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">🌐 Von URL laden…</a>
|
|
<a href="#" onclick="openDbLoadModal('left'); return false;" title="Tabelle aus einer angebundenen Datenbank laden">🗃️ Aus Datenbank laden…</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dropdown">
|
|
<button class="dropdown-btn" title="Daten als Datei speichern oder weiterverarbeiten">💾 <span class="btn-text">Export</span> ▾</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">📄 JSON (.json)</a>
|
|
<a href="#" onclick="exportCsv('left'); return false;" title="Als CSV speichern - Trennzeichen in den Einstellungen">📊 CSV (.csv)</a>
|
|
<a href="#" onclick="exportExcel('left'); return false;" title="Als Excel-Arbeitsmappe speichern">📈 Excel (.xlsx)</a>
|
|
<a href="#" onclick="exportYaml('left'); return false;" title="Als YAML speichern">📑 YAML (.yaml)</a>
|
|
<a href="#" onclick="exportXml('left'); return false;" title="Als XML speichern - Tag-Namen in den Einstellungen">🧱 XML (.xml)</a>
|
|
<div class="divider"></div>
|
|
<div class="group-label">Erzeugen & Übertragen</div>
|
|
<a href="#" onclick="openSqlModal('left'); return false;" title="CREATE TABLE und INSERTs für MariaDB erzeugen">🗃️ MariaDB Tabelle & SQL</a>
|
|
<a href="#" onclick="openSchemaModal('left'); return false;" title="Aus den Daten ein JSON-Schema ableiten">📜 JSON-Schema ableiten</a>
|
|
<a href="#" onclick="openDbWriteModal('left'); return false;" title="Daten in eine angebundene Datenbank schreiben">📤 In Datenbank schreiben…</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dropdown">
|
|
<button class="dropdown-btn" title="Zwischenablage, Snippets und Webhook">🔧 <span class="btn-text">Werkzeuge</span> ▾</button>
|
|
<div class="dropdown-content">
|
|
<div class="group-label">Zwischenablage</div>
|
|
<a href="#" onclick="copyToClip('left', 'formatted'); return false;" title="Eingerückter JSON-Text">📋 Formatierter JSON-Text</a>
|
|
<a href="#" onclick="copyToClip('left', 'compact'); return false;" title="JSON ohne Leerzeichen und Umbrüche">📋 Kompakter JSON-Text</a>
|
|
<a href="#" onclick="copyToClip('left', 'csv'); return false;" title="Als CSV in die Zwischenablage">📋 Als CSV</a>
|
|
<a href="#" onclick="copyToClip('left', 'yaml'); return false;" title="Als YAML in die Zwischenablage">📋 Als YAML</a>
|
|
<div class="divider"></div>
|
|
<div class="group-label">Weiteres</div>
|
|
<a href="#" onclick="openSnippetModal('left'); return false;" title="Häufig gebrauchte Vorlagen speichern und einfügen">⭐ Snippet-Bibliothek…</a>
|
|
<a href="#" onclick="openWebhookModal('left'); return false;" title="Inhalt per POST an einen Webhook senden, z. B. n8n">🚀 An Webhook senden…</a>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="nav-btn" onclick="toggleFullscreen('leftPane')" title="Diesen Editor im Vollbild anzeigen">⤢ <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 ändern - Doppelklick setzt sie zurü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)"><</button>
|
|
<button id="btnCopyLeftToRight" title="Inhalt von links nach rechts kopieren (1:1)">></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ösen und nach links übertragen"><</button>
|
|
<button id="btnTransLeftToRight" title="Links verschachtelte Objekte zu flachen Feldern auflösen und nach rechts übertragen">></button>
|
|
</div>
|
|
<span class="center-hint" onclick="openHelpModal('flatten')" title="Was macht Flatten? Hier klicken für die Erklärung">❔</span>
|
|
</div>
|
|
<div class="center-section">
|
|
<span class="eyebrow">Vergleich</span>
|
|
<button id="btnCompare" title="Beide Dokumente Feld für Feld vergleichen (Strg+Enter)">☐ 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">✎</span>
|
|
</div>
|
|
<div class="bar-actions">
|
|
<button class="nav-btn" onclick="clearEditor('right')" title="Editor leeren und neu beginnen">📄 <span class="btn-text">Neu</span></button>
|
|
|
|
<div class="dropdown">
|
|
<button class="dropdown-btn" title="Daten aus einer Datei, URL oder Datenbank einlesen">📁 <span class="btn-text">Öffnen</span> ▾</button>
|
|
<div class="dropdown-content">
|
|
<div class="group-label">Datei einlesen</div>
|
|
<label title="Eine JSON- oder Textdatei laden">📄 JSON / Text (.json, .txt)<input type="file" accept=".json,.txt" onchange="importJsonFile(event, 'right')" hidden></label>
|
|
<label title="Tabelle aus einer CSV-Datei laden">📊 CSV (.csv)<input type="file" accept=".csv" onchange="importCsvFile(event, 'right')" hidden></label>
|
|
<label title="Excel-Arbeitsmappe laden - mehrere Blätter werden zu einem Objekt">📈 Excel (.xlsx, .xls)<input type="file" accept=".xlsx,.xls" onchange="importExcelFile(event, 'right')" hidden></label>
|
|
<label title="YAML-Datei laden, auch mit mehreren Dokumenten">📑 YAML (.yaml, .yml)<input type="file" accept=".yaml,.yml" onchange="importYamlFile(event, 'right')" hidden></label>
|
|
<label title="XML laden - Attribute werden zu @-Feldern">🧱 XML (.xml)<input type="file" accept=".xml" onchange="importXmlFile(event, 'right')" hidden></label>
|
|
<label title="SQL-Dump mit INSERT-Anweisungen wieder in JSON zurückverwandeln">🗃️ 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">🌐 Von URL laden…</a>
|
|
<a href="#" onclick="openDbLoadModal('right'); return false;" title="Tabelle aus einer angebundenen Datenbank laden">🗃️ Aus Datenbank laden…</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dropdown">
|
|
<button class="dropdown-btn" title="Daten als Datei speichern oder weiterverarbeiten">💾 <span class="btn-text">Export</span> ▾</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">📄 JSON (.json)</a>
|
|
<a href="#" onclick="exportCsv('right'); return false;" title="Als CSV speichern - Trennzeichen in den Einstellungen">📊 CSV (.csv)</a>
|
|
<a href="#" onclick="exportExcel('right'); return false;" title="Als Excel-Arbeitsmappe speichern">📈 Excel (.xlsx)</a>
|
|
<a href="#" onclick="exportYaml('right'); return false;" title="Als YAML speichern">📑 YAML (.yaml)</a>
|
|
<a href="#" onclick="exportXml('right'); return false;" title="Als XML speichern - Tag-Namen in den Einstellungen">🧱 XML (.xml)</a>
|
|
<div class="divider"></div>
|
|
<div class="group-label">Erzeugen & Übertragen</div>
|
|
<a href="#" onclick="openSqlModal('right'); return false;" title="CREATE TABLE und INSERTs für MariaDB erzeugen">🗃️ MariaDB Tabelle & SQL</a>
|
|
<a href="#" onclick="openSchemaModal('right'); return false;" title="Aus den Daten ein JSON-Schema ableiten">📜 JSON-Schema ableiten</a>
|
|
<a href="#" onclick="openDbWriteModal('right'); return false;" title="Daten in eine angebundene Datenbank schreiben">📤 In Datenbank schreiben…</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dropdown">
|
|
<button class="dropdown-btn" title="Zwischenablage, Snippets und Webhook">🔧 <span class="btn-text">Werkzeuge</span> ▾</button>
|
|
<div class="dropdown-content">
|
|
<div class="group-label">Zwischenablage</div>
|
|
<a href="#" onclick="copyToClip('right', 'formatted'); return false;" title="Eingerückter JSON-Text">📋 Formatierter JSON-Text</a>
|
|
<a href="#" onclick="copyToClip('right', 'compact'); return false;" title="JSON ohne Leerzeichen und Umbrüche">📋 Kompakter JSON-Text</a>
|
|
<a href="#" onclick="copyToClip('right', 'csv'); return false;" title="Als CSV in die Zwischenablage">📋 Als CSV</a>
|
|
<a href="#" onclick="copyToClip('right', 'yaml'); return false;" title="Als YAML in die Zwischenablage">📋 Als YAML</a>
|
|
<div class="divider"></div>
|
|
<div class="group-label">Weiteres</div>
|
|
<a href="#" onclick="openSnippetModal('right'); return false;" title="Häufig gebrauchte Vorlagen speichern und einfügen">⭐ Snippet-Bibliothek…</a>
|
|
<a href="#" onclick="openWebhookModal('right'); return false;" title="Inhalt per POST an einen Webhook senden, z. B. n8n">🚀 An Webhook senden…</a>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="nav-btn" onclick="toggleFullscreen('rightPane')" title="Diesen Editor im Vollbild anzeigen">⤢ <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 & Quellcode</a>
|
|
<a href="#" onclick="openHelpModal(); return false;">Anleitung</a>
|
|
</div>
|
|
<div>Alle Daten bleiben in deinem Browser · 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ßen (Esc)">×</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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
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 ? '☀️' : '🌙';
|
|
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>) – 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('🌐 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
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">📊 Visuelle MariaDB Tabellen-Vorschau</div>
|
|
${htmlTable}
|
|
<div class="modal-section-title">📄 Generiertes SQL-Skript</div>
|
|
<pre class="sql-code">${escapeHtml(sql)}</pre>`;
|
|
|
|
window.openModal('🗃️ 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">📜 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 –
|
|
Wertebereiche, Muster und Beschreibungen ergänzt du bei Bedarf selbst.
|
|
</div>
|
|
<pre class="sql-code">${escapeHtml(schemaText)}</pre>`;
|
|
|
|
window.openModal('📜 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)} · ${s.size} Zeichen · ${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ügen</button>
|
|
<button class="btn-mini danger" onclick="deleteSnippet('${s.id}')" title="Snippet dauerhaft entfernen">Löschen</button>
|
|
</div>
|
|
</div>`).join('');
|
|
}
|
|
|
|
const body = `
|
|
<div class="modal-section-title">⭐ 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üst">
|
|
<div class="field-hint">
|
|
Wird im Browser gespeichert und steht in beiden Editoren zur Verfügung.
|
|
Eingefügt wird immer in „${escapeHtml(docName(side))}“.
|
|
</div>
|
|
</div>`;
|
|
|
|
window.openModal('⭐ 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">– noch nichts gesendet –</pre>
|
|
</div>`;
|
|
window.openModal('🚀 An Webhook senden – ' + 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ücke, die die eigentliche Datenbankverbindung herstellt – wie das funktioniert, steht in der Anleitung unter „Datenbank“.</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 ? ' · DB: ' + escapeHtml(p.database) : ''}</div>
|
|
</div>
|
|
<div class="list-actions">
|
|
<button class="btn-mini" onclick="testProfile('${p.id}')" title="Verbindung prüfen und Tabellen abrufen">Testen</button>
|
|
<button class="btn-mini danger" onclick="deleteProfile('${p.id}')" title="Profil entfernen">Löschen</button>
|
|
</div>
|
|
</div>`).join('');
|
|
}
|
|
|
|
const body = `
|
|
<div class="modal-section-title">🗃️ 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ü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ü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öglichkeit einen eigenen
|
|
Datenbankbenutzer mit eingeschränkten Rechten statt eines Administratorkontos.
|
|
</div>
|
|
</div>`;
|
|
|
|
window.openModal('🗃️ 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="">– Tabellen werden geladen –</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ücke auf dem Server – die mitgelieferte lässt nur SELECT zu.
|
|
</div>
|
|
</div>
|
|
<div id="dbLoadResult"></div>`;
|
|
|
|
window.openModal('🗃️ Aus Datenbank laden → ' + 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="">– Tabellen werden geladen –</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="">– keine Tabellen gefunden –</option>';
|
|
} catch (err) {
|
|
sel.innerHTML = '<option value="">– Abruf fehlgeschlagen –</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 „${escapeHtml(docName(side))}“ werden ü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ängen – vorhandene Zeilen bleiben unberührt</option>
|
|
<option value="replace">Ersetzen – Tabelle vorher leeren (TRUNCATE)</option>
|
|
<option value="upsert">Aktualisieren – vorhandene Zeilen per Schlüssel überschreiben</option>
|
|
</select>
|
|
<div class="field-hint">
|
|
„Ersetzen“ lö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 – Struktur aus den Daten ableiten</option>
|
|
<option value="no">Nein – Fehler melden, wenn die Tabelle fehlt</option>
|
|
</select>
|
|
</div>
|
|
<div id="dbWriteResult"></div>`;
|
|
|
|
window.openModal('📤 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">📊 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="	"${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="	"${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 – mit Komma landet sonst alles in einer Spalte.
|
|
Exporte enthalten zusätzlich ein BOM, damit Umlaute richtig ankommen.
|
|
</div>
|
|
|
|
<div class="modal-section-title">🧱 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ür Listeneinträge</label>
|
|
<input type="text" id="setXmlItem" value="${escapeHtml(s.xmlItemName)}">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-section-title">🚀 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/…">
|
|
</div>
|
|
|
|
<div class="modal-section-title">💾 Sicherung & 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äßig abgewählt, weil sie API-Schlü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äge werden ergänzt, nicht gelöscht. Snippets und Profile mit gleichem Namen kommen zusätzlich hinzu.</div>
|
|
</div>`;
|
|
|
|
window.openModal('⚙️ 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 & 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 & 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ände zu
|
|
vergleichen, Daten von einem Format ins andere zu überführen oder ein Schema neben den
|
|
Rohdaten zu haben. Der zuletzt angeklickte Editor ist der „aktive“ – seine Leiste ist
|
|
etwas heller, und die Tastaturkürzel wirken auf ihn.</p>
|
|
|
|
<h4>Der Dokumentname ist wichtiger, als er aussieht</h4>
|
|
<p>Der Name oben links („Dokument 1“) 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 – außer du benutzt bewusst URL-Import, Webhook oder Datenbank.</p>
|
|
<p>Weil der Speicher am Browser hängt: Beim Wechsel auf einen anderen Rechner nimmst du
|
|
deine Konfiguration über <b>Einstellungen → Sicherung exportieren</b> mit.</p>
|
|
|
|
<h4>Die drei Ansichten des Editors</h4>
|
|
<ul>
|
|
<li><b>tree</b> – Baumansicht zum bequemen Klicken und Bearbeiten</li>
|
|
<li><b>text</b> – roher JSON-Text, gut zum Einfügen größerer Mengen</li>
|
|
<li><b>table</b> – 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> – lässt sich der Text nicht als JSON lesen, landet er unverändert im Texteditor, damit du ihn reparieren kannst.</li>
|
|
<li><b>CSV</b> – die erste Zeile gilt als Überschrift. Zahlen werden automatisch als Zahlen erkannt.</li>
|
|
<li><b>Excel</b> – bei mehreren Tabellenblättern entsteht ein Objekt mit einem Eintrag je Blatt.</li>
|
|
<li><b>YAML</b> – auch Dateien mit mehreren Dokumenten (getrennt durch <code>---</code>).</li>
|
|
<li><b>XML</b> – Attribute werden zu Feldern mit <code>@</code>-Präfix, gemischter Text landet unter <code>#text</code>, wiederholte Tags werden zu Listen.</li>
|
|
<li><b>SQL-Dump</b> – liest <code>INSERT</code>-Anweisungen zurück in JSON. Spaltennamen kommen aus der INSERT-Zeile oder ersatzweise aus <code>CREATE TABLE</code>.</li>
|
|
<li><b>URL</b> – lädt direkt von einer Adresse. Das Format wird am Inhaltstyp erkannt.</li>
|
|
</ul>
|
|
|
|
<h4>Was hinausgeht</h4>
|
|
<ul>
|
|
<li><b>CSV / Excel</b> – 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> – Felder mit <code>@</code> werden wieder zu Attributen. Wurzel- und Listen-Tag stellst du in den Einstellungen ein.</li>
|
|
<li><b>MariaDB</b> – erzeugt <code>CREATE TABLE</code> samt <code>INSERT</code>s. Die Spaltentypen werden aus den Werten geraten; prüfe sie, bevor du das Skript produktiv einspielst.</li>
|
|
<li><b>JSON-Schema</b> – 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ättest,
|
|
benutze vorher <b>Flatten</b> in der Mitte.</p>
|
|
</div>
|
|
|
|
<div class="help-panel" data-panel="flatten">
|
|
<h4>Kopieren (< >)</h4>
|
|
<p>Überträgt den Inhalt unverändert von einer Seite auf die andere. Der Pfeil zeigt die
|
|
Richtung: <code>></code> schiebt von links nach rechts.</p>
|
|
|
|
<h4>Flatten (< >) – wofür ist das gut?</h4>
|
|
<p>Flatten lö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 – also fü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öst. Listen <i>innerhalb</i> eines
|
|
Objekts bleiben unangetastet – dafü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üft beide Dokumente Feld für Feld und listet jeden Unterschied mit seinem Pfad auf –
|
|
inklusive Feldern, die nur auf einer Seite existieren. Den Bericht kannst du als Markdown-Tabelle
|
|
oder CSV mitnehmen.</p>
|
|
|
|
<h4>Aufteilung ändern</h4>
|
|
<p>Die mittlere Leiste lässt sich mit gedrückter Maustaste verschieben. Ein Doppelklick
|
|
darauf stellt die Hälfte-Hälfte-Aufteilung wieder her.</p>
|
|
</div>
|
|
|
|
<div class="help-panel" data-panel="db">
|
|
<h4>Warum eine Brücke nötig ist</h4>
|
|
<p>Ein Browser kann sich nicht direkt mit MariaDB oder MySQL verbinden – ihm fehlt der
|
|
Zugriff auf rohe Netzwerkverbindungen, und das ist auch gut so: Sonst mü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ücke auf dem Server ablegen und Zugangsdaten dort eintragen – 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 – erscheint die Tabellenliste, steht die Verbindung.</li>
|
|
</ul>
|
|
|
|
<h4>Das Protokoll</h4>
|
|
<p>Die Brücke bekommt ein JSON per <code>POST</code> und antwortet mit JSON:</p>
|
|
<pre class="sql-code">{"action":"tables"} → {"tables":["kunden","artikel"]}
|
|
{"action":"select","table":"kunden","limit":500} → {"rows":[{...}]}
|
|
{"action":"query","sql":"SELECT ...","limit":500} → {"rows":[{...}]}
|
|
{"action":"insert","table":"kunden",
|
|
"rows":[{...}],"mode":"append"} → {"inserted":42}</pre>
|
|
<p>Ein optionaler API-Schlüssel wird als Header <code>X-Api-Key</code> mitgeschickt.
|
|
Im Fehlerfall antwortet die Brücke mit <code>{"error":"Text"}</code>.</p>
|
|
|
|
<h4>Bitte vorsichtig damit</h4>
|
|
<ul>
|
|
<li>Der API-Schlüssel liegt im Browser-Speicher – also im Klartext auf deinem Rechner.
|
|
Verwende einen eigenen Datenbankbenutzer mit genau den nötigen Rechten, nicht <code>root</code>.</li>
|
|
<li>Die Schreibweise <b>Ersetzen</b> leert die Zieltabelle vollständig. Vorher sichern.</li>
|
|
<li>Beim Anlegen einer Tabelle werden die Spaltentypen aus den Daten geraten. Fü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ä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 über die Adressen, die im Kopf der HTML-Datei als Kommentar
|
|
stehen (direkt daneben notiert) – einfach im Browser aufrufen und mit
|
|
„Speichern unter“ unter dem genannten Namen ablegen. Der JSON-Editor bringt seine
|
|
Stile seit Version 3 direkt im Skript mit, eine separate CSS-Datei ist dafür nicht
|
|
mehr nötig.</p>
|
|
|
|
<h4>Ein Haken, den du kennen solltest</h4>
|
|
<p>Der JSON-Editor ist ein ES-Modul. Browser verbieten aus Sicherheitsgründen, solche
|
|
Module von <code>file://</code> zu laden. Per Doppelklick aus dem Dateisystem geöffnet,
|
|
holt sich die Seite den Editor deshalb weiterhin aus dem Netz – auch wenn die lokale
|
|
Datei vorhanden ist.</p>
|
|
<p>Damit der Offline-Betrieb wirklich greift, muss der Ordner ü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ügen. Oben siehst du jederzeit,
|
|
welche Quelle tatsächlich benutzt wurde.</p>
|
|
<p>Die Version des JSON-Editors ist bewusst fest eingetragen (aktuell 3.11.0) statt
|
|
„immer die neueste“ zu laden – eine unversionierte Adresse kann sich
|
|
unangekündigt ändern und Dateien umbenennen oder entfernen, wie es mit der
|
|
früheren <code>standalone.css</code> passiert ist.</p>
|
|
</div>
|
|
|
|
<div class="help-panel" data-panel="lizenzen">
|
|
<h4>Diese Anwendung</h4>
|
|
<p>Veröffentlicht unter der MIT-Lizenz. Du darfst sie frei verwenden, verändern
|
|
und weitergeben, solange der Copyright- und Lizenzhinweis erhalten bleibt. Sie wird
|
|
ohne Gewährleistung bereitgestellt.</p>
|
|
<p>Quellcode: <a href="#" onclick="return false;">im Repository hinterlegt</a>
|
|
– die Adresse trägst du hier im Quelltext ein, sobald das Repository steht.</p>
|
|
|
|
<h4>Verwendete Bibliotheken</h4>
|
|
<ul>
|
|
<li><b>vanilla-jsoneditor</b> (ISC) – der JSON-Editor selbst, von Jos de Jong</li>
|
|
<li><b>PapaParse</b> (MIT) – CSV lesen und schreiben, von Matthew Holt</li>
|
|
<li><b>js-yaml</b> (MIT) – YAML lesen und schreiben, von Vladimir Zapparov,
|
|
Vitaly Puzrin und Mitwirkenden</li>
|
|
<li><b>SheetJS Community Edition</b> (Apache-2.0) – Excel-Dateien, von SheetJS LLC</li>
|
|
</ul>
|
|
<p>Die vollständigen Lizenztexte liegen dem Quellcode bei. Wenn du die Anwendung
|
|
weitergibst, müssen sie mitgeliefert werden – bei Apache-2.0 zusätzlich
|
|
der Hinweis auf etwaige Ä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 ü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 übersteht, werden Inhalte, Namen, Einstellungen,
|
|
Snippets und Datenbank-Profile im lokalen Speicher deines Browsers abgelegt –
|
|
ausschließlich auf deinem Gerät. Über die Website-Einstellungen deines
|
|
Browsers kannst du sie jederzeit löschen.</p>
|
|
<p>Drei Funktionen senden bewusst Daten nach drauß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änden
|
|
Zugangsschlüssel, die unverschlüsselt im Browser-Speicher liegen. Auf einem
|
|
gemeinsam genutzten Rechner solltest du sie nicht speichern.</p>
|
|
</div>`;
|
|
|
|
window.openModal('❔ 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ürzel wirken auf den zuletzt angeklickten Editor – erkennbar an der etwas helleren Leiste.
|
|
Innerhalb des Editors gelten zusätzlich dessen eigene Kürzel, etwa Strg+F fü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>⏎</kbd></td><td>Beide Dokumente vergleichen</td></tr>
|
|
<tr><td><kbd>Strg</kbd> + <kbd>K</kbd></td><td>Snippet-Bibliothek öffnen</td></tr>
|
|
<tr><td><kbd>Strg</kbd> + <kbd>M</kbd></td><td>MariaDB-Tabelle & 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>⇧</kbd> + <kbd>S</kbd></td><td>JSON-Schema ableiten</td></tr>
|
|
<tr><td><kbd>Strg</kbd> + <kbd>⇧</kbd> + <kbd>E</kbd></td><td>Als Excel exportieren</td></tr>
|
|
<tr><td><kbd>Strg</kbd> + <kbd>⇧</kbd> + <kbd>D</kbd></td><td>Dunkel-/Hellmodus umschalten</td></tr>
|
|
<tr><td><kbd>Strg</kbd> + <kbd>/</kbd></td><td>Diese Übersicht</td></tr>
|
|
<tr><td><kbd>F1</kbd></td><td>Anleitung öffnen</td></tr>
|
|
<tr><td><kbd>Esc</kbd></td><td>Dialog schließen</td></tr>
|
|
</table>`;
|
|
window.openModal('⌨ Tastaturkü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">✓ 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('🔍 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>
|