Files
frigate/docs/scripts/generate-mock-manifest.mjs
T

367 lines
11 KiB
JavaScript

#!/usr/bin/env node
/** Build the compact field catalog used by documentation config mocks. */
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "../..");
const schemaPath = path.join(
repoRoot,
"web/e2e/fixtures/mock-data/config-schema.json",
);
const localeRoot = path.join(repoRoot, "web/public/locales/en/config");
const sectionConfigRoot = path.join(
repoRoot,
"web/src/components/config-form/section-configs",
);
const settingsSourcePath = path.join(repoRoot, "web/src/pages/Settings.tsx");
const settingsLocalePath = path.join(
repoRoot,
"web/public/locales/en/views/settings.json",
);
const outputPath = path.join(
repoRoot,
"docs/src/components/FrigateConfigMock/manifest.json",
);
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
const translations = {
global: JSON.parse(
fs.readFileSync(path.join(localeRoot, "global.json"), "utf8"),
),
camera: JSON.parse(
fs.readFileSync(path.join(localeRoot, "cameras.json"), "utf8"),
),
groups: JSON.parse(
fs.readFileSync(path.join(localeRoot, "groups.json"), "utf8"),
),
};
const settingsTranslations = JSON.parse(
fs.readFileSync(settingsLocalePath, "utf8"),
);
function resolveNode(node) {
if (!node || typeof node !== "object") return {};
if (node.$ref) {
const refName = node.$ref.split("/").at(-1);
return {
...resolveNode(schema.$defs?.[refName]),
...node,
$ref: undefined,
};
}
const variants = node.anyOf ?? node.oneOf;
if (Array.isArray(variants)) {
const concrete = variants.find((variant) => variant.type !== "null");
return {
...resolveNode(concrete),
...node,
anyOf: undefined,
oneOf: undefined,
};
}
return node;
}
function translationAt(level, section, fieldPath) {
let current = translations[level]?.[section];
for (const segment of fieldPath) {
if (!current || typeof current !== "object") return {};
current = current[segment];
}
return current && typeof current === "object" ? current : {};
}
function inferWidget(node) {
if (Array.isArray(node.enum)) return "select";
if (node.type === "boolean") return "switch";
if (
["integer", "number"].includes(node.type) &&
node.minimum !== undefined &&
(node.maximum !== undefined || node.exclusiveMaximum !== undefined)
) {
return "range";
}
if (node.type === "integer" || node.type === "number") return "number";
if (node.type === "array") return "tags";
if (node.type === "object") return "object";
return "text";
}
function extractArray(source, key) {
const match = source.match(new RegExp(`${key}\\s*:\\s*\\[([\\s\\S]*?)\\]`));
return match
? [...match[1].matchAll(/["']([^"']+)["']/g)].map((item) => item[1])
: [];
}
function extractObjectBlock(source, key) {
const match = new RegExp(`\\b${key}\\s*:\\s*\\{`).exec(source);
if (!match) return "";
const start = source.indexOf("{", match.index);
let depth = 0;
let quote = null;
let escaped = false;
for (let index = start; index < source.length; index += 1) {
const character = source[index];
if (quote) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === quote) quote = null;
continue;
}
if (['"', "'", "`"].includes(character)) {
quote = character;
continue;
}
if (character === "{") depth += 1;
if (character === "}") {
depth -= 1;
if (depth === 0) return source.slice(start + 1, index);
}
}
return "";
}
function extractGroups(source) {
const fieldGroups = {};
const groupsBlock = extractObjectBlock(source, "fieldGroups");
for (const match of groupsBlock.matchAll(/(\w+)\s*:\s*\[([\s\S]*?)\]/g)) {
fieldGroups[match[1]] = [...match[2].matchAll(/["']([^"']+)["']/g)].map(
(item) => item[1],
);
}
return fieldGroups;
}
function loadSectionHints(section, level) {
const configPath = path.join(sectionConfigRoot, `${section}.ts`);
if (!fs.existsSync(configPath)) return {};
const source = fs.readFileSync(configPath, "utf8");
const base = extractObjectBlock(source, "base");
const override = extractObjectBlock(source, level);
const overrideHas = (key) => new RegExp(`\\b${key}\\s*:`).test(override);
return {
order: overrideHas("fieldOrder")
? extractArray(override, "fieldOrder")
: extractArray(base, "fieldOrder"),
hidden: [
...extractArray(base, "hiddenFields"),
...extractArray(override, "hiddenFields"),
],
advanced: overrideHas("advancedFields")
? extractArray(override, "advancedFields")
: extractArray(base, "advancedFields"),
groups: overrideHas("fieldGroups")
? extractGroups(override)
: extractGroups(base),
docs: base.match(/sectionDocs\s*:\s*["']([^"']+)["']/)?.[1] ?? null,
};
}
function groupLabel(level, section, group) {
const domain = level === "camera" ? "cameras" : "global";
return (
translations.groups?.[section]?.[domain]?.[group] ??
group.replaceAll("_", " ").replace(/^./, (value) => value.toUpperCase())
);
}
function collectFields(level, section, sectionNode, hints) {
const fields = {};
function visit(rawNode, fieldPath = []) {
const node = resolveNode(rawNode);
const properties = node.properties;
if (properties && typeof properties === "object") {
for (const [name, child] of Object.entries(properties)) {
visit(child, [...fieldPath, name]);
}
return;
}
if (fieldPath.length === 0) return;
const key = fieldPath.join(".");
const localized = translationAt(level, section, fieldPath);
fields[key] = {
label: localized.label ?? node.title ?? fieldPath.at(-1),
description: localized.description ?? node.description ?? "",
widget: inferWidget(node),
default: node.default ?? null,
enum: node.enum ?? null,
minimum: node.minimum ?? node.exclusiveMinimum ?? null,
maximum: node.maximum ?? node.exclusiveMaximum ?? null,
advanced: hints.advanced?.includes(key) ?? false,
};
}
visit(sectionNode);
return fields;
}
function buildLevel(level) {
const rootProperties =
level === "camera"
? resolveNode(schema.$defs.CameraConfig).properties
: schema.properties;
const result = {};
for (const [section, rawNode] of Object.entries(rootProperties ?? {})) {
const node = resolveNode(rawNode);
if (!node.properties) continue;
const hints = loadSectionHints(section, level);
const hidden = new Set(hints.hidden ?? []);
const fields = collectFields(level, section, node, hints);
for (const key of hidden) delete fields[key];
const localized = translations[level]?.[section] ?? {};
result[section] = {
label: localized.label ?? rawNode.title ?? node.title ?? section,
description: localized.description ?? rawNode.description ?? "",
order: hints.order ?? [],
groups: Object.entries(hints.groups ?? {}).map(([key, groupFields]) => ({
key,
label: groupLabel(level, section, key),
fields: groupFields,
})),
docs: hints.docs ?? null,
fields,
};
}
return result;
}
function parseSectionMapping(source, constantName, level) {
const match = source.match(
new RegExp(`const ${constantName}[^=]*=\\s*\\{([\\s\\S]*?)\\n\\};`),
);
if (!match) return [];
return [...match[1].matchAll(/(\w+)\s*:\s*"([^"]+)"/g)].map(
([, section, page]) => ({ section, page, level }),
);
}
function buildNavigation() {
const source = fs.readFileSync(settingsSourcePath, "utf8");
const settingsBlock = source.match(
/const settingsGroups\s*=\s*\[([\s\S]*?)\n\];/,
)?.[1];
if (!settingsBlock) return { groups: [], pages: {} };
const mappings = [
...parseSectionMapping(source, "GLOBAL_SECTION_MAPPING", "global"),
...parseSectionMapping(source, "CAMERA_SECTION_MAPPING", "camera"),
...parseSectionMapping(source, "ENRICHMENTS_SECTION_MAPPING", "global"),
...parseSectionMapping(source, "SYSTEM_SECTION_MAPPING", "global"),
];
const pages = Object.fromEntries(
mappings.map((mapping) => [mapping.page, mapping]),
);
const groupMatches = [...settingsBlock.matchAll(/\{\s*label:\s*"([^"]+)"/g)];
const groups = groupMatches.map((match, index) => {
const start = match.index ?? 0;
const end = groupMatches[index + 1]?.index ?? settingsBlock.length;
const sourceSlice = settingsBlock.slice(start, end);
const itemKeys = [...sourceSlice.matchAll(/key:\s*"([^"]+)"/g)].map(
(item) => item[1],
);
return {
key: match[1],
label: settingsTranslations.menu?.[match[1]] ?? match[1],
items: itemKeys.map((key) => ({
key,
label: settingsTranslations.menu?.[key] ?? key,
...(key === "masksAndZones"
? { section: key, page: key, level: "camera" }
: {}),
...(pages[key] ?? {}),
})),
};
});
return { groups, pages };
}
function buildDetectorTypes() {
const detectorTranslations = translations.global?.detectors ?? {};
const reserved = new Set([
"label",
"description",
"type",
"model",
"model_path",
]);
return Object.fromEntries(
Object.entries(detectorTranslations)
.filter(
([key, value]) =>
!reserved.has(key) &&
value &&
typeof value === "object" &&
typeof value.label === "string" &&
typeof value.description === "string",
)
.map(([type, value]) => [
type,
{
label: value.label,
description: value.description,
fields: Object.fromEntries(
Object.entries(value)
.filter(
([key, field]) =>
!["label", "description"].includes(key) &&
field &&
typeof field === "object" &&
typeof field.label === "string",
)
.map(([key, field]) => [
key,
{
label: field.label,
description: field.description ?? "",
},
]),
),
},
]),
);
}
const manifest = {
generatedFrom: path.relative(repoRoot, schemaPath).replaceAll("\\", "/"),
detectorTypes: buildDetectorTypes(),
levels: {
global: buildLevel("global"),
camera: buildLevel("camera"),
},
navigation: buildNavigation(),
};
const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
if (process.argv.includes("--check")) {
const current = fs.existsSync(outputPath)
? fs.readFileSync(outputPath, "utf8")
: "";
if (current !== serialized) {
console.error(
`${path.relative(repoRoot, outputPath)} is stale. Run npm run build:mock.`,
);
process.exit(1);
}
console.log(`Checked ${path.relative(repoRoot, outputPath)}`);
} else {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, serialized);
console.log(`Generated ${path.relative(repoRoot, outputPath)}`);
}