diff --git a/src/App.tsx b/src/App.tsx index 5bfa1f8..e717345 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,8 +20,14 @@ import type { EnvDefault } from "./env"; type ThemeMode = "system" | "light" | "dark"; type Language = "de" | "en" | "es" | "fr" | "nl"; +type DefaultSource = "auto" | "manual"; -const initialDefaults: EnvDefault[] = [{ key: "BOOTSTRAP_ADMIN_EMAIL", value: "admin@example.local" }]; +interface DefaultRow extends EnvDefault { + source: DefaultSource; + index?: number; +} + +const initialDefaults: EnvDefault[] = []; const languageNames: Record = { de: "Deutsch", @@ -50,8 +56,10 @@ const translations = { source: "Quelle", text: "Text", defaults: "Standardwerte", - defaultsHint: "Diese Werte überschreiben passende Keys im Output.", + defaultsHint: "Mögliche Standardwerte werden erkannt; eigene Werte überschreiben passende Keys im Output.", addDefault: "Standardwert hinzufügen", + autoDefault: "Auto", + manualDefault: "Manuell", key: "Key", value: "Wert", detected: "Erkannte Anforderungen", @@ -87,8 +95,10 @@ const translations = { source: "Source", text: "Text", defaults: "Defaults", - defaultsHint: "These values override matching keys in the output.", + defaultsHint: "Possible defaults are detected; custom values override matching keys in the output.", addDefault: "Add default", + autoDefault: "Auto", + manualDefault: "Manual", key: "Key", value: "Value", detected: "Detected requirements", @@ -124,8 +134,10 @@ const translations = { source: "Origen", text: "Texto", defaults: "Valores predeterminados", - defaultsHint: "Estos valores sobrescriben claves iguales en la salida.", + defaultsHint: "Se detectan valores posibles; los personalizados sobrescriben claves iguales.", addDefault: "Añadir valor", + autoDefault: "Auto", + manualDefault: "Manual", key: "Clave", value: "Valor", detected: "Requisitos detectados", @@ -161,8 +173,10 @@ const translations = { source: "Source", text: "Texte", defaults: "Valeurs par défaut", - defaultsHint: "Ces valeurs remplacent les clés correspondantes dans la sortie.", + defaultsHint: "Les valeurs possibles sont détectées; les valeurs personnalisées remplacent les clés.", addDefault: "Ajouter", + autoDefault: "Auto", + manualDefault: "Manuel", key: "Clé", value: "Valeur", detected: "Exigences détectées", @@ -198,8 +212,10 @@ const translations = { source: "Bron", text: "Tekst", defaults: "Standaardwaarden", - defaultsHint: "Deze waarden overschrijven gelijke keys in de output.", + defaultsHint: "Mogelijke standaardwaarden worden herkend; eigen waarden overschrijven keys.", addDefault: "Waarde toevoegen", + autoDefault: "Auto", + manualDefault: "Handmatig", key: "Key", value: "Waarde", detected: "Gedetecteerde eisen", @@ -245,11 +261,122 @@ function readDefaults(): EnvDefault[] { } } +function readIgnoredAutoDefaults(): string[] { + const stored = localStorage.getItem("envhelper-ignored-auto-defaults"); + if (!stored) { + return []; + } + + try { + const parsed = JSON.parse(stored) as string[]; + return Array.isArray(parsed) ? parsed.filter(Boolean) : []; + } catch { + return []; + } +} + function readTheme(): ThemeMode { const stored = localStorage.getItem("envhelper-theme") as ThemeMode | null; return stored && themeLabels.includes(stored) ? stored : "system"; } +function inferAutomaticDefaults(input: string, manualDefaults: EnvDefault[], ignoredKeys: string[]): EnvDefault[] { + const manualKeys = new Set(manualDefaults.map((entry) => normalizeKey(entry.key)).filter(Boolean)); + const ignored = new Set(ignoredKeys.map(normalizeKey).filter(Boolean)); + const defaults = new Map(); + + for (const line of input.split(/\r?\n/)) { + const parsed = parseEnvAssignment(line); + if (!parsed) { + continue; + } + + const normalizedKey = normalizeKey(parsed.key); + if (!normalizedKey || manualKeys.has(normalizedKey) || ignored.has(normalizedKey)) { + continue; + } + + const value = inferDefaultValue(parsed.key, parsed.value); + if (value) { + defaults.set(normalizedKey, { key: parsed.key, value }); + } + } + + return [...defaults.values()].sort((a, b) => a.key.localeCompare(b.key)); +} + +function parseEnvAssignment(line: string): { key: string; value: string } | null { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + return null; + } + + const normalized = trimmed.startsWith("export ") ? trimmed.slice(7).trimStart() : trimmed; + const equalsIndex = normalized.indexOf("="); + if (equalsIndex <= 0) { + return null; + } + + return { + key: normalized.slice(0, equalsIndex).trim(), + value: normalized.slice(equalsIndex + 1).trim() + }; +} + +function inferDefaultValue(key: string, value: string): string | null { + const signal = normalizeKey(key); + const hasPlaceholder = /CHANGE_ME[A-Z0-9_]*/i.test(value); + const isEmpty = value.length === 0; + const shouldFillGeneric = hasPlaceholder || isEmpty; + + if (signal === "NODE_ENV") { + return "production"; + } + + if (signal.includes("BOOTSTRAP_ADMIN_EMAIL") || signal.includes("ADMIN_EMAIL")) { + return "admin@example.local"; + } + + if (signal.includes("SUPPORT_EMAIL")) { + return "support@example.local"; + } + + if (signal.includes("CONTACT_EMAIL")) { + return "contact@example.local"; + } + + if (shouldFillGeneric && signal.endsWith("EMAIL")) { + return "admin@example.local"; + } + + if (shouldFillGeneric && (signal.includes("BASE_URL") || signal.endsWith("_URL") || signal.endsWith("_URI") || signal.includes("ORIGIN"))) { + return "https://example.local"; + } + + if (shouldFillGeneric && signal.includes("PORT")) { + if (signal.includes("POSTGRES")) { + return "5432"; + } + + if (signal.includes("REDIS")) { + return "6379"; + } + + return "3000"; + } + + return null; +} + +function normalizeKey(key: string): string { + return key.trim().toUpperCase(); +} + +function addUnique(values: string[], value: string): string[] { + const normalized = normalizeKey(value); + return values.some((entry) => normalizeKey(entry) === normalized) ? values : [...values, value]; +} + export default function App() { const [input, setInput] = useState(""); const [loadedPath, setLoadedPath] = useState(null); @@ -258,8 +385,17 @@ export default function App() { const [settingsOpen, setSettingsOpen] = useState(false); const [themeMode, setThemeMode] = useState(readTheme); const [language, setLanguage] = useState(detectLanguage); - const [defaults, setDefaults] = useState(readDefaults); + const [manualDefaults, setManualDefaults] = useState(readDefaults); + const [ignoredAutoDefaults, setIgnoredAutoDefaults] = useState(readIgnoredAutoDefaults); const t = translations[language]; + const automaticDefaults = useMemo(() => inferAutomaticDefaults(input, manualDefaults, ignoredAutoDefaults), [input, manualDefaults, ignoredAutoDefaults]); + const defaults = useMemo( + () => [ + ...automaticDefaults.map((entry) => ({ ...entry, source: "auto" as const })), + ...manualDefaults.map((entry, index) => ({ ...entry, index, source: "manual" as const })) + ], + [automaticDefaults, manualDefaults] + ); const result = useMemo(() => transformEnv(input, defaults), [input, defaults, generation]); useEffect(() => { @@ -272,8 +408,12 @@ export default function App() { }, [language]); useEffect(() => { - localStorage.setItem("envhelper-defaults", JSON.stringify(defaults)); - }, [defaults]); + localStorage.setItem("envhelper-defaults", JSON.stringify(manualDefaults)); + }, [manualDefaults]); + + useEffect(() => { + localStorage.setItem("envhelper-ignored-auto-defaults", JSON.stringify(ignoredAutoDefaults)); + }, [ignoredAutoDefaults]); async function openFile() { const file = await window.envHelper?.openFile(); @@ -293,16 +433,35 @@ export default function App() { window.setTimeout(() => setCopyLabel("copy"), 1400); } - function updateDefault(index: number, field: keyof EnvDefault, value: string) { - setDefaults((current) => current.map((entry, entryIndex) => (entryIndex === index ? { ...entry, [field]: value } : entry))); + function updateDefault(row: DefaultRow, field: keyof EnvDefault, value: string) { + if (row.source === "auto") { + setIgnoredAutoDefaults((current) => addUnique(current, row.key)); + setManualDefaults((current) => [...current, { key: field === "key" ? value : row.key, value: field === "value" ? value : row.value }]); + return; + } + + if (row.index === undefined) { + return; + } + + setManualDefaults((current) => current.map((entry, entryIndex) => (entryIndex === row.index ? { ...entry, [field]: value } : entry))); } function addDefault() { - setDefaults((current) => [...current, { key: "", value: "" }]); + setManualDefaults((current) => [...current, { key: "", value: "" }]); } - function removeDefault(index: number) { - setDefaults((current) => current.filter((_, entryIndex) => entryIndex !== index)); + function removeDefault(row: DefaultRow) { + if (row.source === "auto") { + setIgnoredAutoDefaults((current) => addUnique(current, row.key)); + return; + } + + if (row.index === undefined) { + return; + } + + setManualDefaults((current) => current.filter((_, entryIndex) => entryIndex !== row.index)); } return ( @@ -380,20 +539,21 @@ export default function App() { } title={t.defaults} hint={t.defaultsHint} />
{defaults.map((entry, index) => ( -
+
updateDefault(index, "key", event.target.value)} + onChange={(event) => updateDefault(entry, "key", event.target.value)} /> updateDefault(index, "value", event.target.value)} + onChange={(event) => updateDefault(entry, "value", event.target.value)} /> -
diff --git a/src/styles.css b/src/styles.css index 1a4978c..c43a0f6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -366,7 +366,32 @@ textarea::selection { .defaultRow { display: grid; gap: 8px; - grid-template-columns: minmax(120px, 0.9fr) minmax(140px, 1.1fr) 36px; + grid-template-columns: minmax(120px, 0.9fr) minmax(140px, 1.1fr) auto 36px; +} + +.defaultSource { + align-items: center; + align-self: center; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted); + display: inline-flex; + font-size: 0.68rem; + font-weight: 850; + height: 28px; + justify-content: center; + padding: 0 10px; + white-space: nowrap; +} + +.defaultSource.auto { + background: var(--accent-soft); + border-color: color-mix(in srgb, var(--accent) 28%, var(--border)); + color: var(--accent-strong); +} + +.defaultSource.manual { + background: var(--surface-subtle); } .fullWidth { @@ -543,7 +568,7 @@ textarea::selection { } .defaultRow { - grid-template-columns: 1fr 1fr 36px; + grid-template-columns: 1fr 1fr auto 36px; } .settingsPanel {