From cf855864469b3dfc247938fbbbe672cbb7ea856b Mon Sep 17 00:00:00 2001 From: Oliver Braasch Date: Fri, 18 Sep 2026 10:08:45 +0200 Subject: [PATCH] Dateien nach "/" hochladen --- json-workbench.html | 750 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 664 insertions(+), 86 deletions(-) diff --git a/json-workbench.html b/json-workbench.html index 74580ba..0da4bf1 100644 --- a/json-workbench.html +++ b/json-workbench.html @@ -245,75 +245,126 @@ body.dark-mode .jse-table-mode, body.dark-mode .jse-welcome, body.dark-mode .jse-theme-dark { - --jse-bg-color: #16171a !important; - --jse-main-border: 1px solid #2a2b30 !important; + /* 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-menu-color: #e7e8ea !important; --jse-text-color: #e7e8ea !important; - --jse-text-color-min: #8b8d93 !important; - --jse-key-color: #6cb6ff !important; - --jse-value-color-string: #9fd6a0 !important; - --jse-value-color-number: #f7b267 !important; - --jse-value-color-boolean: #f47067 !important; - --jse-value-color-null: #8b8d93 !important; - --jse-hover-background-color: #22232a !important; - --jse-selection-background-color: #2a3340 !important; - --jse-active-line-background-color: rgba(255,255,255,0.04) !important; + --jse-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-background-hover: #24252b !important; --jse-panel-color: #e7e8ea !important; --jse-panel-color-readonly: #8b8d93 !important; --jse-panel-border: 1px solid #2a2b30 !important; --jse-panel-button-color: #c7c8cc !important; + --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; - --jse-context-menu-separator-color: #2a2b30 !important; - --jse-context-menu-hover-background-color: #26272d !important; - --jse-modal-background: #16171a !important; - --jse-modal-overlay-background: rgba(0,0,0,0.6) !important; - --jse-input-background: #101114 !important; - --jse-button-background: #26272d !important; - --jse-button-background-highlight: #33343b !important; - --jse-button-color: #e7e8ea !important; - --jse-tooltip-background: #1c1d21 !important; - --jse-tooltip-color: #e7e8ea !important; - --jse-tooltip-error-background: #3a1414 !important; - --jse-tooltip-error-color: #ff9a9a !important; - --jse-search-match-background: rgba(108,182,255,0.22) !important; - --jse-search-match-color: #e7e8ea !important; - --jse-search-match-outline: 1px solid #6cb6ff !important; - --jse-search-match-active-background: #3b78b0 !important; - --jse-search-match-active-color: #ffffff !important; - --jse-search-match-active-outline: 1px solid #8fcaff !important; - --jse-tag-background: #24252b !important; - --jse-tag-color: #e7e8ea !important; + + /* 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-collapsed-items-selected-link-color: #ffffff !important; - --jse-delimiter-color: #55565c !important; - --jse-edit-outline: 2px solid #6cb6ff !important; - --jse-messages-info-background: #1c1d21 !important; - --jse-messages-info-color: #8fcaff !important; - --jse-messages-success-background: #123321 !important; - --jse-messages-success-color: #6bd8a1 !important; - --jse-messages-error-background: #3a1414 !important; - --jse-messages-error-color: #ff9a9a !important; - --jse-error-color: #e5484d !important; - --jse-warning-color: #f7b267 !important; + + --jse-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; } @@ -439,6 +490,52 @@ .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; @@ -554,6 +651,7 @@
🌐 Von URL laden… 🗃️ Aus Datenbank laden… +
@@ -589,6 +687,7 @@ + @@ -642,6 +741,7 @@
🌐 Von URL laden… 🗃️ Aus Datenbank laden… +
@@ -677,6 +777,7 @@ + @@ -795,7 +896,9 @@ const DEFAULT_SETTINGS = { webhookUrl: '', xmlRootName: 'root', xmlItemName: 'item', - splitRatio: 0.5 + splitRatio: 0.5, + autoSaveEnabled: false, + autoSaveIntervalMinutes: 10 }; function loadSettings() { @@ -810,6 +913,42 @@ function saveSettings(s) { } 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, '''); @@ -873,13 +1012,51 @@ if (savedTitleRight) document.getElementById('titleRight').innerText = savedTitl 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() } + 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() } + props: { + content: loadSavedContent('wb_right_content'), mode: 'tree', + onChange: () => window.saveAppState(), + onSelect: (selection) => { window.lastSelectionPath.right = selectionToPath(selection); } + } }); window.activeSide = 'left'; @@ -925,6 +1102,46 @@ window.applyTheme = function(isDark) { 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 =========================================================== */ @@ -965,7 +1182,54 @@ function readFileAsText(file, callback) { reader.readAsText(file); } -window.clearEditor = function(side) { ed(side).set({ text: '' }); window.saveAppState(); }; +/** 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 = '
Zuletzt ge\u00f6ffnet
' + + list.map(f => '
' + + fileIconFor(f.name) + ' ' + escapeHtml(f.name) + '
').join(''); +} + +window.clearEditor = function(side) { + ed(side).set({ text: '' }); + window.resetSqlPrefs(side); + window.saveAppState(); +}; window.toggleFullscreen = function(paneId) { const elem = document.getElementById(paneId); @@ -983,6 +1247,7 @@ window.importJsonFile = function(event, side) { 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 = ''; @@ -996,6 +1261,7 @@ window.importCsvFile = function(event, side) { 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'); }, @@ -1015,10 +1281,12 @@ window.importExcelFile = function(event, side) { 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'); } @@ -1034,6 +1302,7 @@ window.importYamlFile = function(event, side) { 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'); } }); @@ -1049,6 +1318,7 @@ window.importXmlFile = function(event, side) { 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'); } }); @@ -1102,6 +1372,7 @@ window.importSqlFile = function(event, side) { 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'); } @@ -1382,8 +1653,40 @@ window.copyToClip = function(side, format) { 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) ? 'INT' : 'DOUBLE'; + 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') { @@ -1400,68 +1703,304 @@ function formatSqlValue(val) { return `'${String(val).replace(/'/g, "''")}'`; } -function buildSql(tableName, items) { +// 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)) { - if (!columns[key]) columns[key] = inferSqlType(val); - else if (columns[key] === 'INT' && typeof val === 'number' && !Number.isInteger(val)) columns[key] = 'DOUBLE'; + const t = inferSqlType(val); + columns[key] = columns[key] ? widenSqlType(columns[key], t) : t; } } }); - const colNames = Object.keys(columns); + 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`; - sql += ` \`id\` INT AUTO_INCREMENT PRIMARY KEY,\n`; - sql += colNames.map(c => ` \`${c}\` ${columns[c]}`).join(',\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}\` (\`${colNames.join('`, `')}\`) VALUES (${vals.join(', ')});\n`; + sql += `INSERT INTO \`${tableName}\` (\`${outColList}\`) VALUES (${vals.join(', ')});\n`; }); - return { sql, columns, colNames }; + return { sql, columns, colNames, outNames }; } +function buildSqlPreviewTable(items, colNames, columns, pkList, outNames) { + let html = '
'; + if (pkList.length === 0) html += ''; + colNames.forEach(c => { + const marker = pkList.includes(c) ? ' 🔑 PK' : ''; + const renamed = outNames[c] !== c; + const label = renamed + ? escapeHtml(outNames[c]) + ' umbenannt' + : escapeHtml(c); + html += ''; + }); + html += ''; + items.forEach((item, idx) => { + html += ''; + if (pkList.length === 0) html += ''; + colNames.forEach(c => { + const v = item[c]; + const d = (typeof v === 'object' && v !== null) ? JSON.stringify(v) : String(v === undefined || v === null ? '' : v); + html += ''; + }); + html += ''; + }); + html += '
id (PK, automatisch)' + label + ' (' + columns[c] + ')' + marker + '
' + (idx + 1) + '' + escapeHtml(d) + '
'; + 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 => + '' + ).join(''); + + const lengthField = info.hasLength + ? `` + : ''; + + return ` +
+
${escapeHtml(c)}${isCustomized ? ' angepasst' : ''}
+ + + ${lengthField} +
${escapeHtml(info.desc)}
+
`; + }).join(''); + + return ` +
+ ⚙️ Spalten anpassen (Typ & Name) + + ${rows} +
`; +} + +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 => + `` + ).join(''); + + document.getElementById('modalContent').innerHTML = ` +
+ +
${pkChecklist}
+
+ Keine Auswahl – automatische, fortlaufende id-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. +
+
+ ${buildColumnTypeEditor()} + + ${buildSqlPreviewTable(st.items, st.colNames, resolvedColumns, st.pk, outNames)} + +
${escapeHtml(sql)}
`; +} + +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 { sql, columns, colNames } = buildSql(tableName, items); + const { columns, colNames } = inferColumns(items); - let htmlTable = '
'; - colNames.forEach(c => { htmlTable += ''; }); - htmlTable += ''; - items.forEach((item, idx) => { - htmlTable += ''; - colNames.forEach(c => { - const v = item[c]; - const d = (typeof v === 'object' && v !== null) ? JSON.stringify(v) : String(v === undefined || v === null ? '' : v); - htmlTable += ''; - }); - htmlTable += ''; - }); - htmlTable += '
id (PK)' + escapeHtml(c) + ' (' + columns[c] + ')
' + (idx + 1) + '' + escapeHtml(d) + '
'; + // 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]; }); - const body = ` - - ${htmlTable} - -
${escapeHtml(sql)}
`; + window.sqlModalState = { side, tableName, items, columns, colNames, pk, overrides, renames, editorOpen: false, sql: '' }; - window.openModal('🗃️ MariaDB Workbench: ' + escapeHtml(tableName), body, [ + window.openModal('🗃️ MariaDB Workbench: ' + escapeHtml(tableName), '
Wird geladen…
', [ { label: 'Als .sql speichern', handler: () => { - downloadBlob(sql, tableName + '.sql', 'application/sql'); - window.showToast('Gespeichert: ' + tableName + '.sql', 'success'); + 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(sql).then(() => window.showToast('SQL-Skript kopiert.', 'success')); + navigator.clipboard.writeText(window.sqlModalState.sql).then(() => window.showToast('SQL-Skript kopiert.', 'success')); } } ]); + renderSqlModalBody(); }; /* =========================================================== @@ -2076,6 +2615,30 @@ window.openSettingsModal = function() {
Vorhandene Einträge werden ergänzt, nicht gelöscht. Snippets und Profile mit gleichem Namen kommen zusätzlich hinzu.
+ + + +
+ +
+ Lädt jeden Editor mit geändertem Inhalt automatisch als JSON-Datei herunter + (Dateiname mit Zeitstempel, z. B. bestellung_autosave_2026-09-17-1530.json). + 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. +
+
+
+ +
`; window.openModal('⚙️ Einstellungen', body, [ @@ -2086,8 +2649,11 @@ window.openSettingsModal = function() { 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() + 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'); } } @@ -2178,14 +2744,27 @@ window.openHelpModal = function(startTab) {

Der Dokumentname ist wichtiger, als er aussieht

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.

+ 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.

+ +

Pfad eines Feldes kopieren

+

Ein Klick auf ein Feld im Baum, dann oben auf „Pfad“ – kopiert eine + Notation wie Positionen[2].Preis in die Zwischenablage. Praktisch, um schnell + an einen n8n-Ausdruck oder eine Doku-Notiz zu kommen, ohne den Pfad von Hand abzuzählen.

Alles bleibt erhalten

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.

Weil der Speicher am Browser hängt: Beim Wechsel auf einen anderen Rechner nimmst du - deine Konfiguration über Einstellungen → Sicherung exportieren mit.

+ deine Konfiguration über Einstellungen → Sicherung exportieren 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.

Die drei Ansichten des Editors