Make the Generator's server-name lists editable, add themes, import names from Skatteverket

Name lists now live in settings instead of the web bundle. Admins edit them under
Settings -> Names (add/remove names, create/rename/delete lists, reset a built-in
list). Names are folded to hostname-safe form (a-z, 0-9, hyphen) and validated on
the server; saves are audited.

Adds Swedish boy names, Pixar, Norse mythology and Astrid Lindgren lists, and
lengthens the Swedish girl and Disney lists. "Mixed" is now every list with each
name counted once.

Admins can preview and import the most common Swedish names from Skatteverket's
open "Namn pa nyfodda" data (girls or boys, latest 1-5 full years); nothing is
stored until the editor is saved. The old comment that credited SCB statistics is
gone: SCB stopped publishing name statistics after 2023.

Privacy page, README and ROLES updated for the new outbound call and page.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5.5 committed 2026-10-03 01:53:31 +02:00
1 parent 447f33fff6
commit 3035d7fc08
14 files changed
+980 -44

No files matched your search

+3 -1
View File
@@ -38,6 +38,7 @@ import TagSettings from "./pages/settings/TagSettings";
import CacheSettings from "./pages/settings/CacheSettings";
import LogSettings from "./pages/settings/LogSettings";
import BackupSettings from "./pages/settings/BackupSettings";
import NameSettings from "./pages/settings/NameSettings";
import AppShell from "./layout/AppShell";
import DialogHost from "./components/DialogHost";
import { setDateTimeSettings } from "./utils/date";
@@ -100,7 +101,7 @@ export default function App() {
<Route path="/ports" element={<Ports user={user} />} />
<Route path="/secrets" element={<Secrets user={user} />} />
<Route path="/integrations" element={<Integrations user={user} />} />
<Route path="/generator" element={<Generator />} />
<Route path="/generator" element={<Generator user={user} />} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/consistency" element={<Consistency user={user} />} />
<Route path="/domains" element={<Domains user={user} />} />
@@ -161,6 +162,7 @@ export default function App() {
<Route path="badges" element={<BadgeSettings />} />
<Route path="display" element={<DisplaySettings />} />
<Route path="tags" element={<TagSettings />} />
<Route path="names" element={<NameSettings />} />
<Route path="cache" element={<CacheSettings />} />
<Route path="logs" element={<LogSettings />} />
<Route path="backup" element={<BackupSettings />} />
+29
View File
@@ -651,6 +651,28 @@ export interface PrivacyOverview {
};
}
export interface NameThemeSource {
kind: "skatteverket";
sex: "girls" | "boys";
years: number[];
count: number;
importedAt: string;
}
export interface NameTheme {
id: string;
label: string;
names: string[];
lastImport?: NameThemeSource;
}
export interface NameImportPreview {
names: string[];
source: NameThemeSource;
missingYears: number[];
skipped: string[];
}
export interface TagEntry {
name: string;
/** "#rrggbb", or null for the automatic colour. */
@@ -1030,6 +1052,13 @@ export const api = {
syncProxmox: () => request<IpamSyncResult>("/api/ipam/sync-proxmox", { method: "POST" }),
syncPhpIpam: () => request<IpamSyncResult>("/api/ipam/sync-phpipam", { method: "POST" }),
},
generator: {
themes: () => request<{ themes: NameTheme[]; builtinIds: string[] }>("/api/generator/themes"),
defaults: () => request<{ themes: NameTheme[] }>("/api/generator/themes/defaults"),
save: (themes: (Omit<NameTheme, "id"> & { id?: string })[]) => request<{ themes: NameTheme[] }>("/api/generator/themes", { method: "PUT", body: JSON.stringify({ themes }) }),
importNames: (sex: "girls" | "boys", years: number, count: number) =>
request<NameImportPreview>("/api/generator/themes/import", { method: "POST", body: JSON.stringify({ sex, years, count }) }),
},
alerts: {
list: (refresh = false) => request<AlertsReport>(`/api/alerts${refresh ? "?refresh=1" : ""}`),
},
+46 -13
View File
@@ -1,21 +1,26 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { api, type CurrentUser, type NameTheme } from "../api/client";
import CopyButton from "../components/CopyButton";
import { readableError } from "../utils/errors";
import {
generateServerName,
generateUsername,
generatePassword,
passwordEntropyBits,
passwordStrengthLabel,
type ServerNameTheme,
type UsernameOptions,
type PasswordOptions,
} from "../utils/generators";
export default function Generator() {
const MIXED = "mixed";
export default function Generator({ user }: { user: CurrentUser }) {
// ─── Server name ───────────────────────────────────────────────────────
const [existingServerNames, setExistingServerNames] = useState<string[]>([]);
const [theme, setTheme] = useState<ServerNameTheme>("mixed");
const [themes, setThemes] = useState<NameTheme[] | null>(null);
const [themesError, setThemesError] = useState<string | null>(null);
const [theme, setTheme] = useState<string>(MIXED);
const [serverName, setServerName] = useState("");
useEffect(() => {
@@ -27,8 +32,22 @@ export default function Generator() {
});
}, []);
useEffect(() => {
api.generator
.themes()
.then((res) => setThemes(res.themes))
.catch((err) => setThemesError(readableError(err)));
}, []);
// "Mixed" is every list together, each name once even if it appears in several.
const pool = useMemo(() => {
if (!themes) return [];
if (theme === MIXED) return [...new Set(themes.flatMap((t) => t.names))];
return themes.find((t) => t.id === theme)?.names ?? [];
}, [themes, theme]);
function rollServerName() {
setServerName(generateServerName(theme, existingServerNames));
setServerName(generateServerName(pool, existingServerNames));
}
// ─── Username ────────────────────────────────────────────────────────
@@ -64,7 +83,8 @@ export default function Generator() {
<>
<h2 className="page-title mb-3">Generator</h2>
<div className="text-secondary mb-3">
Everything here is generated locally in your browser — nothing is sent to or stored on the server.
Usernames and passwords are generated locally in your browser — nothing is sent to or stored on the server. Server names are
picked in your browser too, from name lists the server hands out.
</div>
<div className="row row-cards">
@@ -74,20 +94,33 @@ export default function Generator() {
<h3 className="card-title">Server name</h3>
</div>
<div className="card-body">
<p className="text-secondary">Picks from Swedish girl names and Disney characters, avoiding names already in use.</p>
<p className="text-secondary">
Picks from a list of names, avoiding names already in use.
{user.role === "admin" && (
<>
{" "}
<Link to="/settings/names">Edit the lists</Link>.
</>
)}
</p>
{themesError && <div className="alert alert-danger py-2">{themesError}</div>}
<label className="form-label">Theme</label>
<select className="form-select mb-3" value={theme} onChange={(e) => setTheme(e.target.value as ServerNameTheme)}>
<option value="mixed">Mixed</option>
<option value="swedish">Swedish girl names</option>
<option value="disney">Disney characters</option>
<select className="form-select mb-1" value={theme} onChange={(e) => setTheme(e.target.value)} disabled={!themes}>
<option value={MIXED}>Mixed — all lists</option>
{(themes ?? []).map((t) => (
<option key={t.id} value={t.id}>
{t.label} ({t.names.length})
</option>
))}
</select>
<div className="form-hint mb-3">{themes ? `${pool.length} name${pool.length === 1 ? "" : "s"} to pick from.` : "Loading the lists…"}</div>
<div className="input-group">
<input type="text" className="form-control" readOnly value={serverName} placeholder="Click Generate…" />
{serverName && <CopyButton text={serverName} />}
</div>
</div>
<div className="card-footer">
<button className="btn btn-primary" onClick={rollServerName}>
<button className="btn btn-primary" onClick={rollServerName} disabled={pool.length === 0}>
Generate
</button>
</div>
+5
View File
@@ -251,6 +251,11 @@ export default function Privacy() {
<strong>Certificate checks</strong> — for {outbound?.tlsCertificateChecks ?? 0} secret{outbound?.tlsCertificateChecks === 1 ? "" : "s"} with
a host to check, the app connects to that host to read its certificate.
</li>
<li className="mb-2">
<strong>Skatteverket</strong> — only when an admin clicks "Import from Skatteverket" under Settings → Names, the app asks
Skatteverket's open name statistics for the most common given names. The request carries a sex and a birth year, nothing about you
or your servers.
</li>
<li className="mb-2">
<strong>Servers and agents</strong> — {outbound?.serversWithAgent ?? 0} of {outbound?.servers ?? 0} servers have reported in. Agents push
their reports to the app; the app doesn't connect out to them, apart from port scans, which run only when an operator starts one
+1
View File
@@ -6,6 +6,7 @@ const SUB_NAV = [
{ to: "/settings/badges", label: "Badges" },
{ to: "/settings/display", label: "Display" },
{ to: "/settings/tags", label: "Tags" },
{ to: "/settings/names", label: "Names" },
{ to: "/settings/cache", label: "Cache" },
{ to: "/settings/logs", label: "Logs" },
{ to: "/settings/backup", label: "Backup" },
+408
View File
@@ -0,0 +1,408 @@
import { useEffect, useMemo, useState } from "react";
import { api, type NameImportPreview, type NameTheme, type NameThemeSource } from "../../api/client";
import { confirmDialog } from "../../utils/dialogs";
import { formatDateTime } from "../../utils/date";
import { readableError } from "../../utils/errors";
import { parseNames } from "../../utils/nameLists";
/** One list as it's being edited. The names are kept as the text in the box, so typing stays natural. */
interface Draft {
/** Stable for the life of the page; a list that hasn't been saved yet has no `id`. */
key: string;
id?: string;
label: string;
text: string;
lastImport?: NameThemeSource;
}
const MAX_LABEL = 40;
function toDraft(t: NameTheme): Draft {
return { key: t.id, id: t.id, label: t.label, text: t.names.join("\n"), lastImport: t.lastImport };
}
/** What identifies the content of a list, for telling whether anything changed. */
function snapshot(id: string | undefined, label: string, names: string[], lastImport: NameThemeSource | undefined): string {
return JSON.stringify({ id, label, names, lastImport });
}
function describeImport(s: NameThemeSource): string {
const years = s.years.length === 0 ? "" : s.years.length === 1 ? String(s.years[0]) : `${s.years[0]}–${s.years[s.years.length - 1]}`;
return `${s.sex === "girls" ? "girls" : "boys"}, ${years}, top ${s.count} — ${formatDateTime(new Date(s.importedAt))}`;
}
export default function NameSettings() {
const [saved, setSaved] = useState<NameTheme[] | null>(null);
const [builtinIds, setBuiltinIds] = useState<string[]>([]);
const [drafts, setDrafts] = useState<Draft[]>([]);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [newCount, setNewCount] = useState(0);
// Skatteverket import panel
const [importOpen, setImportOpen] = useState(false);
const [sex, setSex] = useState<"girls" | "boys">("girls");
const [years, setYears] = useState(3);
const [count, setCount] = useState(100);
const [fetching, setFetching] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [preview, setPreview] = useState<NameImportPreview | null>(null);
useEffect(() => {
api.generator
.themes()
.then((res) => {
setSaved(res.themes);
setBuiltinIds(res.builtinIds);
const next = res.themes.map(toDraft);
setDrafts(next);
setSelectedKey(next[0]?.key ?? null);
})
.catch((err) => setError(readableError(err)));
}, []);
const parsed = useMemo(() => drafts.map((d) => parseNames(d.text)), [drafts]);
const selectedIndex = Math.max(0, drafts.findIndex((d) => d.key === selectedKey));
const selected = drafts[selectedIndex];
const selectedParsed = parsed[selectedIndex];
const dirty = useMemo(() => {
if (!saved) return false;
const now = drafts.map((d, i) => snapshot(d.id, d.label.trim(), parsed[i].names, d.lastImport));
const before = saved.map((t) => snapshot(t.id, t.label, t.names, t.lastImport));
return JSON.stringify(now) !== JSON.stringify(before);
}, [saved, drafts, parsed]);
const problem = useMemo(() => {
for (let i = 0; i < drafts.length; i++) {
if (!drafts[i].label.trim()) return "Every list needs a name.";
if (parsed[i].invalid.length > 0) return `“${drafts[i].label.trim() || "A list"}” has names that can't be used — see the list.`;
}
return null;
}, [drafts, parsed]);
function update(key: string, patch: Partial<Draft>) {
setDrafts((all) => all.map((d) => (d.key === key ? { ...d, ...patch } : d)));
setNotice(null);
}
function select(key: string) {
setSelectedKey(key);
setImportOpen(false);
setPreview(null);
setImportError(null);
}
function addList() {
const key = `new-${newCount}`;
setNewCount((n) => n + 1);
setDrafts((all) => [...all, { key, label: "New list", text: "" }]);
select(key);
setNotice(null);
}
async function removeList(d: Draft) {
const ok = await confirmDialog({
title: "Delete list",
message: `Delete “${d.label.trim() || "this list"}”? It's removed when you save${d.id && builtinIds.includes(d.id) ? ", and can be brought back with “Restore built-in lists”" : ""}.`,
confirmLabel: "Delete",
danger: true,
});
if (!ok) return;
const index = drafts.findIndex((x) => x.key === d.key);
const rest = drafts.filter((x) => x.key !== d.key);
setDrafts(rest);
select(rest[Math.min(index, rest.length - 1)]?.key ?? "");
setNotice(null);
}
async function resetToBuiltIn(d: Draft) {
if (!d.id) return;
const ok = await confirmDialog({
title: "Reset list",
message: `Put “${d.label.trim() || d.id}” back to its built-in names? Your edits to this list are lost (once you save).`,
confirmLabel: "Reset",
danger: true,
});
if (!ok) return;
try {
const { themes } = await api.generator.defaults();
const original = themes.find((t) => t.id === d.id);
if (!original) throw new Error("There's no built-in version of this list.");
update(d.key, { label: original.label, text: original.names.join("\n"), lastImport: undefined });
} catch (err) {
setError(readableError(err));
}
}
const missingBuiltIns = builtinIds.filter((id) => !drafts.some((d) => d.id === id));
async function restoreBuiltIns() {
try {
const { themes } = await api.generator.defaults();
const missing = themes.filter((t) => missingBuiltIns.includes(t.id));
setDrafts((all) => [...all, ...missing.map(toDraft)]);
setNotice(`Brought back ${missing.length} built-in list${missing.length === 1 ? "" : "s"} — save to keep ${missing.length === 1 ? "it" : "them"}.`);
} catch (err) {
setError(readableError(err));
}
}
async function save() {
setBusy(true);
setError(null);
setNotice(null);
try {
const res = await api.generator.save(
drafts.map((d, i) => ({ id: d.id, label: d.label.trim(), names: parsed[i].names, lastImport: d.lastImport })),
);
const keepAt = selectedIndex;
setSaved(res.themes);
const next = res.themes.map(toDraft);
setDrafts(next);
setSelectedKey(next[Math.min(keepAt, next.length - 1)]?.key ?? null);
setNotice("Saved. The Generator uses these lists from now on.");
} catch (err) {
setError(readableError(err));
} finally {
setBusy(false);
}
}
function discard() {
if (!saved) return;
const next = saved.map(toDraft);
setDrafts(next);
setSelectedKey(next[0]?.key ?? null);
setNotice(null);
setError(null);
setImportOpen(false);
setPreview(null);
}
async function fetchPreview() {
setFetching(true);
setImportError(null);
setPreview(null);
try {
setPreview(await api.generator.importNames(sex, years, count));
} catch (err) {
setImportError(readableError(err));
} finally {
setFetching(false);
}
}
function applyPreview(mode: "add" | "replace") {
if (!preview || !selected) return;
const existing = mode === "add" ? selectedParsed.names : [];
const have = new Set(existing);
const fresh = preview.names.filter((n) => !have.has(n));
update(selected.key, { text: [...existing, ...fresh].join("\n"), lastImport: preview.source });
setNotice(
mode === "add"
? `Added ${fresh.length} new name${fresh.length === 1 ? "" : "s"} (${preview.names.length - fresh.length} were already there) — save to keep ${fresh.length === 1 ? "it" : "them"}.`
: `Replaced the list with ${preview.names.length} names — save to keep them.`,
);
setPreview(null);
setImportOpen(false);
}
function sortList() {
if (!selected) return;
update(selected.key, { text: [...selectedParsed.names].sort((a, b) => a.localeCompare(b, "sv")).join("\n") });
}
if (!saved) {
return error ? <div className="alert alert-danger">{error}</div> : <div className="text-secondary">Loading…</div>;
}
return (
<>
{error && <div className="alert alert-danger">{error}</div>}
{notice && <div className="alert alert-success">{notice}</div>}
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title">Name lists</h3>
<div className="card-actions btn-list">
{missingBuiltIns.length > 0 && (
<button className="btn btn-outline-secondary btn-sm" onClick={() => void restoreBuiltIns()}>
Restore built-in lists ({missingBuiltIns.length})
</button>
)}
<button className="btn btn-outline-primary btn-sm" onClick={addList} disabled={drafts.length >= 20}>
New list
</button>
</div>
</div>
<div className="card-body">
<div className="text-secondary small mb-3">
Operations → Generator picks server names from these lists. A name is letters, digits and hyphens (å, ä and ö become a, a and o), so
it works as a hostname. Changes apply when you save.
</div>
{drafts.length === 0 ? (
<div className="text-secondary">No lists. Add one, or restore the built-in ones.</div>
) : (
<ul className="nav nav-pills gap-1">
{drafts.map((d, i) => (
<li className="nav-item" key={d.key}>
<button className={`nav-link ${d.key === selected?.key ? "active" : ""}`} onClick={() => select(d.key)}>
{d.label.trim() || "(unnamed)"} <span className="ms-1 opacity-75">{parsed[i].names.length}</span>
</button>
</li>
))}
</ul>
)}
</div>
</div>
{selected && selectedParsed && (
<div className="card mb-3">
<div className="card-body">
<div className="row g-3">
<div className="col-md-6">
<label className="form-label">List name</label>
<input
className="form-control"
maxLength={MAX_LABEL}
value={selected.label}
onChange={(e) => update(selected.key, { label: e.target.value })}
/>
</div>
<div className="col-md-6 d-flex align-items-end justify-content-md-end">
<div className="btn-list">
<button className="btn btn-outline-secondary btn-sm" onClick={() => setImportOpen((v) => !v)}>
Import from Skatteverket…
</button>
<button className="btn btn-outline-secondary btn-sm" onClick={sortList} disabled={selectedParsed.names.length < 2}>
Sort A–Z
</button>
{selected.id && builtinIds.includes(selected.id) && (
<button className="btn btn-outline-secondary btn-sm" onClick={() => void resetToBuiltIn(selected)}>
Reset to built-in
</button>
)}
<button className="btn btn-outline-danger btn-sm" onClick={() => void removeList(selected)}>
Delete list
</button>
</div>
</div>
</div>
{importOpen && (
<div className="border rounded p-3 mt-3">
<div className="fw-bold mb-1">Import the most common names from Skatteverket</div>
<div className="text-secondary small mb-3">
Skatteverket publishes the given names of newborns in Sweden, by sex and birth year, as open data. This fetches the most common ones
across the years you pick (the running year isn't counted — it isn't complete). You see them first; nothing changes until you add
them to this list and save.
</div>
<div className="d-flex flex-wrap align-items-end gap-2">
<div>
<label className="form-label mb-1">Names for</label>
<select className="form-select" value={sex} onChange={(e) => setSex(e.target.value as "girls" | "boys")}>
<option value="girls">Girls</option>
<option value="boys">Boys</option>
</select>
</div>
<div>
<label className="form-label mb-1">Latest years</label>
<select className="form-select" value={years} onChange={(e) => setYears(Number(e.target.value))}>
{[1, 2, 3, 4, 5].map((n) => (
<option key={n} value={n}>
{n} year{n === 1 ? "" : "s"}
</option>
))}
</select>
</div>
<div>
<label className="form-label mb-1">How many names</label>
<input
type="number"
className="form-control"
style={{ width: 120 }}
min={10}
max={500}
value={count}
onChange={(e) => setCount(Math.min(500, Math.max(10, Number(e.target.value) || 10)))}
/>
</div>
<button className="btn btn-primary" onClick={() => void fetchPreview()} disabled={fetching}>
{fetching ? "Fetching…" : "Fetch names"}
</button>
</div>
{importError && <div className="alert alert-danger mt-3 mb-0">{importError}</div>}
{preview && (
<div className="mt-3">
<div className="mb-2">
Found <strong>{preview.names.length}</strong> names for {preview.source.sex === "girls" ? "girls" : "boys"}, born{" "}
{preview.source.years.join(", ")}.
{preview.missingYears.length > 0 && <span className="text-secondary"> No data yet for {preview.missingYears.join(", ")}.</span>}
{preview.skipped.length > 0 && (
<span className="text-secondary">
{" "}
{preview.skipped.length} left out because they can't be used as a server name ({preview.skipped.slice(0, 5).join(", ")}
{preview.skipped.length > 5 ? ", …" : ""}).
</span>
)}
</div>
<div className="text-secondary small mb-3" style={{ maxHeight: 96, overflow: "auto" }}>
{preview.names.join(", ")}
</div>
<div className="btn-list">
<button className="btn btn-primary btn-sm" onClick={() => applyPreview("add")}>
Add to this list
</button>
<button className="btn btn-outline-primary btn-sm" onClick={() => applyPreview("replace")}>
Replace this list
</button>
<button className="btn btn-link btn-sm" onClick={() => setPreview(null)}>
Cancel
</button>
</div>
</div>
)}
</div>
)}
<label className="form-label mt-3">
Names <span className="text-secondary fw-normal">— one per line, or separated by commas or spaces</span>
</label>
<textarea
className={`form-control font-monospace ${selectedParsed.invalid.length > 0 ? "is-invalid" : ""}`}
rows={14}
spellCheck={false}
value={selected.text}
onChange={(e) => update(selected.key, { text: e.target.value })}
/>
{selectedParsed.invalid.length > 0 && (
<div className="invalid-feedback d-block">
Can't be used as a server name: {selectedParsed.invalid.slice(0, 8).map((n) => `“${n}”`).join(", ")}
{selectedParsed.invalid.length > 8 ? `, and ${selectedParsed.invalid.length - 8} more` : ""}. Use letters, digits and hyphens.
</div>
)}
<div className="text-secondary small mt-2">
{selectedParsed.names.length} name{selectedParsed.names.length === 1 ? "" : "s"}
{selected.id && builtinIds.includes(selected.id) ? " · built-in list" : ""}
{selected.lastImport ? ` · last imported from Skatteverket (${describeImport(selected.lastImport)})` : ""}
</div>
</div>
</div>
)}
<div className="d-flex flex-wrap align-items-center gap-2">
<button className="btn btn-primary" onClick={() => void save()} disabled={busy || !dirty || problem !== null}>
{busy ? "Saving…" : "Save changes"}
</button>
<button className="btn btn-outline-secondary" onClick={discard} disabled={busy || !dirty}>
Discard changes
</button>
{dirty && !problem && <span className="text-secondary small">You have unsaved changes.</span>}
{problem && <span className="text-danger small">{problem}</span>}
</div>
</>
);
}
+8 -30
View File
@@ -16,41 +16,19 @@ function pick<T>(list: T[]): T {
// ─── Server names ───────────────────────────────────────────────────────────
export type ServerNameTheme = "swedish" | "disney" | "mixed";
// Common Swedish girl names, per SCB/Skatteverket's own published
// name-popularity statistics for recent birth cohorts.
const SWEDISH_GIRL_NAMES = [
"freja", "elsa", "alice", "maja", "wilma", "alma", "ebba", "lilly", "ella", "saga",
"agnes", "stella", "selma", "vera", "ingrid", "astrid", "linnea", "nova", "sara", "emma",
"julia", "olivia", "isabelle", "klara", "nellie", "elin", "signe", "tuva", "moa", "tyra",
"hedda", "nora", "amanda", "anna", "elvira", "iris", "matilda", "molly", "sofia", "thea",
"vilma", "cornelia", "filippa", "livia", "meja", "ronja", "sigrid", "tilde", "greta", "hilda",
];
// Single-word Disney character names (kept single-word so they slug into a
// hostname cleanly without a judgment call on how to join "Snow White").
const DISNEY_CHARACTERS = [
"moana", "elsa", "anna", "belle", "ariel", "jasmine", "aurora", "cinderella", "rapunzel", "mulan",
"tiana", "merida", "pocahontas", "mickey", "minnie", "simba", "nala", "stitch", "lilo", "olaf",
"baymax", "dory", "nemo", "woody", "buzz", "genie", "aladdin", "eric", "flynn", "kristoff",
"sven", "pumbaa", "timon", "mufasa", "scar", "ursula", "maleficent", "gaston", "hercules", "meg",
"tinkerbell", "wendy", "pinocchio", "bambi", "dumbo", "thumper", "flounder", "sebastian", "iago", "abu",
"pascal", "maximus", "heihei", "goofy", "donald", "daisy", "pluto", "chip", "dale", "bagheera",
"baloo", "mowgli", "rafiki", "zazu", "piglet", "tigger", "eeyore", "pooh",
];
/** Picks a name from the given theme not already present (case-insensitively) in `existing`, appending -2/-3/... only if the whole list is exhausted. */
export function generateServerName(theme: ServerNameTheme, existing: string[] = []): string {
const pool = theme === "swedish" ? SWEDISH_GIRL_NAMES : theme === "disney" ? DISNEY_CHARACTERS : [...SWEDISH_GIRL_NAMES, ...DISNEY_CHARACTERS];
/**
* Picks a name from `pool` that isn't already in `existing` (case-insensitively). Only if the whole pool is taken does it
* fall back to numbering one — maja2, maja3, …. The pools themselves are the name lists under Settings → Names.
*/
export function generateServerName(pool: string[], existing: string[] = []): string {
if (pool.length === 0) return "";
const used = new Set(existing.map((n) => n.toLowerCase()));
const available = pool.filter((n) => !used.has(n));
const available = pool.filter((n) => !used.has(n.toLowerCase()));
if (available.length > 0) return pick(available);
// Every name in the theme is already taken — fall back to numbering a random one.
const base = pick(pool);
for (let suffix = 2; suffix < 1000; suffix++) {
const candidate = `${base}${suffix}`;
if (!used.has(candidate)) return candidate;
if (!used.has(candidate.toLowerCase())) return candidate;
}
return `${base}${randomInt(100000)}`;
}
+33
View File
@@ -0,0 +1,33 @@
/**
* The same rule the server applies to every name in the Generator's lists (server/src/services/nameThemes.ts), so the
* editor can point out a bad name while it's being typed. The server checks again on save and is the one that decides.
*/
export function normalizeNameInput(raw: string): string | null {
const folded = raw
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.trim()
.toLowerCase();
return /^[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$/.test(folded) ? folded : null;
}
/** Names are separated by line breaks, commas, semicolons or spaces. */
export function splitNames(text: string): string[] {
return text.split(/[\s,;]+/).filter(Boolean);
}
/** The usable names in `text` (folded and de-duplicated, in the order written) and whatever couldn't be used. */
export function parseNames(text: string): { names: string[]; invalid: string[] } {
const names: string[] = [];
const invalid: string[] = [];
const seen = new Set<string>();
for (const token of splitNames(text)) {
const clean = normalizeNameInput(token);
if (clean === null) invalid.push(token);
else if (!seen.has(clean)) {
seen.add(clean);
names.push(clean);
}
}
return { names, invalid };
}