From 05cce8f91f7330a5ba77f23c93f9b17cdf391425 Mon Sep 17 00:00:00 2001 From: codetypes Date: Mon, 29 Sep 2025 14:35:40 +0800 Subject: [PATCH 1/2] add nanoid for node --- package-lock.json | 46 ++++++++++++++++++++++++++------- package.json | 1 + src/behavior3 | 2 +- src/components/graph.ts | 37 ++++++++++++++++---------- src/components/inspector.tsx | 1 + src/components/register-node.ts | 6 ++--- src/misc/b3type.ts | 13 +++++++--- src/misc/b3util.ts | 42 ++++++++++++++++-------------- src/misc/util.ts | 20 +++++++++++--- 9 files changed, 114 insertions(+), 54 deletions(-) diff --git a/package-lock.json b/package-lock.json index 40471ff..fef96d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "i18next": "^23.12.2", "i18next-browser-languagedetector": "^7.2.1", "i18next-http-backend": "^2.5.2", + "nanoid": "^5.1.6", "react-i18next": "^14.1.3", "react-icons": "^5.2.1", "react-markdown": "^9.0.1", @@ -8315,10 +8316,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", - "dev": true, + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", "funding": [ { "type": "github", @@ -8327,10 +8327,10 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/natural-compare": { @@ -8833,6 +8833,25 @@ "dev": true, "license": "MIT" }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -17731,10 +17750,9 @@ } }, "nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", - "dev": true + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==" }, "natural-compare": { "version": "1.4.0", @@ -17990,6 +18008,14 @@ "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" + }, + "dependencies": { + "nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true + } } }, "postcss-import": { diff --git a/package.json b/package.json index 650ee7e..3b47d98 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "i18next": "^23.12.2", "i18next-browser-languagedetector": "^7.2.1", "i18next-http-backend": "^2.5.2", + "nanoid": "^5.1.6", "react-i18next": "^14.1.3", "react-icons": "^5.2.1", "react-markdown": "^9.0.1", diff --git a/src/behavior3 b/src/behavior3 index 9283002..16334ea 160000 --- a/src/behavior3 +++ b/src/behavior3 @@ -1 +1 @@ -Subproject commit 9283002a96ab5ff5e326919c26cb953c2285fa9c +Subproject commit 16334eab4c36a2a8b2445e9fa11e44673f0d7252 diff --git a/src/components/graph.ts b/src/components/graph.ts index 22cfa1b..fe3dd3e 100644 --- a/src/components/graph.ts +++ b/src/components/graph.ts @@ -19,7 +19,7 @@ import * as b3util from "../misc/b3util"; import { message } from "../misc/hooks"; import i18n from "../misc/i18n"; import Path from "../misc/path"; -import { readTree, writeTree } from "../misc/util"; +import { nanoid, readTree, writeTree } from "../misc/util"; import { TreeNodeState, TreeNodeStyle } from "./register-node"; type G6NodeState = Exclude["state"]; @@ -89,8 +89,8 @@ export class Graph { const dataB = nodeB.data as unknown as NodeData; return Number(dataA.id) - Number(dataB.id); }, - getHeight: ({ data }: { data: NodeData }) => data.size![1], - getWidth: ({ data }: { data: NodeData }) => data.size![0], + getHeight: ({ data }: { data: NodeData }) => data.$size![1], + getWidth: ({ data }: { data: NodeData }) => data.$size![0], getVGap: () => 10, getHGap: () => 30, }, @@ -147,7 +147,7 @@ export class Graph { private async _update(data: TreeData, refreshId: boolean = true, refreshVars: boolean = false) { this.editor.data = data; if (refreshId) { - b3util.refreshNodeData(this.data.root, 1); + b3util.refreshNodeData(this.data, this.data.root, 1); } if (refreshVars) { @@ -471,18 +471,24 @@ export class Graph { async updateNode(editNode: EditNode) { const node = this._graph.getNodeData(editNode.data.id); - const data = node.data as unknown as NodeData; + let data = node.data as unknown as NodeData; if (b3util.isNodeEqual(data, editNode.data)) { return; } + const subtree = data.path; + // update node - node.data = { ...editNode.data, size: b3util.calcSize(editNode.data) }; + data = { ...editNode.data, $size: b3util.calcSize(editNode.data) }; + if (editNode.data.name !== data.name) { + data.$id = nanoid(); + } + node.data = data as unknown as Record; this._graph.updateNodeData([node]); await this._graph.draw(); // update subtree - if (data.path !== editNode.data.path) { + if (subtree !== editNode.data.path) { this.editor.data.root = this._nodeToData("1"); await this.refresh(); } @@ -640,7 +646,7 @@ export class Graph { const id = this._dropId; const pos = this._graph.getElementPosition(id); const data = this._graph.getNodeData(id).data as unknown as NodeData; - const [w, h] = data.size!; + const [w, h] = data.$size!; const x = e.canvas.x - pos[0]; const y = e.canvas.y - pos[1]; const states = this._getState(id); @@ -803,17 +809,20 @@ export class Graph { } console.debug("parse node:", str); + const node = JSON.parse(str) as NodeData; + b3util.dfs(node, (v) => (v.$id = nanoid())); + const root = this._nodeToData("1"); let dstData: NodeData | undefined; - b3util.dfs(root, (node) => { - if (node.id === this._selectedId) { - dstData = node; + b3util.dfs(root, (v) => { + if (v.id === this._selectedId) { + dstData = v; } }); assert(dstData, this._selectedId); dstData.children ||= []; - dstData.children.push(JSON.parse(str) as NodeData); + dstData.children.push(node); this.selectNode(null); await this._update({ ...this.data, root }); this._storeHistory(); @@ -881,7 +890,7 @@ export class Graph { assert(dstData, this._selectedId); dstData.children ||= []; - dstData.children.push({ id: "", name: "unknow" }); + dstData.children.push({ id: "", name: "unknow", $id: nanoid() }); await this._update({ ...this.data, root }); this._storeHistory(); } @@ -920,7 +929,7 @@ export class Graph { hasSubtreeUpdated() { let updated = false; b3util.dfs(this.data.root, (node) => { - if (node.path && b3util.files[node.path] !== node.mtime) { + if (node.path && b3util.files[node.path] !== node.$mtime) { updated = true; } }); diff --git a/src/components/inspector.tsx b/src/components/inspector.tsx index 965b506..75ce55e 100644 --- a/src/components/inspector.tsx +++ b/src/components/inspector.tsx @@ -822,6 +822,7 @@ const NodeInspector: FC = () => { if (editingNode.data.name !== newname) { workspace.onEditingNode({ data: { + $id: editingNode.data.$id, id: editingNode.data.id, name: workspace.nodeDefs.get(newname)?.name ?? newname, desc: editingNode.data.desc, diff --git a/src/components/register-node.ts b/src/components/register-node.ts index 2a0dd0b..246cc7a 100644 --- a/src/components/register-node.ts +++ b/src/components/register-node.ts @@ -388,7 +388,7 @@ class TreeNode extends Rect { } private drawStatusIcon(attributes: Required, container: Group) { - const status = ((this._data.status ?? 0) & 0b111).toString(2).padStart(3, "0"); + const status = ((this._data.$status ?? 0) & 0b111).toString(2).padStart(3, "0"); this.upsert( "status", GImage, @@ -758,8 +758,8 @@ class TreeNode extends Rect { color = NODE_COLORS[classify]; } - assert(data.size); - const [width, height] = data.size; + assert(data.$size); + const [width, height] = data.$size; this._prefix = (node.prefix as string) ?? ""; this._width = width; diff --git a/src/misc/b3type.ts b/src/misc/b3type.ts index 4753392..d955289 100644 --- a/src/misc/b3type.ts +++ b/src/misc/b3type.ts @@ -27,10 +27,13 @@ export interface NodeData { disabled?: boolean; path?: string; + // nanoid, for override + $id: string; + // for runtime - mtime?: number; - size?: number[]; - status?: number; + $mtime?: number; + $size?: number[]; + $status?: number; } export type NodeLayout = "compact" | "normal"; @@ -71,6 +74,10 @@ export interface TreeData { import: string[]; vars: VarDecl[]; root: NodeData; + + $override: { + [key: string]: Pick; + }; } export const getNodeType = (def: NodeDef): NodeType => { diff --git a/src/misc/b3util.ts b/src/misc/b3util.ts index 4f2d545..df69105 100644 --- a/src/misc/b3util.ts +++ b/src/misc/b3util.ts @@ -20,7 +20,7 @@ import { VERSION, } from "./b3type"; import Path from "./path"; -import { readJson, readTree, readWorkspace } from "./util"; +import { nanoid, readJson, readTree, readWorkspace } from "./util"; export class NodeDefs extends Map { get(key: string): NodeDef { @@ -520,6 +520,7 @@ const parsingStack: string[] = []; export const createNode = (data: NodeData, includeChildren: boolean = true) => { const node: NodeData = { + $id: data.$id, id: data.id, name: data.name, desc: data.desc, @@ -598,7 +599,7 @@ const appendStatusFlag = (status: number, childStatus: number) => { }; const buildStatusFlag = (data: NodeData, childStatus: number) => { - let status = data.status!; + let status = data.$status!; const def = nodeDefs.get(data.name); if (def.status?.length) { const childSuccess = (childStatus >> StatusFlag.SUCCESS) & 1; @@ -639,9 +640,9 @@ const buildStatusFlag = (data: NodeData, childStatus: number) => { break; } }); - data.status = status; + data.$status = status; } else { - data.status = status | childStatus; + data.$status = status | childStatus; } }; @@ -694,9 +695,9 @@ export const checkTreeData = (data: NodeData) => { return true; }; -export const refreshNodeData = (node: NodeData, id: number) => { +export const refreshNodeData = (tree: TreeData, node: NodeData, id: number) => { node.id = (id++).toString(); - node.size = calcSize(node); + node.$size = calcSize(node); const def = nodeDefs.get(node.name); @@ -718,16 +719,16 @@ export const refreshNodeData = (node: NodeData, id: number) => { parsingStack.push(node.path); try { const subtreePath = workdir + "/" + node.path; - const subtree = readTree(subtreePath).root; - id = refreshNodeData(subtree, --id); + const subtree = readTree(subtreePath); + id = refreshNodeData(subtree, subtree.root, --id); node.name = subtree.name; node.desc = subtree.desc; - node.args = subtree.args; - node.input = subtree.input; - node.output = subtree.output; - node.children = subtree.children; - node.mtime = fs.statSync(subtreePath).mtimeMs; - node.size = calcSize(node); + node.args = subtree.root.args; + node.input = subtree.root.input; + node.output = subtree.root.output; + node.children = subtree.root.children; + node.$mtime = fs.statSync(subtreePath).mtimeMs; + node.$size = calcSize(node); } catch (e) { alertError(`解析子树失败:${node.path}`); console.log("parse subtree:", e); @@ -735,16 +736,16 @@ export const refreshNodeData = (node: NodeData, id: number) => { parsingStack.pop(); } else if (node.children?.length) { for (let i = 0; i < node.children.length; i++) { - id = refreshNodeData(node.children[i], id); + id = refreshNodeData(tree, node.children[i], id); } } - node.status = toStatusFlag(node); + node.$status = toStatusFlag(node); if (node.children) { let childStatus = 0; node.children.forEach((child) => { - if (child.status && !child.disabled) { - childStatus = appendStatusFlag(childStatus, child.status); + if (child.$status && !child.disabled) { + childStatus = appendStatusFlag(childStatus, child.$status); } }); buildStatusFlag(node, childStatus); @@ -756,7 +757,7 @@ export const refreshNodeData = (node: NodeData, id: number) => { export const createBuildData = (path: string) => { try { const treeModel: TreeData = readTree(path); - refreshNodeData(treeModel.root, 1); + refreshNodeData(treeModel, treeModel.root, 1); dfs(treeModel.root, (node) => (node.id = treeModel.prefix + node.id)); treeModel.name = Path.basenameWithoutExt(path); treeModel.root = createFileData(treeModel.root, true); @@ -885,6 +886,7 @@ export const loadModule = async (path: string) => { export const createFileData = (data: NodeData, includeSubtree?: boolean) => { const nodeData: NodeData = { + $id: data.$id, id: data.id, name: data.name, desc: data.desc || undefined, @@ -926,7 +928,9 @@ export const createNewTree = (path: string) => { root: { id: "1", name: "Sequence", + $id: nanoid(), }, + $override: {}, }; return tree; }; diff --git a/src/misc/util.ts b/src/misc/util.ts index ea49060..e6e1fec 100644 --- a/src/misc/util.ts +++ b/src/misc/util.ts @@ -1,9 +1,15 @@ import * as fs from "fs"; +import { customAlphabet } from "nanoid"; import { type WorkspaceModel } from "../contexts/workspace-context"; -import { VarDecl, VERSION, type TreeData } from "./b3type"; +import { VERSION, type TreeData } from "./b3type"; import { dfs } from "./b3util"; import Path from "./path"; +export const nanoid = customAlphabet( + "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", + 10 +); + export const readJson = (path: string): T => { const str = fs.readFileSync(path, "utf-8"); return JSON.parse(str); @@ -21,16 +27,21 @@ export const readWorkspace = (path: string) => { }; export const readTree = (path: string) => { - const data = readJson(path) as TreeData & { declvar?: VarDecl[] }; + const data = readJson(path) as TreeData; data.version = data.version ?? VERSION; data.prefix = data.prefix ?? ""; data.group = data.group || []; data.import = data.import || []; - data.vars = data.vars || data.declvar || []; + data.vars = data.vars || []; data.root = data.root || {}; // compatible with old version - dfs(data.root, (node) => (node.id = node.id.toString())); + dfs(data.root, (node) => { + node.id = node.id.toString(); + if (!node.$id) { + node.$id = nanoid(); + } + }); return data; }; @@ -46,6 +57,7 @@ export const writeTree = (path: string, data: TreeData) => { import: data.import, vars: data.vars, root: data.root, + $override: data.$override, }); }; From c137b880b37e5dad0de2dcfd06024efb1e4be8c3 Mon Sep 17 00:00:00 2001 From: codetypes Date: Fri, 17 Oct 2025 16:34:02 +0800 Subject: [PATCH 2/2] print all build errors at the end --- package-lock.json | 4 +- package.json | 2 +- src/components/inspector.tsx | 2 +- src/components/register-node.ts | 2 +- src/misc/b3type.ts | 3 +- src/misc/b3util.ts | 172 ++++++++--------- src/misc/stringify.ts | 332 ++++++++++++++++++++++++++++++++ src/misc/util.ts | 6 +- 8 files changed, 427 insertions(+), 96 deletions(-) create mode 100644 src/misc/stringify.ts diff --git a/package-lock.json b/package-lock.json index fef96d4..6aceac1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "behavior3editor", - "version": "1.8.5", + "version": "1.8.7", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "behavior3editor", - "version": "1.8.5", + "version": "1.8.7", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 3b47d98..20102b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "behavior3editor", - "version": "1.8.5", + "version": "1.8.7", "main": "dist-electron/main/index.js", "description": "行为树编辑器", "author": "zhandouxiaojiji & codetypes", diff --git a/src/components/inspector.tsx b/src/components/inspector.tsx index 75ce55e..2c719fe 100644 --- a/src/components/inspector.tsx +++ b/src/components/inspector.tsx @@ -549,7 +549,7 @@ const validateArg = ( } else if (value === null && !required) { value = undefined; } - if (!checkNodeArgValue(node, arg, value, true)) { + if (!checkNodeArgValue(node, arg, value, console.error)) { return Promise.reject(new Error(i18n.t("node.invalidValue"))); } return Promise.resolve(value); diff --git a/src/components/register-node.ts b/src/components/register-node.ts index 246cc7a..fff80f8 100644 --- a/src/components/register-node.ts +++ b/src/components/register-node.ts @@ -748,7 +748,7 @@ class TreeNode extends Rect { !b3util.nodeDefs.has(data.name) || (data.path && !node.children?.length) || (nodeDef.group && !nodeDef.group.some((g) => b3util.usingGroups?.[g])) || - !b3util.checkTreeData(data) || + !b3util.isValidNodeData(data) || foundUndefined(data.input) || foundUndefined(data.output) || foundUndefinedInArgs(nodeDef, data) || diff --git a/src/misc/b3type.ts b/src/misc/b3type.ts index d955289..3edbc13 100644 --- a/src/misc/b3type.ts +++ b/src/misc/b3type.ts @@ -1,6 +1,6 @@ import { NodeDef } from "../behavior3/src/behavior3"; -export const VERSION = "1.8.5"; +export const VERSION = "1.8.7"; export const keyWords = ["true", "false", "null", "undefined", "NaN", "Infinity"]; @@ -73,6 +73,7 @@ export interface TreeData { group: string[]; import: string[]; vars: VarDecl[]; + custom: Record; root: NodeData; $override: { diff --git a/src/misc/b3util.ts b/src/misc/b3util.ts index df69105..bd73059 100644 --- a/src/misc/b3util.ts +++ b/src/misc/b3util.ts @@ -20,10 +20,11 @@ import { VERSION, } from "./b3type"; import Path from "./path"; +import { stringifyJson } from "./stringify"; import { nanoid, readJson, readTree, readWorkspace } from "./util"; export class NodeDefs extends Map { - get(key: string): NodeDef { + override get(key: string): NodeDef { return super.get(key) ?? unknownNodeDef; } } @@ -188,8 +189,10 @@ export const isNodeEqual = (node1: NodeData, node2: NodeData) => { return false; }; -const error = (data: NodeData, msg: string) => { - console.error(`check ${data.id}|${data.name}: ${msg}`); +type ErrorPrinter = (msg: string) => void; + +const formatError = (data: NodeData, msg: string) => { + return `check ${data.id}|${data.name}: ${msg}`; }; export const getNodeArgRawType = (arg: NodeArg) => { @@ -208,75 +211,62 @@ export const checkNodeArgValue = ( data: NodeData, arg: NodeArg, value: unknown, - verbose?: boolean + printer?: ErrorPrinter ) => { let hasError = false; const type = getNodeArgRawType(arg); + const error = !printer ? () => {} : (msg: string) => printer(formatError(data, msg)); if (isFloatType(type)) { const isNumber = typeof value === "number"; const isOptional = value === undefined && isNodeArgOptional(arg); if (!(isNumber || isOptional)) { - if (verbose) { - error(data, `'${arg.name}=${JSON.stringify(value)}' is not a number`); - } + error(`'${arg.name}=${JSON.stringify(value)}' is not a number`); hasError = true; } } else if (isIntType(type)) { const isInt = typeof value === "number" && value === Math.floor(value); const isOptional = value === undefined && isNodeArgOptional(arg); if (!(isInt || isOptional)) { - if (verbose) { - error(data, `'${arg.name}=${JSON.stringify(value)}' is not a int`); - } + error(`'${arg.name}=${JSON.stringify(value)}' is not a int`); hasError = true; } } else if (isStringType(type)) { const isString = typeof value === "string" && value; const isOptional = (value === undefined || value === "") && isNodeArgOptional(arg); if (!(isString || isOptional)) { - if (verbose) { - error(data, `'${arg.name}=${JSON.stringify(value)}' is not a string`); - } + error(`'${arg.name}=${JSON.stringify(value)}' is not a string`); hasError = true; } } else if (isExprType(type)) { const isExpr = typeof value === "string" && value; const isOptional = (value === undefined || value === "") && isNodeArgOptional(arg); if (!(isExpr || isOptional)) { - if (verbose) { - error(data, `'${arg.name}=${JSON.stringify(value)}' is not an expr string`); - } + error(`'${arg.name}=${JSON.stringify(value)}' is not an expr string`); hasError = true; } } else if (isJsonType(type)) { const isJson = value !== undefined && value !== ""; const isOptional = isNodeArgOptional(arg); if (!(isJson || isOptional)) { - if (verbose) { - error(data, `'${arg.name}=${value}' is not an invalid object`); - } + error(`'${arg.name}=${value}' is not an invalid object`); hasError = true; } } else if (isBoolType(type)) { const isBool = typeof value === "boolean" || value === undefined; if (!isBool) { - if (verbose) { - error(data, `'${arg.name}=${JSON.stringify(value)}' is not a boolean`); - } + error(`'${arg.name}=${JSON.stringify(value)}' is not a boolean`); hasError = true; } } else { hasError = true; - error(data, `unknown arg type '${arg.type}'`); + error(`unknown arg type '${arg.type}'`); } if (hasArgOptions(arg)) { const found = !!arg.options?.find((option) => option.value === value); const isOptional = value === undefined && isNodeArgOptional(arg); if (!(found || isOptional)) { - if (verbose) { - error(data, `'${arg.name}=${JSON.stringify(value)}' is not a one of the option values`); - } + error(`'${arg.name}=${JSON.stringify(value)}' is not a one of the option values`); hasError = true; } } @@ -284,37 +274,34 @@ export const checkNodeArgValue = ( return !hasError; }; -export const checkNodeArg = (data: NodeData, conf: NodeDef, i: number, verbose?: boolean) => { +export const checkNodeArg = (data: NodeData, conf: NodeDef, i: number, printer?: ErrorPrinter) => { let hasError = false; const arg = conf.args![i] as NodeArg; const value = data.args?.[arg.name]; + const error = !printer ? () => {} : (msg: string) => printer(formatError(data, msg)); if (isNodeArgArray(arg)) { if (!Array.isArray(value) || value.length === 0) { if (!isNodeArgOptional(arg)) { - if (verbose) { - error(data, `'${arg.name}=${JSON.stringify(value)}' is not an array or empty array`); - } + error(`'${arg.name}=${JSON.stringify(value)}' is not an array or empty array`); hasError = true; } } else { for (let j = 0; j < value.length; j++) { - if (!checkNodeArgValue(data, arg, value[j], verbose)) { + if (!checkNodeArgValue(data, arg, value[j], printer)) { hasError = true; } } } - } else if (!checkNodeArgValue(data, arg, value, verbose)) { + } else if (!checkNodeArgValue(data, arg, value, printer)) { hasError = true; } if (arg.oneof !== undefined) { const idx = conf.input?.findIndex((v) => v.startsWith(arg.oneof!)) ?? -1; if (!checkOneof(arg, data.args?.[arg.name], data.input?.[idx])) { - if (verbose) { - error( - data, - `only one is allowed for between argument '${arg.name}' and input '${data.input?.[idx]}'` - ); - } + error( + `only one is allowed for between argument '${arg.name}' and input '${data.input?.[idx]}'` + ); + hasError = true; } } @@ -333,13 +320,44 @@ export const checkOneof = (arg: NodeArg, argValue: unknown, inputValue: unknown) return (argValue !== "" && inputValue === "") || (argValue === "" && inputValue !== ""); }; -export const checkNodeData = (data: NodeData | null | undefined) => { +export const isValidNodeData = (data: NodeData) => { + const def = nodeDefs.get(data.name); + if (def.input) { + for (let i = 0; i < def.input.length; i++) { + if (!isValidInputOrOutput(def.input, data.input, i)) { + return false; + } + } + } + if (def.output) { + for (let i = 0; i < def.output.length; i++) { + if (!isValidInputOrOutput(def.output, data.output, i)) { + return false; + } + } + } + if (!isValidChildren(data)) { + return false; + } + if (def.args) { + for (let i = 0; i < def.args.length; i++) { + if (!checkNodeArg(data, def, i)) { + return false; + } + } + } + + return true; +}; + +const checkNodeData = (data: NodeData | null | undefined, printer: ErrorPrinter) => { if (!data) { return false; } + const error = !printer ? () => {} : (msg: string) => printer(formatError(data, msg)); const conf = nodeDefs.get(data.name); if (conf.name === unknownNodeDef.name) { - error(data, `undefined node: ${data.name}`); + error(`undefined node: ${data.name}`); return false; } @@ -347,7 +365,7 @@ export const checkNodeData = (data: NodeData | null | undefined) => { if (conf.group) { if (!conf.group.some((g) => usingGroups?.[g])) { - error(data, `node group '${conf.group}' is not enabled`); + error(`node group '${conf.group}' is not enabled`); hasError = true; } } @@ -356,7 +374,7 @@ export const checkNodeData = (data: NodeData | null | undefined) => { if (data.input) { for (const v of data.input) { if (v && !usingVars[v]) { - error(data, `input variable '${v}' is not defined`); + error(`input variable '${v}' is not defined`); hasError = true; } } @@ -364,7 +382,7 @@ export const checkNodeData = (data: NodeData | null | undefined) => { if (data.output) { for (const v of data.output) { if (v && !usingVars[v]) { - error(data, `output variable '${v}' is not defined`); + error(`output variable '${v}' is not defined`); hasError = true; } } @@ -386,7 +404,7 @@ export const checkNodeData = (data: NodeData | null | undefined) => { } for (const v of vars) { if (v && !usingVars[v]) { - error(data, `expr variable '${arg.name}' is not defined`); + error(`expr variable '${arg.name}' is not defined`); hasError = true; } } @@ -403,11 +421,11 @@ export const checkNodeData = (data: NodeData | null | undefined) => { for (const expr of exprs) { try { if (!new ExpressionEvaluator(expr).dryRun()) { - error(data, `expr '${expr}' is not valid`); + error(`expr '${expr}' is not valid`); hasError = true; } } catch (e) { - error(data, `expr '${expr}' is not valid`); + error(`expr '${expr}' is not valid`); hasError = true; } } @@ -420,7 +438,7 @@ export const checkNodeData = (data: NodeData | null | undefined) => { const count = data.children?.length || 0; if (conf.children !== count) { hasError = true; - error(data, `expect ${conf.children} children, but got ${count}`); + error(`expect ${conf.children} children, but got ${count}`); } } @@ -435,14 +453,13 @@ export const checkNodeData = (data: NodeData | null | undefined) => { } if (data.input[i] && !isValidVariableName(data.input[i])) { error( - data, `input field '${data.input[i]}' is not a valid variable name,` + `should start with a letter or underscore` ); hasError = true; } if (!isValidInputOrOutput(conf.input, data.input, i)) { - error(data, `intput field '${conf.input[i]}' is required`); + error(`intput field '${conf.input[i]}' is required`); hasError = true; } if (i === conf.input.length - 1 && conf.input.at(-1)?.endsWith("...")) { @@ -465,14 +482,13 @@ export const checkNodeData = (data: NodeData | null | undefined) => { } if (data.output[i] && !isValidVariableName(data.output[i])) { error( - data, `output field '${data.output[i]}' is not a valid variable name,` + `should start with a letter or underscore` ); hasError = true; } if (!isValidInputOrOutput(conf.output, data.output, i)) { - error(data, `output field '${conf.output[i]}' is required`); + error(`output field '${conf.output[i]}' is required`); hasError = true; } if (i === conf.output.length - 1 && conf.output.at(-1)?.endsWith("...")) { @@ -496,7 +512,7 @@ export const checkNodeData = (data: NodeData | null | undefined) => { args[key] = value; } - if (!checkNodeArg(data, conf, i, true)) { + if (!checkNodeArg(data, conf, i, printer)) { hasError = true; } } @@ -505,7 +521,7 @@ export const checkNodeData = (data: NodeData | null | undefined) => { if (data.children) { for (const child of data.children) { - if (!checkNodeData(child)) { + if (!checkNodeData(child, printer)) { hasError = true; } } @@ -665,36 +681,6 @@ const isValidInputOrOutput = (def: string[], data: string[] | undefined, index: return def[index].includes("?") || data?.[index] || isVariadic(def, index); }; -export const checkTreeData = (data: NodeData) => { - const def = nodeDefs.get(data.name); - if (def.input) { - for (let i = 0; i < def.input.length; i++) { - if (!isValidInputOrOutput(def.input, data.input, i)) { - return false; - } - } - } - if (def.output) { - for (let i = 0; i < def.output.length; i++) { - if (!isValidInputOrOutput(def.output, data.output, i)) { - return false; - } - } - } - if (!isValidChildren(data)) { - return false; - } - if (def.args) { - for (let i = 0; i < def.args.length; i++) { - if (!checkNodeArg(data, def, i, false)) { - return false; - } - } - } - - return true; -}; - export const refreshNodeData = (tree: TreeData, node: NodeData, id: number) => { node.id = (id++).toString(); node.$size = calcSize(node); @@ -721,8 +707,8 @@ export const refreshNodeData = (tree: TreeData, node: NodeData, id: number) => { const subtreePath = workdir + "/" + node.path; const subtree = readTree(subtreePath); id = refreshNodeData(subtree, subtree.root, --id); - node.name = subtree.name; - node.desc = subtree.desc; + node.name = subtree.root.name; + node.desc = subtree.root.desc; node.args = subtree.root.args; node.input = subtree.root.input; node.output = subtree.root.output; @@ -761,6 +747,8 @@ export const createBuildData = (path: string) => { dfs(treeModel.root, (node) => (node.id = treeModel.prefix + node.id)); treeModel.name = Path.basenameWithoutExt(path); treeModel.root = createFileData(treeModel.root, true); + dfs(treeModel.root, (node) => delete (node as Partial).$id); + delete (treeModel as Partial).$override; return treeModel as TreeData; } catch (e) { console.log("build error:", path, e); @@ -826,6 +814,7 @@ export const buildProject = async (project: string, buildDir: string) => { }); } + const allErrors: string[] = []; for (const path of Path.ls(Path.dirname(project), true)) { if (path.endsWith(".json")) { const buildpath = buildDir + "/" + path.substring(workdir.length + 1); @@ -843,7 +832,6 @@ export const buildProject = async (project: string, buildDir: string) => { } console.log("build:", buildpath); if (errors.length) { - errors.forEach((msg) => console.error(msg)); hasError = true; } const declare: FileVarDecl = { @@ -852,14 +840,19 @@ export const buildProject = async (project: string, buildDir: string) => { subtree: [], }; refreshVarDecl(tree.root, tree.group, declare); - if (!checkNodeData(tree?.root)) { + if (!checkNodeData(tree?.root, (msg) => errors.push(msg))) { hasError = true; } + if (errors.length) { + allErrors.push(`${path}:`); + errors.forEach((v) => allErrors.push(` ${v}`)); + } buildScript?.onWriteFile?.(buildpath, tree); fs.mkdirSync(Path.dirname(buildpath), { recursive: true }); - fs.writeFileSync(buildpath, JSON.stringify(tree, null, 2)); + fs.writeFileSync(buildpath, stringifyJson(tree, { indent: 2 })); } } + allErrors.forEach((v) => console.error(v)); buildScript?.onComplete?.(hasError ? "failure" : "success"); return hasError; }; @@ -930,6 +923,7 @@ export const createNewTree = (path: string) => { name: "Sequence", $id: nanoid(), }, + custom: {}, $override: {}, }; return tree; @@ -1017,7 +1011,7 @@ const collectSubtree = (data: NodeData) => { export const refreshVarDecl = (root: NodeData, group: string[], declare: FileVarDecl) => { const filter: Record = {}; const vars: Array = new (class extends Array { - push(...items: VarDecl[]): number { + override push(...items: VarDecl[]): number { for (const v of items) { if (filter[v.name]) { continue; diff --git a/src/misc/stringify.ts b/src/misc/stringify.ts new file mode 100644 index 0000000..c17a3de --- /dev/null +++ b/src/misc/stringify.ts @@ -0,0 +1,332 @@ +import * as fs from "node:fs"; +import { basename, dirname, extname } from "node:path"; + +export type Tag = { + /** data name */ + ["!name"]?: string; + /** type tag */ + ["!type"]?: string; + /** special stringify function */ + ["!stringify"]?: (v: TValue, ctx: StringifyContext) => void; + /** ignore fields when stringify */ + ["!ignore"]?: { [k: string]: boolean }; +}; + +export type TValue = boolean | number | string | null | undefined | TObject | TArray; +export type TObject = { [k: string | number]: TValue } & Tag; +export type TArray = TValue[] & Tag; + +export class StringBuffer { + readonly data: string[] = []; + + private _indent: number; + private _indentCount: number = 0; + + constructor(indent: number) { + this._indent = indent; + } + + get indentCount() { + return this._indentCount; + } + + indent() { + this._indentCount += this._indent; + } + + unindent() { + this._indentCount -= this._indent; + } + + padding() { + if (this._indent > 0) { + this.data.push(" ".repeat(this._indentCount)); + } + } + + linefeed() { + if (this._indent > 0) { + this.data.push("\n"); + } + } + + writeLine(value: string) { + this.padding(); + this.data.push(value); + this.linefeed(); + } + + writeLines(value: string) { + for (const line of value.split("\n")) { + this.writeLine(line); + } + } + + writeString(value: string) { + this.data.push(value); + } + + toString() { + return this.data.join(""); + } +} + +export const isNumericKey = (key: string) => { + if (typeof key !== "string") return false; + + // integer or bigint + if (/^-?\d+$/.test(key)) { + try { + return String(BigInt(key)) === key; + } catch { + return false; + } + } + + // float + const num = Number(key); + return !isNaN(num) && String(num) === key; +}; + +export const escape = (value: string) => { + return value + .replaceAll("\r\n", "\n") + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("\n", "\\n") + .replaceAll("\t", "\\t") + .replaceAll("\r", "\\r") + .replaceAll("\b", "\\b") + .replaceAll("\f", "\\f"); +}; + +export const outdent = (value: string) => { + value = value.replace(/^\n/, ""); + value = value.replace(/\n *$/, ""); + const space = value.match(/^ +/gm)?.[0]; + return space ? value.replace(new RegExp(`^${space}`, "gm"), "") : value; +}; + +export const format = (str: string, vars: Record) => { + const lines: string[] = []; + for (const line of str.split(/\n|\r\n/)) { + if (line.match(/^\s*%{\w+}\s*$/)) { + const [_, space, key] = line.match(/^(\s*)%{(\w+)}$/)!; + if (vars[key] !== undefined && vars[key] !== null) { + for (const l of vars[key].split(/\n|\r\n/)) { + lines.push(space + l); + } + } else { + throw new Error(`variable '${key}' not found`); + } + } else { + lines.push( + line.replaceAll(/%{(\w+)}/g, (_, key) => { + if (vars[key] !== undefined && vars[key] !== null) { + return vars[key]; + } + throw new Error(`variable '${key}' not found`); + }) + ); + } + } + return lines.join("\n"); +}; + +export const keys = ( + o: object, + filter?: (v: TValue) => boolean, + ignore?: { [k: string]: boolean } +) => { + const value = o as TObject; + const ks = Object.keys(value).filter( + (k) => !k.startsWith("!") && (!ignore || !ignore[k]) && (!filter || filter(value[k])) + ); + + const numKeys: string[] = []; + const strKeys: string[] = []; + for (const k of ks) { + const num = Number(k); + if (!isNaN(num) && isFinite(num)) { + numKeys.push(k); + } else { + strKeys.push(k); + } + } + numKeys.sort((a, b) => Number(a) - Number(b) || a.localeCompare(b)); + strKeys.sort((a, b) => a.localeCompare(b)); + return [...numKeys, ...strKeys]; +}; + +export const values = ( + o: TObject, + filter?: (v: TValue) => boolean, + ignore?: { [k: string]: boolean } +): T[] => { + return keys(o, filter, ignore).map((k) => o[k] as T); +}; + +export const toPascalCase = (str: string): string => { + return str + .replace(/^_+/, "") + .replace(/_([a-zA-Z])/g, (_, letter) => letter.toUpperCase()) + .replace(/^[a-zA-Z]/, (match) => match.toUpperCase()); +}; + +export const readFile = (path: string) => { + if (fs.existsSync(path)) { + return fs.readFileSync(path, "utf-8"); + } + return null; +}; + +export const writeFile = (path: string, data: string) => { + const dir = dirname(path); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + if (fs.existsSync(path) && readFile(path) === data) { + console.log(`up-to-date: ${path}`); + } else { + console.log(`write: ${path}`); + fs.writeFileSync(path, data, { encoding: "utf-8" }); + } +}; + +export const writeJson = (path: string, data: TValue, options?: JsonStringifyOption) => { + options = options ?? {}; + options.indent = options.indent ?? 2; + options.precision = options.precision ?? 10; + writeFile(path, stringifyJson(data, options)); +}; + +export const filename = (path: string, suffix: boolean = false) => { + return basename(path, !suffix ? extname(path) : undefined); +}; + +export type StringifyContext = { + readonly format: "json" | "lua" | "ts" | string; + readonly indent: number; + readonly precision?: number; + readonly buffer: StringBuffer; + readonly writeValue: (value: TValue) => void; + readonly writeArray: (value: TArray) => void; + readonly writeObject: (value: TObject) => void; +}; + +const numberToString = (value: number, precision?: number) => { + if (value === (value | 0)) { + return value.toFixed(0); + } else { + return value.toFixed(precision).replace(/\.?0+$/, ""); + } +}; + +//----------------------------------------------------------------------------- +// Json +//----------------------------------------------------------------------------- +export type JsonStringifyOption = { + indent?: number; + precision?: number; +}; + +export const stringifyJson = (data: unknown, option?: JsonStringifyOption) => { + const stacks: string[] = []; + option = option ?? {}; + option.indent = Math.max(option.indent ?? 4, 0); + option.precision = option.precision ?? 10; + const buffer = new StringBuffer(option.indent); + const ctx: StringifyContext = { + format: "json", + indent: option.indent, + precision: option.precision, + buffer, + writeValue: writeJsonValue, + writeArray: writeJsonArray, + writeObject: writeJsonObject, + }; + + function writeJsonValue(value: TValue) { + if (typeof value === "number") { + buffer.writeString(numberToString(value, ctx.precision)); + } else if (typeof value === "boolean") { + buffer.writeString(value.toString()); + } else if (value === null || value === undefined) { + buffer.writeString("null"); + } else if (typeof value === "string") { + buffer.writeString('"'); + buffer.writeString(escape(value)); + buffer.writeString('"'); + } else if (Array.isArray(value)) { + writeJsonArray(value); + } else { + if (typeof value !== "object" || value === null || value === undefined) { + writeJsonValue(value); + } else if (Array.isArray(value)) { + writeJsonArray(value); + } else { + writeJsonObject(value as TObject); + } + } + } + + function writeJsonObject(value: TObject) { + if (stacks.length > 256) { + throw new Error(`json stringify stack overflow: ${stacks.join("->")}`); + } + + if (value["!stringify"]) { + value["!stringify"](value, ctx); + return; + } + + const ks = keys(value, undefined, value["!ignore"]); + const space = ctx.indent > 0 ? " " : ""; + buffer.writeString("{"); + buffer.linefeed(); + buffer.indent(); + for (let i = 0; i < ks.length; i++) { + const k = ks[i]; + const v = value[k]; + stacks.push(k); + buffer.padding(); + buffer.writeString(`"${k}":${space}`); + writeJsonValue(v); + if (i < ks.length - 1) { + buffer.writeString(","); + } + buffer.linefeed(); + stacks.pop(); + } + buffer.unindent(); + buffer.padding(); + buffer.writeString("}"); + } + + function writeJsonArray(value: TArray) { + if (value["!stringify"]) { + value["!stringify"](value, ctx); + return; + } + + buffer.writeString("["); + buffer.linefeed(); + buffer.indent(); + for (let i = 0; i < value.length; i++) { + const v = value[i]; + buffer.padding(); + writeJsonValue(v); + if (i < value.length - 1) { + buffer.writeString(","); + } + buffer.linefeed(); + } + buffer.unindent(); + buffer.padding(); + buffer.writeString("]"); + } + + writeJsonValue(data as TValue); + + return buffer.toString(); +}; diff --git a/src/misc/util.ts b/src/misc/util.ts index e6e1fec..f167e4a 100644 --- a/src/misc/util.ts +++ b/src/misc/util.ts @@ -4,6 +4,7 @@ import { type WorkspaceModel } from "../contexts/workspace-context"; import { VERSION, type TreeData } from "./b3type"; import { dfs } from "./b3util"; import Path from "./path"; +import { stringifyJson } from "./stringify"; export const nanoid = customAlphabet( "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", @@ -16,7 +17,7 @@ export const readJson = (path: string): T => { }; export const writeJson = (path: string, data: T) => { - const str = JSON.stringify(data, undefined, 2); + const str = stringifyJson(data, { indent: 2 }); fs.writeFileSync(path, str, "utf-8"); }; @@ -34,6 +35,8 @@ export const readTree = (path: string) => { data.import = data.import || []; data.vars = data.vars || []; data.root = data.root || {}; + data.$override = data.$override || {}; + data.custom = data.custom || {}; // compatible with old version dfs(data.root, (node) => { @@ -57,6 +60,7 @@ export const writeTree = (path: string, data: TreeData) => { import: data.import, vars: data.vars, root: data.root, + custom: data.custom, $override: data.$override, }); };