3169 lines
150 KiB
HTML
3169 lines
150 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 {
|
||
/* Grundfarben. --jse-background-color ist die wichtigste hier: Sie ist
|
||
der Hintergrund, auf den viele andere Variablen zurückfallen, wenn sie
|
||
selbst nicht gesetzt sind (u. a. die Box, die beim Umbenennen eines
|
||
Feldes erscheint - ohne diese Variable blieb sie hell). */
|
||
--jse-theme: dark !important;
|
||
--jse-background-color: #16171a !important;
|
||
/* Text/Tree/Table-Leiste an die App-eigene .top-bar angleichen (dunkel,
|
||
kein Akzentorange - siehe Kommentar weiter oben bei .editor-wrapper) */
|
||
--jse-theme-color: #1c1d21 !important;
|
||
--jse-theme-color-highlight: #24252b !important;
|
||
--jse-text-color: #e7e8ea !important;
|
||
--jse-text-readonly: #8b8d93 !important;
|
||
--jse-text-color-inverse: #e7e8ea !important;
|
||
--jse-error-color: #e5484d !important;
|
||
--jse-warning-color: #f7b267 !important;
|
||
--jse-info-color: #6cb6ff !important;
|
||
|
||
--jse-main-border: 1px solid #2a2b30 !important;
|
||
--jse-menu-color: #e7e8ea !important;
|
||
--jse-modal-background: #16171a !important;
|
||
--jse-overlay-background: rgba(0,0,0,0.6) !important;
|
||
--jse-modal-code-background: rgba(255,255,255,0.05) !important;
|
||
|
||
--jse-tooltip-color: #e7e8ea !important;
|
||
--jse-tooltip-background: #1c1d21 !important;
|
||
|
||
--jse-panel-background: #1c1d21 !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;
|
||
--panel-button-color-highlight: #ffffff !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-color-disabled: #55565c !important;
|
||
--jse-context-menu-separator-color: #2a2b30 !important;
|
||
--jse-context-menu-pointer-hover-background: #33343b !important;
|
||
--jse-context-menu-pointer-background: #1c1d21 !important;
|
||
--jse-context-menu-pointer-background-highlight: #26272d !important;
|
||
--jse-context-menu-pointer-color: #e7e8ea !important;
|
||
|
||
/* Schlüssel/Werte im Baum */
|
||
--jse-key-color: #6cb6ff !important;
|
||
--jse-value-color: #e7e8ea !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-value-color-url: #8fd1ff !important;
|
||
--jse-delimiter-color: #55565c !important;
|
||
--jse-edit-outline: 2px solid #6cb6ff !important;
|
||
|
||
/* Ausgewählt/gehovert - hier lag der gemeldete Fehler: Ein editiertes
|
||
Feld bekommt eine eigene Box auf Basis von --jse-background-color
|
||
(siehe oben) statt einer transparenten Fläche; ohne die beiden
|
||
folgenden Variablen blieb außerdem eine früher ausgewählte, aber
|
||
gerade inaktive Zeile hell. */
|
||
--jse-contents-background-color: transparent !important;
|
||
--jse-selection-background-color: #2a3340 !important;
|
||
--jse-selection-background-inactive-color: #20222a !important;
|
||
--jse-hover-background-color: #22232a !important;
|
||
--jse-active-line-background-color: rgba(255,255,255,0.04) !important;
|
||
--jse-search-match-background-color: rgba(255,214,51,0.30) !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-search-match-color: #16171a !important;
|
||
--jse-search-match-outline: 2px solid #ffd633 !important;
|
||
--jse-search-match-active-color: #16171a !important;
|
||
|
||
--jse-tag-background: #24252b !important;
|
||
--jse-tag-color: #e7e8ea !important;
|
||
|
||
/* Tabellen-Ansicht */
|
||
--jse-table-header-background: #1c1d21 !important;
|
||
--jse-table-header-background-highlight: #24252b !important;
|
||
--jse-table-row-odd-background: rgba(255,255,255,0.03) !important;
|
||
|
||
/* Eingaben und Buttons in den eigenen Dialogen des Editors */
|
||
--jse-input-background: #101114 !important;
|
||
--jse-input-background-readonly: rgba(255,255,255,0.03) !important;
|
||
--jse-input-color: #e7e8ea !important;
|
||
--jse-input-border: 1px solid #2a2b30 !important;
|
||
--jse-input-border-focus: 1px solid #6cb6ff !important;
|
||
--jse-button-background: #26272d !important;
|
||
--jse-button-background-highlight: #33343b !important;
|
||
--jse-button-color: #e7e8ea !important;
|
||
--jse-button-secondary-background: #26272d !important;
|
||
--jse-button-secondary-background-highlight: #33343b !important;
|
||
--jse-button-secondary-color: #e7e8ea !important;
|
||
--jse-a-color: #6cb6ff !important;
|
||
--jse-a-color-highlight: #8fcaff !important;
|
||
|
||
/* Meldungen (Fehler/Erfolg/Warnung/Info-Banner im Editor) */
|
||
--jse-message-info-background: #1c1d21 !important;
|
||
--jse-message-info-color: #8fcaff !important;
|
||
--jse-message-success-background: #163524 !important;
|
||
--message-success-background: #163524 !important;
|
||
--jse-message-success-color: #6bd8a1 !important;
|
||
--jse-message-warning-background: #3a2f10 !important;
|
||
--jse-message-warning-color: #f7c948 !important;
|
||
--jse-message-error-background: #3a1414 !important;
|
||
--jse-message-error-color: #ff9a9a !important;
|
||
--jse-message-action-background: rgba(255,255,255,0.12) !important;
|
||
--jse-message-action-background-highlight: rgba(255,255,255,0.2) !important;
|
||
|
||
--jse-svelte-select-background: #16171a !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); }
|
||
|
||
/* Spalten-Typ-Editor im SQL-Dialog */
|
||
.coltype-editor {
|
||
border: 1px solid var(--border-color); border-radius: 8px;
|
||
padding: 10px 14px; margin-bottom: 16px; background: var(--panel-bg);
|
||
}
|
||
.coltype-editor summary {
|
||
cursor: pointer; font-weight: 700; font-size: 13px; padding: 4px 0;
|
||
list-style: none;
|
||
}
|
||
.coltype-editor summary::-webkit-details-marker { display: none; }
|
||
.coltype-editor summary::before { content: '\25b8\0020'; }
|
||
.coltype-editor[open] summary::before { content: '\25be\0020'; }
|
||
.coltype-row {
|
||
display: flex; align-items: flex-start; gap: 10px; flex-wrap: wrap;
|
||
padding: 10px 0; border-top: 1px solid var(--border-color);
|
||
}
|
||
.coltype-name { font-weight: 700; font-size: 12.5px; min-width: 130px; padding-top: 7px; flex-shrink: 0; }
|
||
.coltype-badge {
|
||
font-size: 10px; font-weight: 700; color: var(--accent); background: rgba(255,122,26,0.12);
|
||
border-radius: 10px; padding: 1px 7px; margin-left: 4px; white-space: nowrap;
|
||
}
|
||
.coltype-row select { flex: 1 1 190px; min-width: 170px; }
|
||
.coltype-row input.coltype-rename { flex: 1 1 150px; min-width: 130px; }
|
||
.coltype-row input.coltype-length { width: 110px; flex-shrink: 0; }
|
||
.coltype-row select, .coltype-row input[type="text"] {
|
||
padding: 6px 8px; border-radius: 6px; border: 1px solid var(--border-color);
|
||
background: var(--card-bg); color: var(--text-color); font-size: 12.5px; font-family: inherit;
|
||
}
|
||
.coltype-row select:focus, .coltype-row input:focus { outline: none; border-color: var(--accent); }
|
||
.coltype-row input:disabled { opacity: 0.45; }
|
||
.coltype-hint { flex-basis: 100%; font-size: 11.5px; color: var(--text-muted); line-height: 1.5; margin-top: 2px; }
|
||
|
||
/* Primärschlüssel-Auswahl (auch mehrere Spalten = zusammengesetzter Schlüssel) */
|
||
.pk-checklist {
|
||
display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 8px;
|
||
padding: 10px 12px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--panel-bg);
|
||
}
|
||
.pk-check { display: flex; align-items: center; gap: 6px; font-size: 12.5px; cursor: pointer; white-space: nowrap; }
|
||
.pk-check input { width: auto; margin: 0; accent-color: var(--accent); cursor: pointer; }
|
||
|
||
/* Zuletzt geöffnete Dateien im Öffnen-Menü */
|
||
.recent-file-item {
|
||
padding: 6px 14px; font-size: 11.5px; color: var(--text-muted);
|
||
display: flex; align-items: center; gap: 7px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
|
||
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 id="recentFilesLeft"></div>
|
||
</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="window.copyJsonPath('left')" title="Pfad des zuletzt angeklickten Feldes kopieren (z. B. für n8n-Ausdrücke)">📍 <span class="btn-text">Pfad</span></button>
|
||
<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 id="recentFilesRight"></div>
|
||
</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="window.copyJsonPath('right')" title="Pfad des zuletzt angeklickten Feldes kopieren (z. B. für n8n-Ausdrücke)">📍 <span class="btn-text">Pfad</span></button>
|
||
<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,
|
||
autoSaveEnabled: false,
|
||
autoSaveIntervalMinutes: 10
|
||
};
|
||
|
||
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();
|
||
|
||
/* Merkt sich Primärschlüssel-Wahl, manuelle Spaltentypen und Spalten-Umbenennungen
|
||
aus dem MariaDB-Dialog getrennt für den linken und rechten Editor, damit sie
|
||
beim erneuten Öffnen des Dialogs für dasselbe Dokument erhalten bleiben. */
|
||
window.sqlColumnPrefs = {};
|
||
function loadSqlPrefs(side) {
|
||
if (window.sqlColumnPrefs[side]) return window.sqlColumnPrefs[side];
|
||
try {
|
||
const raw = localStorage.getItem('wb_sql_prefs_' + side);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
// Ältere Version speicherte "pk" als einzelnen Spaltennamen statt als Liste.
|
||
if (typeof parsed.pk === 'string') parsed.pk = [parsed.pk];
|
||
if (!Array.isArray(parsed.pk)) parsed.pk = [];
|
||
if (!parsed.overrides) parsed.overrides = {};
|
||
if (!parsed.renames) parsed.renames = {};
|
||
window.sqlColumnPrefs[side] = parsed;
|
||
return parsed;
|
||
}
|
||
} catch (e) { /* ignoriert */ }
|
||
window.sqlColumnPrefs[side] = { pk: [], overrides: {}, renames: {} };
|
||
return window.sqlColumnPrefs[side];
|
||
}
|
||
function saveSqlPrefs(side) {
|
||
try { localStorage.setItem('wb_sql_prefs_' + side, JSON.stringify(window.sqlColumnPrefs[side])); }
|
||
catch (e) { /* ignoriert */ }
|
||
}
|
||
/** Aktuellen Stand des Dialogs (Primärschlüssel, Typen, Umbenennungen) sichern. */
|
||
function persistSqlPrefs(st) {
|
||
window.sqlColumnPrefs[st.side] = { pk: st.pk, overrides: st.overrides, renames: st.renames };
|
||
saveSqlPrefs(st.side);
|
||
}
|
||
window.resetSqlPrefs = function(side) {
|
||
window.sqlColumnPrefs[side] = { pk: [], overrides: {}, renames: {} };
|
||
try { localStorage.removeItem('wb_sql_prefs_' + side); } catch (e) { /* ignoriert */ }
|
||
};
|
||
|
||
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.lastSelectionPath = { left: null, right: null };
|
||
|
||
/** Wandelt einen JSON-Pfad (Liste aus Schl\u00fcsseln/Indizes) in eine lesbare
|
||
* Notation wie "Positionen[2].Preis" um, wie sie in n8n-Ausdr\u00fccken \u00fcblich ist. */
|
||
function formatJsonPath(path) {
|
||
if (!path || path.length === 0) return '(Wurzel)';
|
||
let out = '';
|
||
path.forEach((seg, i) => {
|
||
if (typeof seg === 'number') out += `[${seg}]`;
|
||
else if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(String(seg))) out += (i === 0 ? seg : '.' + seg);
|
||
else out += `["${String(seg).replace(/"/g, '\\"')}"]`;
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function selectionToPath(selection) {
|
||
if (!selection) return null;
|
||
if (Array.isArray(selection.path)) return selection.path;
|
||
if (Array.isArray(selection.paths) && selection.paths.length) return selection.paths[0];
|
||
if (Array.isArray(selection.focusPath)) return selection.focusPath;
|
||
return null;
|
||
}
|
||
|
||
window.copyJsonPath = function(side) {
|
||
const path = window.lastSelectionPath[side];
|
||
if (!path) return window.showToast('Bitte zuerst ein Feld im Baum anklicken.', 'error');
|
||
const text = formatJsonPath(path);
|
||
navigator.clipboard.writeText(text).then(() => window.showToast('Pfad kopiert: ' + text, 'success'));
|
||
};
|
||
|
||
window.editorLeft = createJSONEditor({
|
||
target: document.getElementById('jsoneditorLeft'),
|
||
props: {
|
||
content: loadSavedContent('wb_left_content'), mode: 'tree',
|
||
onChange: () => window.saveAppState(),
|
||
onSelect: (selection) => { window.lastSelectionPath.left = selectionToPath(selection); }
|
||
}
|
||
});
|
||
window.editorRight = createJSONEditor({
|
||
target: document.getElementById('jsoneditorRight'),
|
||
props: {
|
||
content: loadSavedContent('wb_right_content'), mode: 'tree',
|
||
onChange: () => window.saveAppState(),
|
||
onSelect: (selection) => { window.lastSelectionPath.right = selectionToPath(selection); }
|
||
}
|
||
});
|
||
|
||
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);
|
||
|
||
// "Zuletzt geöffnet"-Liste beim Start einmal befüllen
|
||
renderRecentFiles('left');
|
||
renderRecentFiles('right');
|
||
|
||
/* =========================================================
|
||
Automatische Sicherung als Download (optional, standardmäßig aus)
|
||
========================================================= */
|
||
|
||
window.autoSaveState = { left: { lastHash: null }, right: { lastHash: null } };
|
||
window.autoSaveTimer = null;
|
||
|
||
function autoSaveTick() {
|
||
['left', 'right'].forEach(side => {
|
||
const data = window.getJsonData(side);
|
||
if (data === null) return; // leer oder kaputtes JSON - nichts zu sichern
|
||
let text;
|
||
try { text = JSON.stringify(data); } catch (e) { return; }
|
||
if (text === '{}' || text === '[]' || text === '""') return; // praktisch leer
|
||
|
||
const state = window.autoSaveState[side];
|
||
if (state.lastHash === text) return; // seit der letzten Sicherung nichts geändert
|
||
state.lastHash = text;
|
||
|
||
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16);
|
||
const filename = docName(side) + '_autosave_' + stamp + '.json';
|
||
downloadBlob(JSON.stringify(data, null, 2), filename, 'application/json');
|
||
window.showToast('Automatisch gesichert: ' + filename);
|
||
});
|
||
}
|
||
|
||
/** Startet/stoppt den Hintergrund-Timer passend zu den aktuellen Einstellungen.
|
||
* Wird beim Start und nach jeder Änderung in den Einstellungen aufgerufen. */
|
||
window.restartAutoSaveTimer = function() {
|
||
if (window.autoSaveTimer) { clearInterval(window.autoSaveTimer); window.autoSaveTimer = null; }
|
||
if (!window.wbSettings.autoSaveEnabled) return;
|
||
const minutes = Math.max(1, parseInt(window.wbSettings.autoSaveIntervalMinutes, 10) || 10);
|
||
window.autoSaveTimer = setInterval(autoSaveTick, minutes * 60 * 1000);
|
||
};
|
||
window.restartAutoSaveTimer();
|
||
|
||
/* ===========================================================
|
||
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);
|
||
}
|
||
|
||
/** Setzt den Dokumentnamen oben auf den Dateinamen ohne Endung, damit sich
|
||
* eine geöffnete Datei nach dem Bearbeiten direkt wieder unter demselben
|
||
* Namen speichern lässt, und merkt sie sich in der "Zuletzt geöffnet"-Liste. */
|
||
function setTitleFromFilename(side, filename) {
|
||
const base = filename.replace(/\.[^./\\]+$/, '').trim();
|
||
if (base) {
|
||
document.getElementById(titleId(side)).innerText = base;
|
||
window.saveAppState();
|
||
}
|
||
recordRecentFile(side, filename);
|
||
}
|
||
|
||
function fileIconFor(filename) {
|
||
const ext = (filename.split('.').pop() || '').toLowerCase();
|
||
const map = { json: '\u{1F4C4}', csv: '\u{1F4CA}', xlsx: '\u{1F4C8}', xls: '\u{1F4C8}',
|
||
yaml: '\u{1F4D1}', yml: '\u{1F4D1}', xml: '\u{1F9EC}', sql: '\u{1F5C3}\uFE0F' };
|
||
return map[ext] || '\u{1F4C4}';
|
||
}
|
||
|
||
/** Merkt sich die letzten 5 pro Editor geöffneten Dateinamen (nur der Name,
|
||
* nicht der Inhalt) als kleine Erinnerungsstütze im "Öffnen"-Menü. */
|
||
function recordRecentFile(side, filename) {
|
||
const key = 'wb_recent_' + side;
|
||
let list = [];
|
||
try { list = JSON.parse(localStorage.getItem(key) || '[]'); } catch (e) { list = []; }
|
||
list = list.filter(f => f.name !== filename);
|
||
list.unshift({ name: filename, date: new Date().toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) });
|
||
list = list.slice(0, 5);
|
||
try { localStorage.setItem(key, JSON.stringify(list)); } catch (e) { /* ignoriert */ }
|
||
renderRecentFiles(side);
|
||
}
|
||
|
||
function renderRecentFiles(side) {
|
||
const container = document.getElementById(side === 'right' ? 'recentFilesRight' : 'recentFilesLeft');
|
||
if (!container) return;
|
||
let list = [];
|
||
try { list = JSON.parse(localStorage.getItem('wb_recent_' + side) || '[]'); } catch (e) { list = []; }
|
||
if (list.length === 0) { container.innerHTML = ''; return; }
|
||
container.innerHTML = '<div class="divider"></div><div class="group-label">Zuletzt ge\u00f6ffnet</div>' +
|
||
list.map(f => '<div class="recent-file-item" title="' + escapeHtml(f.name) + ' \u00b7 ' + escapeHtml(f.date) + '">' +
|
||
fileIconFor(f.name) + ' ' + escapeHtml(f.name) + '</div>').join('');
|
||
}
|
||
|
||
window.clearEditor = function(side) {
|
||
ed(side).set({ text: '' });
|
||
window.resetSqlPrefs(side);
|
||
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(); }
|
||
setTitleFromFilename(side, file.name);
|
||
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);
|
||
setTitleFromFilename(side, file.name);
|
||
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);
|
||
setTitleFromFilename(side, file.name);
|
||
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 }));
|
||
setTitleFromFilename(side, file.name);
|
||
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);
|
||
setTitleFromFilename(side, file.name);
|
||
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) });
|
||
setTitleFromFilename(side, file.name);
|
||
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);
|
||
setTitleFromFilename(side, file.name);
|
||
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
|
||
=========================================================== */
|
||
|
||
/** Katalog wählbarer Spaltentypen mit Erklärung für Laien. */
|
||
const SQL_TYPE_CATALOG = {
|
||
'INT': { label: 'INT – Ganzzahl', hasLength: false,
|
||
desc: 'Ganze Zahlen von etwa -2,1 bis +2,1 Milliarden. Für die meisten IDs und Z\u00e4hler ausreichend.' },
|
||
'BIGINT': { label: 'BIGINT – gro\u00dfe Ganzzahl', hasLength: false,
|
||
desc: 'Ganze Zahlen bis etwa 9,2 Trillionen. N\u00f6tig, wenn IDs sehr gro\u00df werden, wie es bei manchen Shop- oder Billbee-IDs vorkommt.' },
|
||
'SMALLINT': { label: 'SMALLINT – kleine Ganzzahl', hasLength: false,
|
||
desc: 'Ganze Zahlen von -32.768 bis 32.767. Spart Platz bei kleinem Wertebereich, z. B. Jahrg\u00e4ngen oder St\u00fcckzahlen.' },
|
||
'TINYINT': { label: 'TINYINT – sehr kleine Ganzzahl', hasLength: false,
|
||
desc: 'Ganze Zahlen von -128 bis 127. F\u00fcr kleine Statuscodes oder Z\u00e4hler geeignet.' },
|
||
'TINYINT(1)': { label: 'TINYINT(1) – Ja/Nein', hasLength: false,
|
||
desc: 'Wird von MariaDB als boolescher Wert behandelt: 0 = falsch/nein, 1 = wahr/ja.' },
|
||
'DECIMAL': { label: 'DECIMAL – genaue Kommazahl', hasLength: true, lengthLabel: 'Gesamtstellen,Nachkommastellen', lengthDefault: '10,2',
|
||
desc: 'Exakte Kommazahl ohne Rundungsfehler. F\u00fcr Preise und Geldbetr\u00e4ge die richtige Wahl \u2013 DOUBLE rundet bei Geld manchmal minimal falsch.' },
|
||
'DOUBLE': { label: 'DOUBLE – Gleitkommazahl', hasLength: false,
|
||
desc: 'Kommazahl mit m\u00f6glichen kleinen Rundungsungenauigkeiten. F\u00fcr Messwerte und Berechnungen, nicht f\u00fcr Geldbetr\u00e4ge.' },
|
||
'VARCHAR': { label: 'VARCHAR – kurzer Text', hasLength: true, lengthLabel: 'Maximale Zeichenzahl', lengthDefault: '255',
|
||
desc: 'Text mit fester Obergrenze, schnell durchsuchbar und indizierbar. F\u00fcr Namen, E-Mail-Adressen, kurze Bezeichnungen.' },
|
||
'TEXT': { label: 'TEXT – langer Text', hasLength: false,
|
||
desc: 'Text ohne praktische L\u00e4ngenbegrenzung. F\u00fcr Notizen, Beschreibungen oder l\u00e4ngere Inhalte.' },
|
||
'DATE': { label: 'DATE – nur Datum', hasLength: false,
|
||
desc: 'Speichert nur ein Datum, z. B. 2026-09-16, ohne Uhrzeit.' },
|
||
'DATETIME': { label: 'DATETIME – Datum und Uhrzeit', hasLength: false,
|
||
desc: 'Datum plus Uhrzeit. Der \u00fcbliche Typ f\u00fcr Zeitstempel wie \u201eletzte \u00c4nderung\u201c.' },
|
||
'JSON': { label: 'JSON – strukturierte Daten', hasLength: false,
|
||
desc: 'F\u00fcr verschachtelte Objekte oder Listen innerhalb eines Feldes. MariaDB kann darin sogar gezielt suchen.' }
|
||
};
|
||
|
||
function sqlIntType(val) {
|
||
return (val > 2147483647 || val < -2147483648) ? 'BIGINT' : 'INT';
|
||
}
|
||
|
||
function inferSqlType(val) {
|
||
if (typeof val === 'number') return Number.isInteger(val) ? sqlIntType(val) : '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, "''")}'`;
|
||
}
|
||
|
||
// Rangfolge, in die ein Zahlentyp "aufsteigt", wenn spätere Zeilen einen
|
||
// größeren Wertebereich brauchen als die erste Zeile vermuten ließ.
|
||
const NUMERIC_WIDENING = { 'INT': 1, 'BIGINT': 2, 'DOUBLE': 3 };
|
||
|
||
function widenSqlType(current, next) {
|
||
if (current === next) return current;
|
||
const rc = NUMERIC_WIDENING[current], rn = NUMERIC_WIDENING[next];
|
||
if (rc && rn) return rc >= rn ? current : next; // beide numerisch: größeren Bereich behalten
|
||
return current; // uneinheitliche Typen: ersten Fund beibehalten, der Editor kann das korrigieren
|
||
}
|
||
|
||
/** Spalten und ihre Typen aus den Daten ableiten, ohne schon SQL zu bauen. */
|
||
function inferColumns(items) {
|
||
const columns = {};
|
||
items.forEach(item => {
|
||
if (typeof item === 'object' && item !== null) {
|
||
for (const [key, val] of Object.entries(item)) {
|
||
const t = inferSqlType(val);
|
||
columns[key] = columns[key] ? widenSqlType(columns[key], t) : t;
|
||
}
|
||
}
|
||
});
|
||
return { columns, colNames: Object.keys(columns) };
|
||
}
|
||
|
||
/** "VARCHAR(255)" -> {base:'VARCHAR', length:'255'}; "TINYINT(1)" bleibt als eigener Katalog-Eintrag. */
|
||
function parseSqlType(typeStr) {
|
||
if (typeStr === 'TINYINT(1)') return { base: 'TINYINT(1)', length: '' };
|
||
const m = typeStr.match(/^([A-Z]+)(?:\(([^)]*)\))?$/);
|
||
if (!m) return { base: typeStr, length: '' };
|
||
return { base: m[1], length: m[2] || '' };
|
||
}
|
||
|
||
function composeSqlType(base, length) {
|
||
const info = SQL_TYPE_CATALOG[base];
|
||
if (!info) return base;
|
||
if (base === 'TINYINT(1)') return 'TINYINT(1)';
|
||
if (info.hasLength) return `${base}(${length || info.lengthDefault})`;
|
||
return base;
|
||
}
|
||
|
||
/** Für jede Spalte den tatsächlich zu verwendenden Typ ermitteln: die
|
||
* manuelle Auswahl im Editor hat Vorrang vor der automatischen Erkennung. */
|
||
function effectiveColumns() {
|
||
const st = window.sqlModalState;
|
||
const map = {};
|
||
st.colNames.forEach(c => {
|
||
const ov = st.overrides[c];
|
||
map[c] = ov ? composeSqlType(ov.base, ov.length) : st.columns[c];
|
||
});
|
||
return map;
|
||
}
|
||
|
||
/**
|
||
* pkColumns = [] -> es wird eine eigene, fortlaufende `id`-Spalte angelegt (Standard).
|
||
* pkColumns = ["Spalte"] -> diese wird zum Primärschlüssel, keine zusätzliche id-Spalte.
|
||
* pkColumns = ["A","B"] -> zusammengesetzter Schlüssel über mehrere Spalten
|
||
* (PRIMARY KEY (A, B)), praktisch wenn erst die Kombination eindeutig ist.
|
||
* renames: { urspr\u00fcnglicherName: 'neuer_name' } - \u00e4ndert nur den Namen im SQL,
|
||
* die Werte werden weiterhin unter dem urspr\u00fcnglichen JSON-Feldnamen gelesen.
|
||
* columns/colNames: bereits aufgel\u00f6ste Spaltentypen (inkl. manueller Korrekturen).
|
||
*/
|
||
function sanitizeColumnName(name) {
|
||
let n = String(name).trim().replace(/[^A-Za-z0-9_]/g, '_');
|
||
if (n === '') return '';
|
||
if (/^[0-9]/.test(n)) n = '_' + n;
|
||
return n;
|
||
}
|
||
|
||
/** Liefert je Originalspalte den tats\u00e4chlich zu verwendenden SQL-Namen.
|
||
* Nur explizit umbenannte Spalten weichen vom JSON-Feldnamen ab; Kollisionen
|
||
* (zwei Spalten enden auf denselben Namen) werden automatisch aufgel\u00f6st. */
|
||
function resolveOutputNames(colNames, renames) {
|
||
const used = new Set();
|
||
const map = {};
|
||
colNames.forEach(c => {
|
||
const wanted = (renames && renames[c]) ? renames[c] : c;
|
||
let finalName = wanted;
|
||
let i = 2;
|
||
while (used.has(finalName)) { finalName = wanted + '_' + i; i++; }
|
||
used.add(finalName);
|
||
map[c] = finalName;
|
||
});
|
||
return map;
|
||
}
|
||
|
||
function buildSql(tableName, items, pkColumns, resolved, renames) {
|
||
const { columns, colNames } = resolved || inferColumns(items);
|
||
const pkList = (Array.isArray(pkColumns) ? pkColumns : (pkColumns ? [pkColumns] : []))
|
||
.filter(c => colNames.includes(c));
|
||
const outNames = resolveOutputNames(colNames, renames);
|
||
|
||
let sql = `-- MariaDB Tabellenstruktur f\u00fcr \`${tableName}\`\n`;
|
||
sql += `CREATE TABLE IF NOT EXISTS \`${tableName}\` (\n`;
|
||
|
||
const lines = [];
|
||
if (pkList.length === 0) {
|
||
lines.push(` \`id\` INT AUTO_INCREMENT PRIMARY KEY`);
|
||
colNames.forEach(c => lines.push(` \`${outNames[c]}\` ${columns[c]}`));
|
||
} else if (pkList.length === 1) {
|
||
colNames.forEach(c => {
|
||
let def = ` \`${outNames[c]}\` ${columns[c]}`;
|
||
if (c === pkList[0]) def += ' NOT NULL PRIMARY KEY';
|
||
lines.push(def);
|
||
});
|
||
} else {
|
||
colNames.forEach(c => {
|
||
let def = ` \`${outNames[c]}\` ${columns[c]}`;
|
||
if (pkList.includes(c)) def += ' NOT NULL';
|
||
lines.push(def);
|
||
});
|
||
lines.push(` PRIMARY KEY (${pkList.map(c => '\`' + outNames[c] + '\`').join(', ')})`);
|
||
}
|
||
sql += lines.join(',\n');
|
||
sql += `\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n\n`;
|
||
|
||
sql += `-- Daten-Imports f\u00fcr \`${tableName}\`\n`;
|
||
const outColList = colNames.map(c => outNames[c]).join('`, `');
|
||
items.forEach(item => {
|
||
const vals = colNames.map(c => formatSqlValue(item[c]));
|
||
sql += `INSERT INTO \`${tableName}\` (\`${outColList}\`) VALUES (${vals.join(', ')});\n`;
|
||
});
|
||
return { sql, columns, colNames, outNames };
|
||
}
|
||
|
||
function buildSqlPreviewTable(items, colNames, columns, pkList, outNames) {
|
||
let html = '<div class="preview-table-container"><table class="sql-preview-table"><thead><tr>';
|
||
if (pkList.length === 0) html += '<th>id (PK, automatisch)</th>';
|
||
colNames.forEach(c => {
|
||
const marker = pkList.includes(c) ? ' 🔑 PK' : '';
|
||
const renamed = outNames[c] !== c;
|
||
const label = renamed
|
||
? escapeHtml(outNames[c]) + ' <span class="coltype-badge" title="Urspr\u00fcnglich: ' + escapeHtml(c) + '">umbenannt</span>'
|
||
: escapeHtml(c);
|
||
html += '<th>' + label + ' (' + columns[c] + ')' + marker + '</th>';
|
||
});
|
||
html += '</tr></thead><tbody>';
|
||
items.forEach((item, idx) => {
|
||
html += '<tr>';
|
||
if (pkList.length === 0) html += '<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);
|
||
html += '<td>' + escapeHtml(d) + '</td>';
|
||
});
|
||
html += '</tr>';
|
||
});
|
||
html += '</tbody></table></div>';
|
||
return html;
|
||
}
|
||
|
||
function buildColumnTypeEditor() {
|
||
const st = window.sqlModalState;
|
||
const typeOptions = Object.keys(SQL_TYPE_CATALOG);
|
||
const resolvedColumns = effectiveColumns();
|
||
|
||
const rows = st.colNames.map(c => {
|
||
const current = parseSqlType(resolvedColumns[c]);
|
||
const info = SQL_TYPE_CATALOG[current.base] || SQL_TYPE_CATALOG['VARCHAR'];
|
||
const isRenamed = !!(st.renames[c] && st.renames[c] !== c);
|
||
const isCustomized = !!st.overrides[c] || isRenamed;
|
||
|
||
const options = typeOptions.map(t =>
|
||
'<option value="' + t + '"' + (t === current.base ? ' selected' : '') + '>' +
|
||
escapeHtml(SQL_TYPE_CATALOG[t].label) + '</option>'
|
||
).join('');
|
||
|
||
const lengthField = info.hasLength
|
||
? `<input type="text" class="coltype-length" value="${escapeHtml(current.length || info.lengthDefault)}"
|
||
onchange="window.onColumnLengthChange('${escapeHtml(c)}', this.value)"
|
||
title="${escapeHtml(info.lengthLabel)}">`
|
||
: '<input type="text" class="coltype-length" value="" disabled title="Für diesen Typ nicht nötig">';
|
||
|
||
return `
|
||
<div class="coltype-row">
|
||
<div class="coltype-name">${escapeHtml(c)}${isCustomized ? ' <span class="coltype-badge">angepasst</span>' : ''}</div>
|
||
<input type="text" class="coltype-rename" value="${escapeHtml(st.renames[c] || c)}"
|
||
onchange="window.onColumnRenameChange('${escapeHtml(c)}', this.value)"
|
||
title="Spaltenname im SQL - Standard: wie im JSON">
|
||
<select onchange="window.onColumnTypeChange('${escapeHtml(c)}', this.value)">${options}</select>
|
||
${lengthField}
|
||
<div class="coltype-hint">${escapeHtml(info.desc)}</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
return `
|
||
<details class="coltype-editor" ${st.editorOpen ? 'open' : ''} ontoggle="window.sqlModalState.editorOpen = this.open">
|
||
<summary>⚙️ Spalten anpassen (Typ & Name)</summary>
|
||
<div class="modal-hint" style="margin-top:10px">
|
||
Die Typen werden automatisch aus den Werten geraten. Passt eine Zuordnung nicht –
|
||
etwa weil eine Spalte eigentlich Geldbeträge enthält oder eine ID größer
|
||
ist als erwartet – kannst du sie hier gezielt überschreiben. Im zweiten Feld lässt
|
||
sich außerdem der Spaltenname im SQL ändern, falls er nicht dem JSON-Feldnamen
|
||
entsprechen soll.
|
||
</div>
|
||
${rows}
|
||
</details>`;
|
||
}
|
||
|
||
function renderSqlModalBody() {
|
||
const st = window.sqlModalState;
|
||
const resolvedColumns = effectiveColumns();
|
||
const resolved = { columns: resolvedColumns, colNames: st.colNames };
|
||
const { sql, outNames } = buildSql(st.tableName, st.items, st.pk, resolved, st.renames);
|
||
st.sql = sql;
|
||
|
||
const pkChecklist = st.colNames.map(c =>
|
||
`<label class="pk-check">
|
||
<input type="checkbox" ${st.pk.includes(c) ? 'checked' : ''} onchange="window.onSqlPkToggle('${escapeHtml(c)}', this.checked)">
|
||
${escapeHtml(c)}
|
||
</label>`
|
||
).join('');
|
||
|
||
document.getElementById('modalContent').innerHTML = `
|
||
<div class="form-row">
|
||
<label class="field-label">Primärschlüssel</label>
|
||
<div class="pk-checklist">${pkChecklist}</div>
|
||
<div class="field-hint">
|
||
Keine Auswahl – automatische, fortlaufende <code>id</code>-Spalte (Standard).
|
||
Ein Haken – diese Spalte wird zum Primärschlüssel; achte darauf, dass sie
|
||
in jeder Zeile eindeutig und nicht leer ist. Mehrere Haken – zusammengesetzter
|
||
Schlüssel, der erst in Kombination eindeutig sein muss. Die Auswahl wird für
|
||
dieses Dokument gemerkt, bis du oben auf „Neu“ klickst.
|
||
</div>
|
||
</div>
|
||
${buildColumnTypeEditor()}
|
||
<div class="modal-section-title">📊 Visuelle MariaDB Tabellen-Vorschau</div>
|
||
${buildSqlPreviewTable(st.items, st.colNames, resolvedColumns, st.pk, outNames)}
|
||
<div class="modal-section-title">📄 Generiertes SQL-Skript</div>
|
||
<pre class="sql-code">${escapeHtml(sql)}</pre>`;
|
||
}
|
||
|
||
window.onSqlPkToggle = function(colName, checked) {
|
||
const st = window.sqlModalState;
|
||
if (checked) { if (!st.pk.includes(colName)) st.pk.push(colName); }
|
||
else { st.pk = st.pk.filter(c => c !== colName); }
|
||
persistSqlPrefs(st);
|
||
renderSqlModalBody();
|
||
};
|
||
|
||
window.onColumnTypeChange = function(colName, base) {
|
||
const st = window.sqlModalState;
|
||
const info = SQL_TYPE_CATALOG[base];
|
||
st.overrides[colName] = { base, length: info.hasLength ? info.lengthDefault : '' };
|
||
st.editorOpen = true;
|
||
persistSqlPrefs(st);
|
||
renderSqlModalBody();
|
||
};
|
||
|
||
window.onColumnLengthChange = function(colName, length) {
|
||
const st = window.sqlModalState;
|
||
const ov = st.overrides[colName];
|
||
if (ov) ov.length = length;
|
||
st.editorOpen = true;
|
||
persistSqlPrefs(st);
|
||
renderSqlModalBody();
|
||
};
|
||
|
||
window.onColumnRenameChange = function(colName, value) {
|
||
const st = window.sqlModalState;
|
||
const sanitized = sanitizeColumnName(value);
|
||
if (!sanitized || sanitized === colName) delete st.renames[colName];
|
||
else st.renames[colName] = sanitized;
|
||
st.editorOpen = true;
|
||
persistSqlPrefs(st);
|
||
renderSqlModalBody();
|
||
};
|
||
|
||
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 { columns, colNames } = inferColumns(items);
|
||
|
||
// Zuvor für dieses Dokument gewählte Einstellungen übernehmen - aber nur,
|
||
// soweit sie noch zu den gerade vorhandenen Spalten passen.
|
||
const prefs = loadSqlPrefs(side);
|
||
const pk = prefs.pk.filter(c => colNames.includes(c));
|
||
const overrides = {};
|
||
colNames.forEach(c => { if (prefs.overrides[c]) overrides[c] = prefs.overrides[c]; });
|
||
const renames = {};
|
||
colNames.forEach(c => { if (prefs.renames[c]) renames[c] = prefs.renames[c]; });
|
||
|
||
window.sqlModalState = { side, tableName, items, columns, colNames, pk, overrides, renames, editorOpen: false, sql: '' };
|
||
|
||
window.openModal('🗃️ MariaDB Workbench: ' + escapeHtml(tableName), '<div>Wird geladen…</div>', [
|
||
{ label: 'Als .sql speichern', handler: () => {
|
||
const st = window.sqlModalState;
|
||
downloadBlob(st.sql, st.tableName + '.sql', 'application/sql');
|
||
window.showToast('Gespeichert: ' + st.tableName + '.sql', 'success');
|
||
} },
|
||
{ label: 'SQL kopieren', primary: true, handler: () => {
|
||
navigator.clipboard.writeText(window.sqlModalState.sql).then(() => window.showToast('SQL-Skript kopiert.', 'success'));
|
||
} }
|
||
]);
|
||
renderSqlModalBody();
|
||
};
|
||
|
||
/* ===========================================================
|
||
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>
|
||
|
||
<div class="modal-section-title">⌛ Automatische Sicherung</div>
|
||
<div class="form-row">
|
||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
|
||
<input type="checkbox" id="setAutoSave" ${s.autoSaveEnabled ? 'checked' : ''} style="width:auto">
|
||
In regelmäßigen Abständen als Datei herunterladen
|
||
</label>
|
||
<div class="field-hint">
|
||
Lädt jeden Editor mit geändertem Inhalt automatisch als JSON-Datei herunter
|
||
(Dateiname mit Zeitstempel, z. B. <code>bestellung_autosave_2026-09-17-1530.json</code>).
|
||
Nur ein zusätzliches Sicherheitsnetz – ersetzt nicht das gezielte Speichern per
|
||
„Export“, und die heruntergeladenen Dateien sammeln sich im Download-Ordner an,
|
||
du räumst sie also gelegentlich selbst auf.
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<label class="field-label">Abstand</label>
|
||
<select id="setAutoSaveInterval">
|
||
<option value="5"${sel(String(s.autoSaveIntervalMinutes), '5')}>Alle 5 Minuten</option>
|
||
<option value="10"${sel(String(s.autoSaveIntervalMinutes), '10')}>Alle 10 Minuten</option>
|
||
<option value="15"${sel(String(s.autoSaveIntervalMinutes), '15')}>Alle 15 Minuten</option>
|
||
<option value="30"${sel(String(s.autoSaveIntervalMinutes), '30')}>Alle 30 Minuten</option>
|
||
</select>
|
||
</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(),
|
||
autoSaveEnabled: document.getElementById('setAutoSave').checked,
|
||
autoSaveIntervalMinutes: parseInt(document.getElementById('setAutoSaveInterval').value, 10) || 10
|
||
}));
|
||
window.restartAutoSaveTimer();
|
||
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 –
|
||
beim Öffnen einer Datei geschieht das ohnehin automatisch: Der Name wird auf den
|
||
Dateinamen ohne Endung gesetzt, damit sich eine bearbeitete Datei direkt wieder unter
|
||
demselben Namen speichern lässt. Im Öffnen-Menü findest du außerdem die
|
||
letzten fünf Dateien, die du in diesem Editor geöffnet hast – nur als
|
||
Gedächtnisstütze, ein erneutes Laden ersetzt das nicht.</p>
|
||
|
||
<h4>Pfad eines Feldes kopieren</h4>
|
||
<p>Ein Klick auf ein Feld im Baum, dann oben auf „Pfad“ – kopiert eine
|
||
Notation wie <code>Positionen[2].Preis</code> in die Zwischenablage. Praktisch, um schnell
|
||
an einen n8n-Ausdruck oder eine Doku-Notiz zu kommen, ohne den Pfad von Hand abzuzählen.</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. Wer mag,
|
||
schaltet dort zusätzlich eine automatische Sicherung als Download in festem Abstand ein
|
||
(standardmäßig aus) – ein zusätzliches Sicherheitsnetz, kein Ersatz für
|
||
das gezielte Speichern.</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, inklusive automatischer Unterscheidung zwischen <code>INT</code> und dem größeren <code>BIGINT</code> bei sehr großen Zahlen – passt eine Zuordnung trotzdem nicht, lässt sie sich im Bereich „Spalten anpassen“ gezielt überschreiben, inklusive Längenangabe, Umbenennung und Erklärung zu jedem Typ. Standardmäßig bekommt die Tabelle eine eigene, fortlaufende <code>id</code>-Spalte als Primärschlüssel – per Häkchen lässt sich stattdessen eine vorhandene Spalte dazu machen, oder mehrere zusammen als zusammengesetzter Schlüssel.</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="https://git.obraasch.de/olli1986/JSON-Studio" target="_blank" rel="noopener noreferrer">git.obraasch.de/olli1986/JSON-Studio</a></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>
|