#!/usr/bin/env python3 """Generate the React/Worker module routing table from the parity matrix.""" from __future__ import annotations import json import re from pathlib import Path ROOT = Path(__file__).resolve().parents[1] MATRIX = ROOT / "docs/web-parity-matrix.json" SRC = ROOT / "web/src" OUT = SRC / "module-registry.generated.ts" COMMAND_RE = re.compile(r'export\s+const\s+\w*COMMAND\s*=\s*["\']([^"\']+)["\']') NATIVE_DESKTOP_MACRO_RE = re.compile(r'FC_WEB_DESKTOP_COMMAND\("([^"]+)"\s*,') NATIVE_DESKTOP_LIST_RE = re.compile(r'(?:desktopCommands|family\s*==\s*QStringLiteral\("desktop-command"\).*?QStringList\s+\w+)\s*=\s*\{(.*?)\};', re.DOTALL) QSTRING_RE = re.compile(r'QStringLiteral\("([^"]+)"\)') PROBE_ROOT = ROOT / "freecad-source/src/WebAssembly/FreeCADCoreProbe" PRIMARY_WORKERS = { "Part": "part.worker.ts", "PartDesign": "partdesign.worker.ts", "Sketcher": "sketcher.worker.ts", "TechDraw": "techdraw-native.worker.ts", "Import": "import.worker.ts", } COMMAND_WORKERS = { ("Part", "part.viewCommand"): "part.worker.ts", ("Part", "part.documentViewCommand"): "part-save.worker.ts", ("Part", "part.document"): "part-save.worker.ts", ("Web", "web.statefulFamilyBatch"): "stateful-family.worker.ts", ("Part", "part.saveReopen"): "part-save.worker.ts", ("TechDraw", "techdraw.nativeSaveReopen"): "techdraw-python.worker.ts", ("TechDraw", "techdraw.desktopCommand"): "techdraw-python.worker.ts", ("Web", "web.dockOverlay"): "web-dock-overlay.worker.ts", ("TechDraw", "techdraw.pythonMethod"): "techdraw-python.worker.ts", ("Spreadsheet", "spreadsheet.saveReopen"): "spreadsheet-save.worker.ts", ("PartDesign", "partdesign.pythonApi"): "partdesign-python.worker.ts", ("PartDesign", "partdesign.pythonFamilyBatch"): "partdesign-python.worker.ts", ("PartDesign", "partdesign.geometryFamily"): "partdesign.worker.ts", ("Points", "points.pythonApi"): "points.worker.ts", ("Plot", "plot.pythonApi"): "plot-python.worker.ts", ("ReverseEngineering", "reverseengineering.pythonApi"): "reverseengineering-python.worker.ts", ("ReverseEngineering", "reverseengineering.pythonFamilyBatch"): "reverseengineering-python.worker.ts", ("Robot", "robot.pythonApi"): "robot-python.worker.ts", ("Sandbox", "sandbox.pythonApi"): "sandbox-python.worker.ts", ("Show", "show.pythonApi"): "show-python.worker.ts", ("Sketcher", "sketcher.pythonApi"): "sketcher-python.worker.ts", ("Sketcher", "sketcher.pythonFamilyBatch"): "sketcher-python.worker.ts", ("Sketcher", "sketcher.pythonMethod"): "sketcher-python.worker.ts", ("Spreadsheet", "spreadsheet.pythonApi"): "spreadsheet-python.worker.ts", ("Spreadsheet", "spreadsheet.pythonFamilyBatch"): "spreadsheet-python.worker.ts", ("Start", "start.pythonApi"): "start-python.worker.ts", ("Surface", "surface.pythonApi"): "surface-python.worker.ts", ("Surface", "surface.pythonFamilyBatch"): "surface-python.worker.ts", ("TechDraw", "techdraw.pythonApi"): "techdraw-python.worker.ts", ("TechDraw", "techdraw.pythonFamilyBatch"): "techdraw-python.worker.ts", ("TemplatePyMod", "templatepymod.pythonApi"): "templatepymod-python.worker.ts", ("PartDesign", "partdesign.inspectSourceInventory"): "partdesign-inspect.worker.ts", ("PartDesign", "partdesign.saveReopen"): "partdesign.worker.ts", ("Sketcher", "sketcher.inspectSourceInventory"): "sketcher-inspect.worker.ts", ("Sketcher", "sketcher.saveReopen"): "sketcher-persistence.worker.ts", ("TechDraw", "techdraw.createRecompute"): "techdraw-python.worker.ts", ("TechDraw", "techdraw.nativeInventory"): "techdraw-inspect.worker.ts", ("Points", "points.inspectSource"): "points.worker.ts", ("Points", "points.saveReopen"): "points-persistence.worker.ts", ("Surface", "surface.createRecompute"): "surface.worker.ts", ("Surface", "surface.inspectSourceInventory"): "surface-inspect.worker.ts", ("Surface", "surface.pythonApi"): "surface-python.worker.ts", ("Surface", "surface.saveReopen"): "surface-save.worker.ts", ("Spreadsheet", "spreadsheet.createRecompute"): "spreadsheet.worker.ts", ("Spreadsheet", "spreadsheet.inspectSourceInventory"): "spreadsheet-inspect.worker.ts", ("Spreadsheet", "spreadsheet.pythonApi"): "spreadsheet-python.worker.ts", ("Spreadsheet", "spreadsheet.saveReopen"): "spreadsheet-save.worker.ts", ("Robot", "robot.createRecompute"): "robot-create.worker.ts", ("Robot", "robot.inspectSourceInventory"): "robot-inspect.worker.ts", ("Robot", "robot.saveReopen"): "robot-save.worker.ts", ("Sandbox", "sandbox.createRecompute"): "sandbox.worker.ts", ("Sandbox", "sandbox.inspectSourceInventory"): "sandbox-inspect.worker.ts", ("Sandbox", "sandbox.pythonApi"): "sandbox-python.worker.ts", ("Sandbox", "sandbox.saveReopen"): "sandbox.worker.ts", ("ReverseEngineering", "reverseengineering.createRecompute"): "reverseengineering-create.worker.ts", ("ReverseEngineering", "reverseengineering.inspectSourceInventory"): "reverseengineering-inspect.worker.ts", ("ReverseEngineering", "reverseengineering.pythonApi"): "reverseengineering-python.worker.ts", ("ReverseEngineering", "reverseengineering.saveReopen"): "reverseengineering-save.worker.ts", ("Start", "start.inspectSourceInventory"): "start-inventory.worker.ts", ("Start", "start.pythonApi"): "start-python.worker.ts", ("Start", "start.moduleImport"): "start.worker.ts", ("Start", "start.importSvg"): "start.worker.ts", ("Show", "show.inspectSourceInventory"): "show-inspect.worker.ts", ("Show", "show.pythonApi"): "show-python.worker.ts", ("Assembly", "assembly.saveReopen"): "assembly.worker.ts", ("Assembly", "assembly.nativeTypeProbe"): "assembly.worker.ts", ("BIM", "bim.createAxis"): "bim.worker.ts", ("BIM", "bim.extrusionData"): "bim.worker.ts", ("CAM", "cam.saveReopen"): "cam.worker.ts", ("CAM", "cam.pythonFamilyBatch"): "cam.worker.ts", ("Cloud", "cloud.saveReopen"): "cloud.worker.ts", ("Cloud", "cloud.pythonFamilyBatch"): "cloud.worker.ts", ("Draft", "draft.pythonFamilyBatch"): "draft.worker.ts", ("Fem", "fem.pythonFamilyBatch"): "fem.worker.ts", ("Import", "import.pythonFamilyBatch"): "import.worker.ts", ("Inspection", "inspection.pythonFamilyBatch"): "inspection.worker.ts", ("Import", "import.saveReopen"): "import.worker.ts", ("Inspection", "inspection.saveReopen"): "inspection.worker.ts", ("JtReader", "jtreader.saveReopen"): "jtreader.worker.ts", ("Material", "material.saveReopen"): "material.worker.ts", ("Material", "material.pythonApi"): "material.worker.ts", ("Material", "material.pythonFamilyBatch"): "material.worker.ts", ("Measure", "measure.pythonFamilyBatch"): "measure.worker.ts", ("Mesh", "mesh.pythonFamilyBatch"): "mesh.worker.ts", ("MeshPart", "meshpart.pythonFamilyBatch"): "meshpart.worker.ts", ("Measure", "measure.saveReopen"): "measure.worker.ts", ("Mesh", "mesh.saveReopen"): "mesh.worker.ts", ("Part", "part.saveReopen"): "part-save.worker.ts", ("Points", "points.saveReopen"): "points-persistence.worker.ts", ("ReverseEngineering", "reverseengineering.saveReopen"): "reverseengineering-save.worker.ts", ("Robot", "robot.saveReopen"): "robot-save.worker.ts", ("Sandbox", "sandbox.saveReopen"): "sandbox.worker.ts", ("Sketcher", "sketcher.saveReopen"): "sketcher-persistence.worker.ts", ("Surface", "surface.saveReopen"): "surface-save.worker.ts", ("TechDraw", "techdraw.nativeSaveReopen"): "techdraw-python.worker.ts", ("Web", "web.saveReopenBoundary"): "web-persistence.worker.ts", } def source_stem(module: str) -> str: return re.sub(r"[^a-z0-9]", "", module.lower()) def module_files(module: str) -> list[str]: stem = source_stem(module) return sorted( path.name for path in SRC.glob(f"{stem}*.worker.ts") if path.name.startswith(f"{stem}.") or path.name.startswith(f"{stem}-") if path.name != "core.worker.ts" ) def native_desktop_commands(module: str) -> set[str]: commands: set[str] = set() paths = list(PROBE_ROOT.glob(f"{module}*Probe.cpp")) desktop = PROBE_ROOT / f"{module}DesktopCommands.cpp" if desktop.is_file(): paths.append(desktop) for path in dict.fromkeys(paths): text = path.read_text(encoding="utf-8", errors="replace") commands.update(NATIVE_DESKTOP_MACRO_RE.findall(text)) for block in NATIVE_DESKTOP_LIST_RE.findall(text): commands.update(QSTRING_RE.findall(block)) return commands def direct_desktop_worker(module: str) -> str | None: for worker in module_files(module): text = (SRC / worker).read_text(encoding="utf-8", errors="replace") if "DesktopRequest" in text and re.search(r"is[A-Za-z0-9]+DesktopRequest", text): return worker return None def module_commands(module: str) -> list[str]: stem = source_stem(module) commands: set[str] = set() for path in sorted(SRC.glob(f"{stem}*-facade.ts")): if not (path.name.startswith(f"{stem}-") or path.name == f"{stem}-facade.ts"): continue commands.update(COMMAND_RE.findall(path.read_text(encoding="utf-8"))) return sorted(commands) def ts_string(value: str) -> str: return json.dumps(value, ensure_ascii=False) rows = json.loads(MATRIX.read_text(encoding="utf-8"))["rows"] entries: list[str] = [] generated_command_workers = dict(COMMAND_WORKERS) for row in rows: module = row["module"] workers = module_files(module) commands = module_commands(module) desktop_worker = direct_desktop_worker(module) if desktop_worker: desktop_commands = native_desktop_commands(module) commands = sorted(set(commands) | desktop_commands) generated_command_workers.update({(module, command): desktop_worker for command in desktop_commands}) if module == "Web": workers = sorted(set(workers + ["stateful-family.worker.ts"])) commands = sorted(set(commands + ["web.statefulFamilyBatch"])) primary_worker = PRIMARY_WORKERS.get(module) or (workers[0] if workers else None) entries.append( " { " f"module: {ts_string(module)}, " f"workers: {json.dumps(workers, ensure_ascii=False)}, " f"primaryWorker: {ts_string(primary_worker) if primary_worker else 'null'}, " f"commands: {json.dumps(commands, ensure_ascii=False)}, " f"objectTypeCount: {row['object_type_count']}, " f"pythonApiCount: {row['python_api_count']}, " f"guiDependency: {str(bool(row['gui_dependency'])).lower()}, " f"formatExtensions: {json.dumps(row['format_extensions'], ensure_ascii=False)} " "}," ) worker_assets = sorted({worker for row in rows for worker in module_files(row["module"])}) worker_identifiers = {worker: f"workerEntry{index}" for index, worker in enumerate(worker_assets)} worker_imports = "\n".join(f'import {worker_identifiers[worker]} from "./{worker}?worker&url";' for worker in worker_assets) declarations = """export const WEB_MODULE_REGISTRY_SCHEMA = "freecad-web-module-registry/v1" as const; export interface WebModuleRoute { readonly module: string; readonly workers: readonly string[]; readonly primaryWorker: string | null; readonly commands: readonly string[]; readonly objectTypeCount: number; readonly pythonApiCount: number; readonly guiDependency: boolean; readonly formatExtensions: readonly string[]; } export const WEB_MODULE_REGISTRY = [ """ + "\n".join(entries) + """ ] as const satisfies readonly WebModuleRoute[]; const ROUTES: ReadonlyMap = new Map(WEB_MODULE_REGISTRY.map((route) => [route.module, route])); export function getWebModuleRoute(module: string): WebModuleRoute | undefined { return ROUTES.get(module); } export function listWebModuleCommands(module?: string): string[] { if (module) return [...(ROUTES.get(module)?.commands ?? [])]; return WEB_MODULE_REGISTRY.flatMap((route) => route.commands); } """ content = """/* Generated by tools/generate_web_module_registry.py. */ import { artifactForWorker, createDynamicModuleWorker, type WebModuleArtifact } from "./module-loader.js"; """ + worker_imports + "\n\n" + declarations + """ export function createWebModuleWorker(module: string, workerFile?: string, artifact?: WebModuleArtifact): Worker | undefined { const route = ROUTES.get(module); const selectedWorker = workerFile ?? route?.primaryWorker; if (!route || !selectedWorker) return undefined; const entryUrl = (() => { switch (`${module}:${selectedWorker}`) { """ + "\n".join( f' case {ts_string(row["module"] + ":" + worker)}: return new URL({worker_identifiers[worker]}, import.meta.url);' for row in rows for worker in module_files(row["module"]) ) + """ default: return undefined; } })(); return entryUrl ? createDynamicModuleWorker(entryUrl, artifactForWorker(artifact, selectedWorker)) : undefined; } export function getWebModuleWorkerForCommand(module: string, command: string): string | undefined { const route = ROUTES.get(module); if (!route) return undefined; switch (`${module}:${command}`) { """ + "\n".join( f' case {ts_string(module + ":" + command)}: return {ts_string(worker)};' for (module, command), worker in sorted(generated_command_workers.items()) ) + """ default: return route.primaryWorker ?? undefined; } } """ changed = not OUT.is_file() or OUT.read_text(encoding="utf-8") != content if changed: OUT.write_text(content, encoding="utf-8") metadata_path = SRC / "module-metadata.generated.ts" metadata_content = "/* Generated by tools/generate_web_module_registry.py; no Worker factories. */\n" + declarations if not metadata_path.is_file() or metadata_path.read_text(encoding="utf-8") != metadata_content: metadata_path.write_text(metadata_content, encoding="utf-8") print(json.dumps({"output": str(OUT.relative_to(ROOT)), "modules": len(entries), "bytes": OUT.stat().st_size, "changed": changed}, ensure_ascii=False))