From 0b31804d216dfa2bbc66e273810cb1cccad0c832 Mon Sep 17 00:00:00 2001 From: Dicken Date: Mon, 1 Jun 2026 12:43:10 +0200 Subject: [PATCH] =?UTF-8?q?DevTools:=20Regex-Builder=20(visuell=20+=20Erkl?= =?UTF-8?q?=C3=A4rungsmodus)=20hinzugef=C3=BCgt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/tools/devtools.jsx | 458 +++++++++++++++++++++++++++++++- 1 file changed, 457 insertions(+), 1 deletion(-) diff --git a/frontend/src/tools/devtools.jsx b/frontend/src/tools/devtools.jsx index ab52223..7b3114f 100644 --- a/frontend/src/tools/devtools.jsx +++ b/frontend/src/tools/devtools.jsx @@ -1409,13 +1409,469 @@ function NetzwerkRechner({ mobile }) { ); } +// ──────────────────────────────────────────────────────────── Regex ── + +const RULE_TYPES = [ + { id:'contains', label:'Enthält', fields:['text'], hint:'abc' }, + { id:'notcontains', label:'Enthält nicht', fields:['text'], hint:'abc' }, + { id:'startswith', label:'Beginnt mit', fields:['text'], hint:'KD-' }, + { id:'endswith', label:'Endet mit', fields:['text'], hint:'.pdf' }, + { id:'exact', label:'Ist genau', fields:['text'], hint:'hallo' }, + { id:'digits_n', label:'Genau N Ziffern', fields:['count'], hint:'6' }, + { id:'digits_min', label:'Mindestens N Ziffern', fields:['count'], hint:'3' }, + { id:'digits_range', label:'Ziffern (von–bis)', fields:['min','max'], hint:'' }, + { id:'letters_n', label:'Genau N Buchstaben', fields:['count'], hint:'5' }, + { id:'alnum_n', label:'Genau N Alphanumerisch',fields:['count'], hint:'8' }, + { id:'minlength', label:'Mindestlänge', fields:['count'], hint:'5' }, + { id:'maxlength', label:'Höchstlänge', fields:['count'], hint:'20' }, + { id:'numrange', label:'Zahlenbereich', fields:['min','max'], hint:'' }, + { id:'onlynums', label:'Nur Zahlen', fields:[], hint:'' }, + { id:'onlyletters', label:'Nur Buchstaben', fields:[], hint:'' }, + { id:'onlyalnum', label:'Nur Alphanumerisch', fields:[], hint:'' }, + { id:'date_de', label:'Datum (TT.MM.JJJJ)', fields:[], hint:'' }, + { id:'date_iso', label:'Datum (JJJJ-MM-TT)', fields:[], hint:'' }, + { id:'time', label:'Uhrzeit (HH:MM)', fields:[], hint:'' }, + { id:'email', label:'E-Mail-Adresse', fields:[], hint:'' }, + { id:'url', label:'URL (http/https)', fields:[], hint:'' }, + { id:'plz', label:'PLZ (5 Ziffern)', fields:[], hint:'' }, + { id:'ipv4', label:'IPv4-Adresse', fields:[], hint:'' }, + { id:'hex_color', label:'Hex-Farbe (#rrggbb)', fields:[], hint:'' }, + { id:'custom', label:'Benutzerdefiniert', fields:['pattern'], hint:'\\d+' }, +]; + +function escapeRegex(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// Build regex fragment + description for a single rule +function ruleToRegex(rule) { + const t = rule.type, f = rule.fields || {}; + const esc = escapeRegex(f.text || ''); + switch (t) { + case 'contains': return { pat: esc, desc: `enthält "${f.text}"` }; + case 'notcontains': return { pat: `(?!.*${esc})`, desc: `enthält NICHT "${f.text}"` }; + case 'startswith': return { pat: `^${esc}`, desc: `beginnt mit "${f.text}"` }; + case 'endswith': return { pat: `${esc}$`, desc: `endet mit "${f.text}"` }; + case 'exact': return { pat: `^${esc}$`, desc: `ist genau "${f.text}"` }; + case 'digits_n': return { pat: `\\d{${f.count||1}}`, desc: `genau ${f.count||1} Ziffer(n)` }; + case 'digits_min': return { pat: `\\d{${f.count||1},}`, desc: `mindestens ${f.count||1} Ziffer(n)` }; + case 'digits_range': return { pat: `\\d{${f.min||1},${f.max||9}}`, desc: `${f.min||1}–${f.max||9} Ziffern` }; + case 'letters_n': return { pat: `[a-zA-ZäöüÄÖÜß]{${f.count||1}}`, desc: `genau ${f.count||1} Buchstabe(n)` }; + case 'alnum_n': return { pat: `[a-zA-Z0-9]{${f.count||1}}`, desc: `genau ${f.count||1} alphanumerische Zeichen` }; + case 'minlength': return { pat: `.{${f.count||1},}`, desc: `mindestens ${f.count||1} Zeichen lang` }; + case 'maxlength': return { pat: `.{0,${f.count||999}}`, desc: `höchstens ${f.count||999} Zeichen lang` }; + case 'numrange': return { pat: buildNumRange(Number(f.min||0), Number(f.max||9)), desc: `Zahl zwischen ${f.min||0} und ${f.max||9}` }; + case 'onlynums': return { pat: `^\\d+$`, desc: `nur Ziffern (0–9)` }; + case 'onlyletters': return { pat: `^[a-zA-ZäöüÄÖÜß]+$`, desc: `nur Buchstaben` }; + case 'onlyalnum': return { pat: `^[a-zA-Z0-9]+$`, desc: `nur Buchstaben und Ziffern` }; + case 'date_de': return { pat: `^(0[1-9]|[12]\\d|3[01])\\.(0[1-9]|1[0-2])\\.\\d{4}$`, desc: `Datum im Format TT.MM.JJJJ` }; + case 'date_iso': return { pat: `^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$`, desc: `Datum im Format JJJJ-MM-TT` }; + case 'time': return { pat: `^([01]\\d|2[0-3]):[0-5]\\d$`, desc: `Uhrzeit im Format HH:MM` }; + case 'email': return { pat: `^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$`, desc: `gültige E-Mail-Adresse` }; + case 'url': return { pat: `^https?:\\/\\/[^\\s/$.?#].[^\\s]*$`, desc: `URL beginnend mit http:// oder https://` }; + case 'plz': return { pat: `^\\d{5}$`, desc: `deutsche Postleitzahl (5 Ziffern)` }; + case 'ipv4': return { pat: `^((25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}(25[0-5]|2[0-4]\\d|[01]?\\d\\d?)$`, desc: `IPv4-Adresse` }; + case 'hex_color': return { pat: `^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`, desc: `Hex-Farbe (#rgb oder #rrggbb)` }; + case 'custom': return { pat: f.pattern || '', desc: `benutzerdefiniert: ${f.pattern||''}` }; + default: return { pat: '', desc: '' }; + } +} + +// Simple numeric range pattern builder +function buildNumRange(lo, hi) { + if (lo === hi) return String(lo); + if (lo < 0 || hi < 0) return `(-?\\d+)`; // fallback for negative + return `(${lo}|${lo+1 <= hi ? `[${lo}-${hi}]` : lo})`.replace(/\((\d+)\)/, '$1'); + // For proper range, just do a simple approach: +} + +// Combine all rules into final regex +function buildRegex(rules) { + if (!rules.length) return ''; + const parts = rules.map(r => ruleToRegex(r).pat).filter(Boolean); + // If any rule has ^ or $ anchors at both ends alone → just combine sequentially + // Wrap in ^ ... $ only if all rules together form a complete match pattern + const hasFullAnchors = parts.some(p => /^\^/.test(p) && /\$$/.test(p)); + if (rules.length === 1) return parts[0]; + // Multiple rules: concat sequentially (user is building a sequence) + const combined = parts.join(''); + return combined; +} + +function buildDescription(rules) { + if (!rules.length) return ''; + const descs = rules.map(r => ruleToRegex(r).desc).filter(Boolean); + if (descs.length === 1) return `Der Text ${descs[0]}.`; + return `Der Text ${descs.slice(0,-1).join(', dann ')} und ${descs[descs.length-1]}.`; +} + +// ── Regex Erklärung (Reverse) ── +function explainRegex(pattern) { + if (!pattern) return []; + const explanations = []; + let p = pattern; + + const rules = [ + [/^\^/, 'Muss am Anfang beginnen'], + [/\$$/, 'Muss am Ende enden'], + [/\\\./g, 'Literaler Punkt (.)'], + [/\\d\+/g, 'Eine oder mehr Ziffern (0–9)'], + [/\\d\*/g, 'Beliebig viele Ziffern (0–9)'], + [/\\d\?/g, 'Optionale Ziffer (0–9)'], + [/\\d\{(\d+)\}/g, (m,n)=>`Genau ${n} Ziffer(n)`], + [/\\d\{(\d+),(\d+)\}/g, (m,a,b)=>`${a} bis ${b} Ziffern`], + [/\\d\{(\d+),\}/g, (m,n)=>`Mindestens ${n} Ziffer(n)`], + [/\\d/g, 'Eine Ziffer (0–9)'], + [/\\w\+/g, 'Ein oder mehr Wortzeichen (a-z, A-Z, 0-9, _)'], + [/\\w/g, 'Ein Wortzeichen (a-z, A-Z, 0-9, _)'], + [/\\s/g, 'Ein Leerzeichen/Whitespace'], + [/\\S/g, 'Kein Leerzeichen'], + [/\.\+/g, 'Ein oder mehr beliebige Zeichen'], + [/\.\*/g, 'Beliebig viele beliebige Zeichen (auch keine)'], + [/\./g, 'Ein beliebiges Zeichen'], + [/\[([^\]]+)\]/g, (m,c)=>`Eines der Zeichen: ${c}`], + [/\(([^)]+)\)\?/g, (m,c)=>`Optional: ${c}`], + [/\(([^)]+)\)\+/g, (m,c)=>`Eines oder mehr von: ${c}`], + [/\(([^)]+)\)\*/g, (m,c)=>`Beliebig viele von: ${c}`], + [/\(([^)]+)\)/g, (m,c)=>`Gruppe: ${c}`], + [/\{(\d+)\}/g, (m,n)=>`Genau ${n} Mal wiederholt`], + [/\{(\d+),(\d+)\}/g, (m,a,b)=>`${a} bis ${b} Mal`], + [/\{(\d+),\}/g, (m,n)=>`Mindestens ${n} Mal`], + [/\?/g, 'Optional (0 oder 1 Mal)'], + [/\+/g, '1 oder mehr Mal'], + [/\*/g, '0 oder mehr Mal'], + [/\|/g, 'ODER-Verknüpfung'], + [/\(\?!/g, 'Negativer Lookahead: das Folgende darf NICHT vorkommen'], + [/\(\?=/g, 'Positiver Lookahead: das Folgende muss vorkommen'], + [/\\b/g, 'Wortgrenze'], + [/i$/, 'Groß-/Kleinschreibung ignorieren (Flag: i)'], + [/g$/, 'Alle Treffer suchen (Flag: g)'], + ]; + + // Tokenize step by step + const tokens = []; + let i = 0; + while (i < p.length) { + // Skip anchors + if ((p[i]==='^'||p[i]==='$') && (i===0||i===p.length-1)) { + tokens.push({raw:p[i], label: p[i]==='^'?'Anfang des Textes':'Ende des Textes'}); + i++; continue; + } + // Escaped sequences + if (p[i]==='\\' && i+1j) { quant = p.slice(j,end+1); j=end+1; } + } else if (next2==='+'||next2==='*'||next2==='?') { quant=next2; j=i+3; } + const labels = {'\\d':'Ziffer (0–9)','\\w':'Wortzeichen','\\s':'Whitespace','\\S':'kein Whitespace','\\b':'Wortgrenze','\\n':'Zeilenumbruch','\\t':'Tab','\\r':'CR','\\+':'Plus (+)','\\*':'Stern (*)','\\?':'Fragezeichen (?)','\\(':'Klammer auf','\\)':'Klammer zu','\\[':'Eckige Klammer [','\\]':'Eckige Klammer ]','\\{':'Geschweifte Klammer {','\\}':'Geschweifte Klammer }','\\^':'Dach (^)','\\$':'Dollar ($)','\\|':'Pipe (|)','\\\\':'Backslash (\\)','\\-':'Bindestrich (-)','\\@':'@-Zeichen','\\.':'Punkt (.)'}; + let lbl = labels[seq] || `Zeichen: ${seq}`; + if (quant==='{') lbl += ` — quantifier:${quant}`; + else if (quant) { + const qmap = {'+':'ein oder mehrfach','*':'beliebig oft (auch 0)','?':'optional'}; + lbl += ` (${qmap[quant]||quant})`; + // Parse {n} or {n,m} + const qm = quant.match(/^\{(\d+),(\d+)\}$/); + const qm1 = quant.match(/^\{(\d+)\}$/); + const qm2 = quant.match(/^\{(\d+),\}$/); + if (qm) lbl += ` — ${qm[1]} bis ${qm[2]} Mal`; + else if (qm1) lbl += ` — genau ${qm1[1]} Mal`; + else if (qm2) lbl += ` — mindestens ${qm2[1]} Mal`; + } + tokens.push({raw:seq+(quant||''), label:lbl}); + i = j; continue; + } + // Character class + if (p[i]==='[') { + const end = p.indexOf(']',i); + if (end>i) { + const cls = p.slice(i,end+1); + const neg = cls[1]==='!'; + const inner = cls.slice(neg?2:1,-1); + tokens.push({raw:cls, label:`${neg?'Kein':'Eines der'} Zeichen: ${inner}`}); + i = end+1; continue; + } + } + // Group + if (p[i]==='(') { + // find matching ) + let depth=1, j=i+1; + while (j0) { if(p[j]==='(') depth++; else if(p[j]===')') depth--; j++; } + const group = p.slice(i,j); + let label = `Gruppe`; + if (group.startsWith('(?!')) label = `Negativer Lookahead (darf NICHT folgen)`; + else if (group.startsWith('(?=')) label = `Positiver Lookahead (muss folgen)`; + else if (group.startsWith('(?:')) label = `Nicht-fangende Gruppe`; + tokens.push({raw:group, label}); + i=j; continue; + } + // Quantifiers standalone + if (p[i]==='+') { tokens.push({raw:'+', label:'Das Vorherige: 1 oder mehr Mal'}); i++; continue; } + if (p[i]==='*') { tokens.push({raw:'*', label:'Das Vorherige: 0 oder mehr Mal'}); i++; continue; } + if (p[i]==='?') { tokens.push({raw:'?', label:'Das Vorherige: optional'}); i++; continue; } + if (p[i]==='|') { tokens.push({raw:'|', label:'ODER — eine der Alternativen'}); i++; continue; } + if (p[i]==='.') { tokens.push({raw:'.', label:'Ein beliebiges Zeichen (außer Zeilenumbruch)'}); i++; continue; } + // Literal + tokens.push({raw:p[i], label:`Literales Zeichen: "${p[i]}"`}); i++; + } + return tokens; +} + +function RegexBuilder({ toast, mobile }) { + const [mode, setMode] = useState('build'); // 'build' | 'explain' + // ── Build mode ── + const [rules, setRules] = useState([]); + const [testVal, setTestVal] = useState(''); + const [copied, setCopied] = useState(false); + + function addRule() { + setRules(r => [...r, {id: Date.now(), type: 'contains', fields: {text:''}}]); + } + + function updateRule(id, changes) { + setRules(r => r.map(rule => rule.id===id ? {...rule, ...changes} : rule)); + } + + function deleteRule(id) { + setRules(r => r.filter(rule => rule.id !== id)); + } + + function moveRule(id, dir) { + setRules(r => { + const idx = r.findIndex(x=>x.id===id); + const next = idx+dir; + if (next<0||next>=r.length) return r; + const arr = [...r]; + [arr[idx], arr[next]] = [arr[next], arr[idx]]; + return arr; + }); + } + + const regex = buildRegex(rules); + const description = buildDescription(rules); + + let testResult = null; + if (regex && testVal !== '') { + try { + testResult = new RegExp(regex).test(testVal); + } catch(e) { testResult = null; } + } + + async function copyRegex() { + if (!regex) return; + try { await navigator.clipboard.writeText(regex); setCopied(true); setTimeout(()=>setCopied(false),1800); } + catch { toast('Kopieren fehlgeschlagen'); } + } + + // ── Explain mode ── + const [explainInput, setExplainInput] = useState(''); + const [explainTestVal, setExplainTestVal] = useState(''); + const explained = explainInput ? explainRegex(explainInput) : []; + let explainTestResult = null; + if (explainInput && explainTestVal !== '') { + try { explainTestResult = new RegExp(explainInput).test(explainTestVal); } catch {} + } + + const inp = {...S.inp, fontSize:13, padding:'7px 9px', boxSizing:'border-box'}; + const lbl = {color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:10, marginBottom:3}; + + const FIELD_LABELS = {text:'Text', count:'Anzahl', min:'Von', max:'Bis', pattern:'Muster'}; + + function RuleCard({rule, idx}) { + const rt = RULE_TYPES.find(r=>r.id===rule.type); + return ( +
+
+ {/* Move buttons */} +
+ + +
+ {/* Rule number badge */} +
+ {idx+1} +
+ {/* Type select */} +
+
Regeltyp
+ +
+ {/* Dynamic fields */} + {rt?.fields.map(f=>( +
+
{FIELD_LABELS[f]||f}
+ updateRule(rule.id,{fields:{...rule.fields,[f]:e.target.value}})}/> +
+ ))} + {/* Preview of this rule's regex */} +
+
Regex-Teil
+ + {ruleToRegex(rule).pat || } + +
+ {/* Delete */} + +
+ {/* Description of this rule */} +
+ {ruleToRegex(rule).desc} +
+
+ ); + } + + return ( +
+ {/* Mode tabs */} +
+ {[['build','🔨 Regex bauen'],['explain','🔍 Regex erklären']].map(([id,lbl])=>( + + ))} +
+ + {mode==='build' && ( +
+ {/* Regel-Karten */} + {rules.map((rule,idx)=>( + + ))} + + + + {!!rules.length && ( + <> + {/* Regex-Ausgabe */} +
+
+ GENERIERTER REGEX + +
+ + {regex || } + + {!!description && ( +
+ {description} +
+ )} +
+ + {/* Test-Bereich */} +
+
TESTEN
+
+ setTestVal(e.target.value)}/> + {testResult===true && ✓ Treffer} + {testResult===false && ✗ Kein Treffer} +
+
+ + )} + + {!rules.length && ( +
+ Noch keine Regeln — klicke „+ Regel hinzufügen" +
+ )} +
+ )} + + {mode==='explain' && ( +
+
+
Regex eingeben
+ setExplainInput(e.target.value)}/> +
+ + {!!explained.length && ( + <> +
+
ERKLÄRUNG BAUSTEIN FÜR BAUSTEIN
+ {explained.map((tok,i)=>( +
+ {tok.raw} + {tok.label} +
+ ))} +
+ + {/* Test */} +
+
TESTEN
+
+ setExplainTestVal(e.target.value)}/> + {explainTestResult===true && ✓ Treffer} + {explainTestResult===false && ✗ Kein Treffer} +
+
+ + )} + + {!explainInput && ( +
+ Einen bestehenden Regex oben eingeben → jeder Baustein wird erklärt +
+ )} +
+ )} +
+ ); +} + + const TOOL_DEFS = [ {id:'cron', label:'⏱ Crontab', desc:'Crontab erklären und erstellen', ready:true, tabs:[{id:'explain',label:'Erklären'},{id:'build',label:'Erstellen'}], components:{explain:CronExplainer,build:CronBuilder}}, {id:'elektro', label:'⚡ Elektro', desc:'Ohm, Leistung, Kosten, Widerstände', ready:true, component:ElektroRechner}, {id:'json', label:'{ } JSON', desc:'JSON formatieren', ready:false}, - {id:'regex', label:'🔍 Regex', desc:'Regex testen', ready:false}, + {id:'regex', label:'🔍 Regex', desc:'Regex bauen & erklären', ready:true, component:RegexBuilder}, {id:'diff', label:'± Text Diff', desc:'Zwei Texte vergleichen', ready:true, component:TextDiff}, {id:'netzwerk', label:'🌐 Netzwerk', desc:'IP, Subnetz, Geschwindigkeit, CIDR', ready:true, component:NetzwerkRechner}, {id:'datetime', label:'📅 Datetime', desc:'Zeiten umrechnen · Geburtstag · Differenz', ready:true, component:DatetimeRechner},