Detect default values from env input
All checks were successful
Build Windows App / build-windows (push) Successful in 20m47s
All checks were successful
Build Windows App / build-windows (push) Successful in 20m47s
This commit is contained in:
196
src/App.tsx
196
src/App.tsx
@@ -20,8 +20,14 @@ import type { EnvDefault } from "./env";
|
|||||||
|
|
||||||
type ThemeMode = "system" | "light" | "dark";
|
type ThemeMode = "system" | "light" | "dark";
|
||||||
type Language = "de" | "en" | "es" | "fr" | "nl";
|
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<Language, string> = {
|
const languageNames: Record<Language, string> = {
|
||||||
de: "Deutsch",
|
de: "Deutsch",
|
||||||
@@ -50,8 +56,10 @@ const translations = {
|
|||||||
source: "Quelle",
|
source: "Quelle",
|
||||||
text: "Text",
|
text: "Text",
|
||||||
defaults: "Standardwerte",
|
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",
|
addDefault: "Standardwert hinzufügen",
|
||||||
|
autoDefault: "Auto",
|
||||||
|
manualDefault: "Manuell",
|
||||||
key: "Key",
|
key: "Key",
|
||||||
value: "Wert",
|
value: "Wert",
|
||||||
detected: "Erkannte Anforderungen",
|
detected: "Erkannte Anforderungen",
|
||||||
@@ -87,8 +95,10 @@ const translations = {
|
|||||||
source: "Source",
|
source: "Source",
|
||||||
text: "Text",
|
text: "Text",
|
||||||
defaults: "Defaults",
|
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",
|
addDefault: "Add default",
|
||||||
|
autoDefault: "Auto",
|
||||||
|
manualDefault: "Manual",
|
||||||
key: "Key",
|
key: "Key",
|
||||||
value: "Value",
|
value: "Value",
|
||||||
detected: "Detected requirements",
|
detected: "Detected requirements",
|
||||||
@@ -124,8 +134,10 @@ const translations = {
|
|||||||
source: "Origen",
|
source: "Origen",
|
||||||
text: "Texto",
|
text: "Texto",
|
||||||
defaults: "Valores predeterminados",
|
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",
|
addDefault: "Añadir valor",
|
||||||
|
autoDefault: "Auto",
|
||||||
|
manualDefault: "Manual",
|
||||||
key: "Clave",
|
key: "Clave",
|
||||||
value: "Valor",
|
value: "Valor",
|
||||||
detected: "Requisitos detectados",
|
detected: "Requisitos detectados",
|
||||||
@@ -161,8 +173,10 @@ const translations = {
|
|||||||
source: "Source",
|
source: "Source",
|
||||||
text: "Texte",
|
text: "Texte",
|
||||||
defaults: "Valeurs par défaut",
|
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",
|
addDefault: "Ajouter",
|
||||||
|
autoDefault: "Auto",
|
||||||
|
manualDefault: "Manuel",
|
||||||
key: "Clé",
|
key: "Clé",
|
||||||
value: "Valeur",
|
value: "Valeur",
|
||||||
detected: "Exigences détectées",
|
detected: "Exigences détectées",
|
||||||
@@ -198,8 +212,10 @@ const translations = {
|
|||||||
source: "Bron",
|
source: "Bron",
|
||||||
text: "Tekst",
|
text: "Tekst",
|
||||||
defaults: "Standaardwaarden",
|
defaults: "Standaardwaarden",
|
||||||
defaultsHint: "Deze waarden overschrijven gelijke keys in de output.",
|
defaultsHint: "Mogelijke standaardwaarden worden herkend; eigen waarden overschrijven keys.",
|
||||||
addDefault: "Waarde toevoegen",
|
addDefault: "Waarde toevoegen",
|
||||||
|
autoDefault: "Auto",
|
||||||
|
manualDefault: "Handmatig",
|
||||||
key: "Key",
|
key: "Key",
|
||||||
value: "Waarde",
|
value: "Waarde",
|
||||||
detected: "Gedetecteerde eisen",
|
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 {
|
function readTheme(): ThemeMode {
|
||||||
const stored = localStorage.getItem("envhelper-theme") as ThemeMode | null;
|
const stored = localStorage.getItem("envhelper-theme") as ThemeMode | null;
|
||||||
return stored && themeLabels.includes(stored) ? stored : "system";
|
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<string, EnvDefault>();
|
||||||
|
|
||||||
|
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() {
|
export default function App() {
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [loadedPath, setLoadedPath] = useState<string | null>(null);
|
const [loadedPath, setLoadedPath] = useState<string | null>(null);
|
||||||
@@ -258,8 +385,17 @@ export default function App() {
|
|||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [themeMode, setThemeMode] = useState<ThemeMode>(readTheme);
|
const [themeMode, setThemeMode] = useState<ThemeMode>(readTheme);
|
||||||
const [language, setLanguage] = useState<Language>(detectLanguage);
|
const [language, setLanguage] = useState<Language>(detectLanguage);
|
||||||
const [defaults, setDefaults] = useState<EnvDefault[]>(readDefaults);
|
const [manualDefaults, setManualDefaults] = useState<EnvDefault[]>(readDefaults);
|
||||||
|
const [ignoredAutoDefaults, setIgnoredAutoDefaults] = useState<string[]>(readIgnoredAutoDefaults);
|
||||||
const t = translations[language];
|
const t = translations[language];
|
||||||
|
const automaticDefaults = useMemo(() => inferAutomaticDefaults(input, manualDefaults, ignoredAutoDefaults), [input, manualDefaults, ignoredAutoDefaults]);
|
||||||
|
const defaults = useMemo<DefaultRow[]>(
|
||||||
|
() => [
|
||||||
|
...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]);
|
const result = useMemo(() => transformEnv(input, defaults), [input, defaults, generation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -272,8 +408,12 @@ export default function App() {
|
|||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("envhelper-defaults", JSON.stringify(defaults));
|
localStorage.setItem("envhelper-defaults", JSON.stringify(manualDefaults));
|
||||||
}, [defaults]);
|
}, [manualDefaults]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("envhelper-ignored-auto-defaults", JSON.stringify(ignoredAutoDefaults));
|
||||||
|
}, [ignoredAutoDefaults]);
|
||||||
|
|
||||||
async function openFile() {
|
async function openFile() {
|
||||||
const file = await window.envHelper?.openFile();
|
const file = await window.envHelper?.openFile();
|
||||||
@@ -293,16 +433,35 @@ export default function App() {
|
|||||||
window.setTimeout(() => setCopyLabel("copy"), 1400);
|
window.setTimeout(() => setCopyLabel("copy"), 1400);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDefault(index: number, field: keyof EnvDefault, value: string) {
|
function updateDefault(row: DefaultRow, field: keyof EnvDefault, value: string) {
|
||||||
setDefaults((current) => current.map((entry, entryIndex) => (entryIndex === index ? { ...entry, [field]: value } : entry)));
|
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() {
|
function addDefault() {
|
||||||
setDefaults((current) => [...current, { key: "", value: "" }]);
|
setManualDefaults((current) => [...current, { key: "", value: "" }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeDefault(index: number) {
|
function removeDefault(row: DefaultRow) {
|
||||||
setDefaults((current) => current.filter((_, entryIndex) => entryIndex !== index));
|
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 (
|
return (
|
||||||
@@ -380,20 +539,21 @@ export default function App() {
|
|||||||
<PanelHeading icon={<Plus size={18} />} title={t.defaults} hint={t.defaultsHint} />
|
<PanelHeading icon={<Plus size={18} />} title={t.defaults} hint={t.defaultsHint} />
|
||||||
<div className="defaultsList">
|
<div className="defaultsList">
|
||||||
{defaults.map((entry, index) => (
|
{defaults.map((entry, index) => (
|
||||||
<div className="defaultRow" key={index}>
|
<div className="defaultRow" key={`${entry.source}-${entry.key}-${index}`}>
|
||||||
<input
|
<input
|
||||||
aria-label={t.key}
|
aria-label={t.key}
|
||||||
placeholder={t.key}
|
placeholder={t.key}
|
||||||
value={entry.key}
|
value={entry.key}
|
||||||
onChange={(event) => updateDefault(index, "key", event.target.value)}
|
onChange={(event) => updateDefault(entry, "key", event.target.value)}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
aria-label={t.value}
|
aria-label={t.value}
|
||||||
placeholder={t.value}
|
placeholder={t.value}
|
||||||
value={entry.value}
|
value={entry.value}
|
||||||
onChange={(event) => updateDefault(index, "value", event.target.value)}
|
onChange={(event) => updateDefault(entry, "value", event.target.value)}
|
||||||
/>
|
/>
|
||||||
<button className="iconButton subtle" onClick={() => removeDefault(index)} title="Remove" type="button">
|
<span className={`defaultSource ${entry.source}`}>{entry.source === "auto" ? t.autoDefault : t.manualDefault}</span>
|
||||||
|
<button className="iconButton subtle" onClick={() => removeDefault(entry)} title="Remove" type="button">
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -366,7 +366,32 @@ textarea::selection {
|
|||||||
.defaultRow {
|
.defaultRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
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 {
|
.fullWidth {
|
||||||
@@ -543,7 +568,7 @@ textarea::selection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.defaultRow {
|
.defaultRow {
|
||||||
grid-template-columns: 1fr 1fr 36px;
|
grid-template-columns: 1fr 1fr auto 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settingsPanel {
|
.settingsPanel {
|
||||||
|
|||||||
Reference in New Issue
Block a user