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:
@@ -206,6 +206,18 @@ offered as one-click suggestions when tagging), give any tag a colour of your ch
|
||||
that already exists merges the two, and both rename and delete rewrite every server
|
||||
that carries the tag.
|
||||
|
||||
**Name generator** — Operations → Generator suggests server names (and usernames and
|
||||
passwords, which are made in your browser and never stored). Server names are picked from
|
||||
name lists: Swedish girl and boy names, Disney and Pixar characters, Norse mythology and
|
||||
Astrid Lindgren to start with, or "Mixed" for all of them together. A name already used
|
||||
by a server isn't suggested. Admins edit the lists under Settings → Names — add and remove
|
||||
names, rename, delete or create lists, put a built-in list back as it was — and can
|
||||
import the most common Swedish names from Skatteverket's open name statistics for
|
||||
girls or boys over the latest one to five years. The import is shown to you first and only
|
||||
changes a list once you add it and save. (Statistics Sweden used to publish this but
|
||||
stopped after 2023.) Names are kept to letters, digits and hyphens so they work as
|
||||
hostnames; å, ä and ö become a, a and o.
|
||||
|
||||
**Privacy** — a page every signed-in user can open that says what this
|
||||
installation stores (accounts, sign-in sessions with their IP and browser, the audit
|
||||
and diagnostic logs, server reports, the secrets tracker, credentials), where data
|
||||
|
||||
@@ -79,6 +79,9 @@ even further, to admin only:
|
||||
- **Admin Links**: everyone can see and open every server's admin
|
||||
bookmarks, from that server's own page or the summary page; adding,
|
||||
editing, or removing one needs operator, from either place.
|
||||
- **Generator**: everyone can use it; the name lists it picks server names from
|
||||
are edited under Settings → Names, which is admin-only (and so is importing
|
||||
names from Skatteverket).
|
||||
- Everything under **Settings** (notification channels, badge colors,
|
||||
display prefs, log retention, backup/restore) is admin-only, matching
|
||||
the page itself being admin-only.
|
||||
|
||||
@@ -30,6 +30,7 @@ import { privacyRouter } from "./routes/privacy.js";
|
||||
import { tagsRouter } from "./routes/tags.js";
|
||||
import { portsRouter } from "./routes/ports.js";
|
||||
import { alertsRouter } from "./routes/alerts.js";
|
||||
import { generatorRouter } from "./routes/generator.js";
|
||||
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
|
||||
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
|
||||
import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js";
|
||||
@@ -106,6 +107,7 @@ app.use("/api/privacy", privacyRouter);
|
||||
app.use("/api/tags", tagsRouter);
|
||||
app.use("/api/ports", portsRouter);
|
||||
app.use("/api/alerts", alertsRouter);
|
||||
app.use("/api/generator", generatorRouter);
|
||||
|
||||
if (existsSync(webDist)) {
|
||||
app.use(express.static(webDist));
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { requireAuth, requireRole } from "../auth/middleware.js";
|
||||
import { recordAudit } from "../services/audit.js";
|
||||
import { getSettings, updateSettings } from "../services/settingsStore.js";
|
||||
import {
|
||||
DEFAULT_THEME_IDS,
|
||||
InvalidThemesError,
|
||||
MAX_NAME_LENGTH,
|
||||
cleanThemes,
|
||||
defaultThemes,
|
||||
importSkatteverketNames,
|
||||
readStoredThemes,
|
||||
type NameTheme,
|
||||
type NameThemeSource,
|
||||
} from "../services/nameThemes.js";
|
||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||
|
||||
export const generatorRouter = Router();
|
||||
generatorRouter.use(requireAuth);
|
||||
|
||||
async function currentThemes(): Promise<NameTheme[]> {
|
||||
return readStoredThemes((await getSettings()).nameGenerator.themes);
|
||||
}
|
||||
|
||||
// Read by everyone signed in — the Generator page needs the lists to pick from. Editing them is a Settings matter.
|
||||
generatorRouter.get("/themes", asyncHandler(async (_req, res) => {
|
||||
res.json({ themes: await currentThemes(), builtinIds: DEFAULT_THEME_IDS });
|
||||
}));
|
||||
|
||||
generatorRouter.get("/themes/defaults", requireRole("admin"), (_req, res) => {
|
||||
res.json({ themes: defaultThemes() });
|
||||
});
|
||||
|
||||
const sourceSchema = z.object({
|
||||
kind: z.literal("skatteverket"),
|
||||
sex: z.enum(["girls", "boys"]),
|
||||
years: z.array(z.number().int()).max(10),
|
||||
count: z.number().int(),
|
||||
importedAt: z.string().max(40),
|
||||
});
|
||||
|
||||
const saveSchema = z.object({
|
||||
themes: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().max(40).optional(),
|
||||
label: z.string().max(200),
|
||||
names: z.array(z.string().max(MAX_NAME_LENGTH * 3)).max(10000),
|
||||
lastImport: sourceSchema.optional(),
|
||||
}),
|
||||
)
|
||||
.max(100),
|
||||
});
|
||||
|
||||
/** A short, readable account of what an edit changed, for the audit log. */
|
||||
function describeThemeChanges(before: NameTheme[], after: NameTheme[]) {
|
||||
const beforeById = new Map(before.map((t) => [t.id, t]));
|
||||
const afterIds = new Set(after.map((t) => t.id));
|
||||
const created = after.filter((t) => !beforeById.has(t.id)).map((t) => `${t.label} (${t.names.length} names)`);
|
||||
const deleted = before.filter((t) => !afterIds.has(t.id)).map((t) => t.label);
|
||||
const edited: { list: string; renamedFrom?: string; added: number; removed: number }[] = [];
|
||||
for (const t of after) {
|
||||
const old = beforeById.get(t.id);
|
||||
if (!old) continue;
|
||||
const had = new Set(old.names);
|
||||
const has = new Set(t.names);
|
||||
const added = t.names.filter((n) => !had.has(n)).length;
|
||||
const removed = old.names.filter((n) => !has.has(n)).length;
|
||||
if (added || removed || old.label !== t.label) edited.push({ list: t.label, ...(old.label !== t.label ? { renamedFrom: old.label } : {}), added, removed });
|
||||
}
|
||||
return { created, deleted, edited };
|
||||
}
|
||||
|
||||
generatorRouter.put("/themes", requireRole("admin"), asyncHandler(async (req, res) => {
|
||||
const parsed = saveSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "That doesn't look like a set of name lists.", details: parsed.error.flatten() });
|
||||
|
||||
let themes: NameTheme[];
|
||||
try {
|
||||
themes = cleanThemes(parsed.data.themes);
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidThemesError) return res.status(400).json({ error: "invalid_names", message: err.message, invalid: err.invalid });
|
||||
throw err;
|
||||
}
|
||||
|
||||
const before = await currentThemes();
|
||||
const changes = describeThemeChanges(before, themes);
|
||||
await updateSettings({ nameGenerator: { themes } });
|
||||
|
||||
if (changes.created.length || changes.deleted.length || changes.edited.length) {
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "settings",
|
||||
action: "update_name_lists",
|
||||
targetType: "name_generator",
|
||||
detail: changes,
|
||||
});
|
||||
}
|
||||
res.json({ themes });
|
||||
}));
|
||||
|
||||
const importSchema = z.object({
|
||||
sex: z.enum(["girls", "boys"]),
|
||||
years: z.number().int().min(1).max(5),
|
||||
count: z.number().int().min(10).max(500),
|
||||
});
|
||||
|
||||
// Only fetches and returns a preview — nothing is stored until the editor's own Save, so an import can be looked at (and
|
||||
// thrown away) first. The address is fixed; none of the request's values go into it unchecked.
|
||||
generatorRouter.post("/themes/import", requireRole("admin"), asyncHandler(async (req, res) => {
|
||||
const parsed = importSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Pick girls or boys, 1–5 years and 10–500 names.", details: parsed.error.flatten() });
|
||||
|
||||
try {
|
||||
const result = await importSkatteverketNames(parsed.data.sex, parsed.data.years, parsed.data.count);
|
||||
const source: NameThemeSource = {
|
||||
kind: "skatteverket",
|
||||
sex: parsed.data.sex,
|
||||
years: result.years,
|
||||
count: parsed.data.count,
|
||||
importedAt: new Date().toISOString(),
|
||||
};
|
||||
res.json({ names: result.names, source, missingYears: result.missingYears, skipped: result.skipped });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: "import_failed", message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}));
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* The name lists behind Operations → Generator's server-name picker.
|
||||
*
|
||||
* The built-in lists below are hand-picked, not taken from any official source — they're a starting point. Admins can
|
||||
* edit every list under Settings → Names, and can replace a Swedish list with real statistics from Skatteverket.
|
||||
*/
|
||||
|
||||
export interface NameThemeSource {
|
||||
kind: "skatteverket";
|
||||
sex: "girls" | "boys";
|
||||
/** Birth years that were combined. */
|
||||
years: number[];
|
||||
/** How many names the import asked for. */
|
||||
count: number;
|
||||
importedAt: string;
|
||||
}
|
||||
|
||||
export interface NameTheme {
|
||||
id: string;
|
||||
label: string;
|
||||
names: string[];
|
||||
/** The last import into this list, if there's been one. Editing the list by hand doesn't clear it. */
|
||||
lastImport?: NameThemeSource;
|
||||
}
|
||||
|
||||
export const MAX_THEMES = 20;
|
||||
export const MAX_NAMES_PER_THEME = 2000;
|
||||
export const MAX_LABEL_LENGTH = 40;
|
||||
export const MAX_NAME_LENGTH = 30;
|
||||
|
||||
/**
|
||||
* Names end up as server names and hostnames, so they're kept to lowercase a–z, digits and inner hyphens. Accents are
|
||||
* folded away first (Åsa → asa, José → jose) so a pasted Swedish name isn't rejected over its å, ä or ö.
|
||||
*/
|
||||
export function normalizeName(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;
|
||||
}
|
||||
|
||||
export function slugifyId(label: string): string {
|
||||
return (
|
||||
label
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40) || "list"
|
||||
);
|
||||
}
|
||||
|
||||
function words(text: string): string[] {
|
||||
return text.split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
const SWEDISH_GIRLS = words(`
|
||||
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 leia lovisa siri jasmine felicia ida lina
|
||||
mira mimmi lykke ellen ellie emelie hanna jenny josefin kajsa karin lisa lotta maria märta nathalie pia
|
||||
rebecka sanna sally stina svea tindra ylva yvonne
|
||||
`)
|
||||
.map((n) => normalizeName(n))
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
const SWEDISH_BOYS = words(`
|
||||
noah liam hugo lucas oliver elias oscar william adam alfred nils axel arvid vincent theo leo ludvig filip viktor
|
||||
albin gustav melvin sixten love ivar edvin otto wilmer malte valter folke sigge algot ebbe noel benjamin felix isak
|
||||
jonathan anton erik emil johan karl lars anders mattias henrik jakob sebastian daniel samuel alex max rasmus
|
||||
tage olle tobias simon kasper julius ture vidar viggo vilgot ville lennart gunnar bertil sten stig bo björn
|
||||
rolf ulf kurt mats magnus mikael peter per pontus
|
||||
`)
|
||||
.map((n) => normalizeName(n))
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
const DISNEY = words(`
|
||||
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 hades phil jafar rajah tarzan jane kida milo pacha kuzco yzma bolt judy nick gazelle mirabel bruno luisa
|
||||
isabela cruella dodger tramp lady jiminy figaro cleo shenzi banzai kronk vanellope ralph esmeralda quasimodo
|
||||
megara belle gus jaq
|
||||
`)
|
||||
.map((n) => normalizeName(n))
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
const PIXAR = words(`
|
||||
jessie rex hamm slinky bo lotso flik heimlich mike sulley boo randall marlin crush bruce gill remy linguini colette
|
||||
walle eve carl russell dug kevin lightning mater sally doc luigi guido fillmore joy sadness anger fear disgust bing
|
||||
arlo spot miguel hector dante ian barley luca alberto giulia mei ming elastigirl dash violet jack edna syndrome
|
||||
forky gabby ducky bunny duke ember wade bing-bong riley
|
||||
`)
|
||||
.map((n) => normalizeName(n))
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
const NORSE = words(`
|
||||
odin thor loki freya frigg baldur tyr heimdall idun bragi njord freyr sif hel ymir fenrir sleipnir ran aegir skadi
|
||||
vidar vali ullr forseti hermod sigyn nanna jord eir fulla gefjon mimir yggdrasil asgard midgard valhalla bifrost
|
||||
ragnarok jormungandr hugin munin audhumla surtr
|
||||
`)
|
||||
.map((n) => normalizeName(n))
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
const LINDGREN = words(`
|
||||
pippi emil ida lotta madicken mio ronja birk mattis borka karlsson lillebror rasmus tengil katla skorpan jonatan
|
||||
nangijala bullerbyn vimmerby lonneberga junibacken villekulla kalle
|
||||
`)
|
||||
.map((n) => normalizeName(n))
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
function dedupe(list: string[]): string[] {
|
||||
return [...new Set(list)];
|
||||
}
|
||||
|
||||
/** What a fresh install starts with, and what "restore" puts back. */
|
||||
export function defaultThemes(): NameTheme[] {
|
||||
return [
|
||||
{ id: "swedish-girls", label: "Swedish girl names", names: dedupe(SWEDISH_GIRLS) },
|
||||
{ id: "swedish-boys", label: "Swedish boy names", names: dedupe(SWEDISH_BOYS) },
|
||||
{ id: "disney", label: "Disney characters", names: dedupe(DISNEY) },
|
||||
{ id: "pixar", label: "Pixar characters", names: dedupe(PIXAR) },
|
||||
{ id: "norse", label: "Norse mythology", names: dedupe(NORSE) },
|
||||
{ id: "lindgren", label: "Astrid Lindgren", names: dedupe(LINDGREN) },
|
||||
];
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_IDS = defaultThemes().map((t) => t.id);
|
||||
|
||||
// ─── Validating an edit ──────────────────────────────────────────────────────
|
||||
|
||||
export interface InvalidName {
|
||||
theme: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class InvalidThemesError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly invalid: InvalidName[] = [],
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns whatever the editor sent into clean themes, or throws with a message that says what to fix. Names are folded
|
||||
* and de-duplicated; a name that can't be made into a hostname-safe one is reported, never silently dropped.
|
||||
*/
|
||||
export function cleanThemes(input: { id?: string; label: string; names: string[]; lastImport?: NameThemeSource }[]): NameTheme[] {
|
||||
if (input.length > MAX_THEMES) throw new InvalidThemesError(`At most ${MAX_THEMES} lists.`);
|
||||
const usedIds = new Set<string>();
|
||||
const invalid: InvalidName[] = [];
|
||||
const out: NameTheme[] = [];
|
||||
|
||||
for (const raw of input) {
|
||||
const label = raw.label.trim();
|
||||
if (!label) throw new InvalidThemesError("Every list needs a name.");
|
||||
if (label.length > MAX_LABEL_LENGTH) throw new InvalidThemesError(`“${label}” — list names can be at most ${MAX_LABEL_LENGTH} characters.`);
|
||||
|
||||
let id = raw.id && /^[a-z0-9-]{1,40}$/.test(raw.id) ? raw.id : slugifyId(label);
|
||||
for (let n = 2; usedIds.has(id); n++) id = `${slugifyId(label)}-${n}`;
|
||||
usedIds.add(id);
|
||||
|
||||
const names: string[] = [];
|
||||
for (const entry of raw.names) {
|
||||
if (!entry.trim()) continue;
|
||||
const clean = normalizeName(entry);
|
||||
if (clean === null) invalid.push({ theme: label, name: entry.trim() });
|
||||
else names.push(clean);
|
||||
}
|
||||
const unique = dedupe(names);
|
||||
if (unique.length > MAX_NAMES_PER_THEME) throw new InvalidThemesError(`“${label}” has ${unique.length} names — at most ${MAX_NAMES_PER_THEME} per list.`);
|
||||
out.push({ id, label, names: unique, ...(raw.lastImport ? { lastImport: raw.lastImport } : {}) });
|
||||
}
|
||||
|
||||
if (invalid.length > 0) {
|
||||
const shown = invalid.slice(0, 5).map((i) => `“${i.name}”`).join(", ");
|
||||
throw new InvalidThemesError(
|
||||
`${invalid.length} name${invalid.length === 1 ? " isn't" : "s aren't"} usable as a server name (${shown}${invalid.length > 5 ? ", …" : ""}). Use letters, digits and hyphens, no spaces.`,
|
||||
invalid,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Reads themes back out of settings defensively — a backup restore or hand-edited row must not be able to break the Generator. */
|
||||
export function readStoredThemes(stored: unknown): NameTheme[] {
|
||||
if (!Array.isArray(stored)) return defaultThemes();
|
||||
const themes: NameTheme[] = [];
|
||||
for (const t of stored) {
|
||||
if (!t || typeof t !== "object") continue;
|
||||
const { id, label, names, lastImport } = t as Partial<NameTheme>;
|
||||
if (typeof id !== "string" || typeof label !== "string" || !Array.isArray(names)) continue;
|
||||
themes.push({
|
||||
id,
|
||||
label,
|
||||
names: names.filter((n): n is string => typeof n === "string"),
|
||||
...(lastImport && typeof lastImport === "object" ? { lastImport } : {}),
|
||||
});
|
||||
}
|
||||
return themes;
|
||||
}
|
||||
|
||||
// ─── Importing from Skatteverket ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Skatteverket's open-data API for "Namn på nyfödda": the most common given names of babies, by sex and birth year,
|
||||
* for the whole country ("Total"). It needs no key. Statistics Sweden (SCB), which used to publish this, stopped
|
||||
* after 2023 and points to Skatteverket.
|
||||
*/
|
||||
const SKATTEVERKET_DATASET = "https://skatteverket.entryscape.net/rowstore/dataset/da2556d0-c717-45e8-a1d8-3320161d3a7d";
|
||||
const PAGE_SIZE = 500;
|
||||
const MAX_PAGES = 4;
|
||||
const FETCH_TIMEOUT_MS = 20_000;
|
||||
|
||||
export interface NameImport {
|
||||
names: string[];
|
||||
years: number[];
|
||||
/** Years that had no data (yet) and were left out. */
|
||||
missingYears: number[];
|
||||
/** Names Skatteverket lists that can't be used as a server name (a space, an unusual letter) and were left out. */
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
interface Row {
|
||||
namn?: string;
|
||||
antal?: string;
|
||||
rangordning?: string;
|
||||
}
|
||||
|
||||
async function fetchYear(sex: "girls" | "boys", year: number): Promise<Row[]> {
|
||||
const rows: Row[] = [];
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const url = `${SKATTEVERKET_DATASET}?gruppering=Total&fodelsear=${year}&k%C3%B6n=${sex === "girls" ? "Kvinna" : "Man"}&_limit=${PAGE_SIZE}&_offset=${page * PAGE_SIZE}`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { Accept: "application/json" } });
|
||||
if (!res.ok) throw new Error(`Skatteverket's name service answered ${res.status}.`);
|
||||
const body = (await res.json()) as { results?: Row[] };
|
||||
const results = Array.isArray(body.results) ? body.results : [];
|
||||
rows.push(...results);
|
||||
if (results.length < PAGE_SIZE) break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* The most common names over the last `yearCount` full calendar years (the running year is only partly counted, so it's
|
||||
* skipped). Counts are added up across years, so one unusually popular year doesn't decide the list.
|
||||
*/
|
||||
export async function importSkatteverketNames(sex: "girls" | "boys", yearCount: number, count: number, now = new Date()): Promise<NameImport> {
|
||||
const wanted = Array.from({ length: yearCount }, (_, i) => now.getUTCFullYear() - 1 - i);
|
||||
const totals = new Map<string, number>();
|
||||
const skipped = new Set<string>();
|
||||
const years: number[] = [];
|
||||
const missingYears: number[] = [];
|
||||
|
||||
for (const year of wanted) {
|
||||
let rows: Row[];
|
||||
try {
|
||||
rows = await fetchYear(sex, year);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "TimeoutError") throw new Error("Skatteverket's name service didn't answer in time.");
|
||||
throw err;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
missingYears.push(year);
|
||||
continue;
|
||||
}
|
||||
years.push(year);
|
||||
for (const row of rows) {
|
||||
const raw = (row.namn ?? "").trim();
|
||||
const amount = Number(row.antal);
|
||||
if (!raw || !Number.isFinite(amount)) continue;
|
||||
const clean = normalizeName(raw);
|
||||
if (clean === null) {
|
||||
skipped.add(raw);
|
||||
continue;
|
||||
}
|
||||
totals.set(clean, (totals.get(clean) ?? 0) + amount);
|
||||
}
|
||||
}
|
||||
|
||||
if (years.length === 0) throw new Error("Skatteverket has no name statistics for those years.");
|
||||
|
||||
const names = [...totals.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], "sv"))
|
||||
.slice(0, count)
|
||||
.map(([name]) => name);
|
||||
return { names, years: years.sort((a, b) => a - b), missingYears, skipped: [...skipped].sort((a, b) => a.localeCompare(b, "sv")) };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { settings } from "../db/schema.js";
|
||||
import { defaultThemes, type NameTheme } from "./nameThemes.js";
|
||||
|
||||
export interface GotifySettings {
|
||||
enabled: boolean;
|
||||
@@ -99,6 +100,11 @@ export interface ConsistencySettings {
|
||||
excludedRanges: string[];
|
||||
}
|
||||
|
||||
export interface NameGeneratorSettings {
|
||||
/** The lists the Generator picks server names from — edited under Settings → Names. */
|
||||
themes: NameTheme[];
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
gotify: GotifySettings;
|
||||
ntfy: NtfySettings;
|
||||
@@ -112,6 +118,7 @@ export interface AppSettings {
|
||||
quietHours: QuietHoursSettings;
|
||||
healthChecks: HealthCheckSettings;
|
||||
consistency: ConsistencySettings;
|
||||
nameGenerator: NameGeneratorSettings;
|
||||
}
|
||||
|
||||
const DEFAULTS: AppSettings = {
|
||||
@@ -145,6 +152,7 @@ const DEFAULTS: AppSettings = {
|
||||
// Docker's default bridge (172.17.0.0/16) and the pool it hands custom networks from (172.18–31) live here, and every
|
||||
// Docker host repeats them. Shown on the Consistency page, where it can be removed if 172.16/12 is used as a real LAN.
|
||||
consistency: { excludedRanges: ["172.16.0.0/12"] },
|
||||
nameGenerator: { themes: defaultThemes() },
|
||||
};
|
||||
|
||||
const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[];
|
||||
|
||||
+3
-1
@@ -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 />} />
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user