diff --git a/tools/gframework-config-tool/src/configPath.js b/tools/gframework-config-tool/src/configPath.js new file mode 100644 index 00000000..b85651e2 --- /dev/null +++ b/tools/gframework-config-tool/src/configPath.js @@ -0,0 +1,64 @@ +/** + * Join one object property onto a logical config path. + * + * @param {string} parentPath Parent logical path. + * @param {string} propertyName Property name. + * @returns {string} Combined logical path. + */ +function joinPropertyPath(parentPath, propertyName) { + return parentPath ? `${parentPath}.${propertyName}` : propertyName; +} + +/** + * Join one indexed array item onto a logical config path. + * + * @param {string} arrayPath Array logical path. + * @param {number} itemIndex Zero-based item index. + * @returns {string} Indexed logical path. + */ +function joinArrayIndexPath(arrayPath, itemIndex) { + return `${arrayPath}[${itemIndex}]`; +} + +/** + * Join one array-item template marker onto a logical config path. + * + * @param {string} arrayPath Array logical path. + * @returns {string} Template logical path. + */ +function joinArrayTemplatePath(arrayPath) { + return `${arrayPath}[]`; +} + +/** + * Check whether a logical path still contains one template array marker. + * + * @param {string} path Logical path. + * @returns {boolean} True when the path contains a template array segment. + */ +function isTemplatePath(path) { + return String(path).includes("[]"); +} + +/** + * Split one logical object path into individual property segments. + * The current form model only supports dotted object paths here and keeps + * array indexing as part of other dedicated helpers. + * + * @param {string} path Logical path. + * @returns {string[]} Property segments. + */ +function splitObjectPath(path) { + return String(path) + .split(".") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); +} + +module.exports = { + isTemplatePath, + joinArrayIndexPath, + joinArrayTemplatePath, + joinPropertyPath, + splitObjectPath +}; diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index c233eabb..832cc909 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -1,3 +1,11 @@ +const { + joinArrayIndexPath, + joinArrayTemplatePath, + joinPropertyPath, + splitObjectPath +} = require("./configPath"); +const {ValidationMessageKeys} = require("./localizationKeys"); + /** * Parse the repository's minimal config-schema subset into a recursive tree. * The parser intentionally mirrors the same high-level contract used by the @@ -137,7 +145,7 @@ function extractYamlComments(text) { const itemIndex = currentContext.nextIndex || 0; currentContext.nextIndex = itemIndex + 1; - const itemPath = `${currentContext.path}[${itemIndex}]`; + const itemPath = joinArrayIndexPath(currentContext.path, itemIndex); assignPendingComments(comments, itemPath, pendingComments); pendingComments = []; @@ -150,37 +158,37 @@ function extractYamlComments(text) { continue; } - const inlineObjectMatch = /^([A-Za-z0-9_]+):(.*)$/u.exec(rest); - if (!inlineObjectMatch) { + const inlineObjectMapping = parseYamlMappingText(rest); + if (!inlineObjectMapping) { continue; } const itemObjectContext = {indent: indent + 2, type: "object", path: itemPath, nextIndex: 0}; stack.push(itemObjectContext); - const key = inlineObjectMatch[1]; - const parsedValue = splitYamlValueAndInlineComment(inlineObjectMatch[2].trim()); + const key = inlineObjectMapping.key; + const parsedValue = splitYamlValueAndInlineComment(inlineObjectMapping.rawValue.trim()); if (parsedValue.comment) { - comments[`${itemPath}.${key}`] = parsedValue.comment; + comments[joinPropertyPath(itemPath, key)] = parsedValue.comment; } const nextLine = findNextMeaningfulLine(lines, index + 1); if (parsedValue.value.length === 0 && nextLine && nextLine.indent > indent) { - stack.push(createContextForChild(`${itemPath}.${key}`, nextLine)); + stack.push(createContextForChild(joinPropertyPath(itemPath, key), nextLine)); } continue; } - const match = /^([A-Za-z0-9_]+):(.*)$/u.exec(trimmed); - if (!match) { + const mapping = parseYamlMappingText(trimmed); + if (!mapping) { pendingComments = []; continue; } - const key = match[1]; - const valueInfo = splitYamlValueAndInlineComment(match[2].trim()); - const currentPath = currentContext.path ? `${currentContext.path}.${key}` : key; + const key = mapping.key; + const valueInfo = splitYamlValueAndInlineComment(mapping.rawValue.trim()); + const currentPath = joinPropertyPath(currentContext.path, key); assignPendingComments(comments, currentPath, pendingComments); pendingComments = []; @@ -282,16 +290,16 @@ function applyFormUpdates(originalYaml, updates) { const commentUpdates = updates.comments || {}; for (const [path, value] of Object.entries(scalarUpdates)) { - setNodeAtPath(root, path.split("."), createScalarNode(String(value))); + setNodeAtPath(root, splitObjectPath(path), createScalarNode(String(value))); } for (const [path, values] of Object.entries(arrayUpdates)) { - setNodeAtPath(root, path.split("."), createArrayNode( + setNodeAtPath(root, splitObjectPath(path), createArrayNode( (values || []).map((item) => createScalarNode(String(item))))); } for (const [path, items] of Object.entries(objectArrayUpdates)) { - setNodeAtPath(root, path.split("."), createArrayNode( + setNodeAtPath(root, splitObjectPath(path), createArrayNode( (items || []).map((item) => createNodeFromFormValue(item)))); } @@ -440,7 +448,7 @@ function parseSchemaNode(rawNode, displayPath) { : []; const properties = {}; for (const [key, propertyNode] of Object.entries(value.properties || {})) { - properties[key] = parseSchemaNode(propertyNode, combinePath(displayPath, key)); + properties[key] = parseSchemaNode(propertyNode, joinPropertyPath(displayPath, key)); } return { @@ -455,7 +463,7 @@ function parseSchemaNode(rawNode, displayPath) { } if (type === "array") { - const itemNode = parseSchemaNode(value.items || {}, `${displayPath}[]`); + const itemNode = parseSchemaNode(value.items || {}, joinArrayTemplatePath(displayPath)); return { type: "array", displayPath, @@ -497,7 +505,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) if (!yamlNode || yamlNode.kind !== "array") { diagnostics.push({ severity: "error", - message: localizeValidationMessage("expectedArray", localizer, { + message: localizeValidationMessage(ValidationMessageKeys.expectedArray, localizer, { displayPath }) }); @@ -505,7 +513,12 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) } for (let index = 0; index < yamlNode.items.length; index += 1) { - validateNode(schemaNode.items, yamlNode.items[index], `${displayPath}[${index}]`, diagnostics, localizer); + validateNode( + schemaNode.items, + yamlNode.items[index], + joinArrayIndexPath(displayPath, index), + diagnostics, + localizer); } return; } @@ -513,7 +526,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) if (!yamlNode || yamlNode.kind !== "scalar") { diagnostics.push({ severity: "error", - message: localizeValidationMessage("expectedScalarShape", localizer, { + message: localizeValidationMessage(ValidationMessageKeys.expectedScalarShape, localizer, { displayPath, schemaType: schemaNode.type, yamlKind: yamlNode ? yamlNode.kind : "missing" @@ -525,7 +538,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) if (!isScalarCompatible(schemaNode.type, yamlNode.value)) { diagnostics.push({ severity: "error", - message: localizeValidationMessage("expectedScalarValue", localizer, { + message: localizeValidationMessage(ValidationMessageKeys.expectedScalarValue, localizer, { displayPath, schemaType: schemaNode.type }) @@ -538,7 +551,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) !schemaNode.enumValues.includes(unquoteScalar(yamlNode.value))) { diagnostics.push({ severity: "error", - message: localizeValidationMessage("enumMismatch", localizer, { + message: localizeValidationMessage(ValidationMessageKeys.enumMismatch, localizer, { displayPath, values: schemaNode.enumValues.join(", ") }) @@ -557,10 +570,16 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) */ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) { if (!yamlNode || yamlNode.kind !== "object") { - const subject = displayPath.length === 0 ? "Root object" : `Property '${displayPath}'`; + const subject = displayPath.length === 0 + ? localizer && localizer.isChinese + ? "根对象应为对象。" + : "Root object is expected to be an object." + : localizer && localizer.isChinese + ? `属性“${displayPath}”应为对象。` + : `Property '${displayPath}' is expected to be an object.`; diagnostics.push({ severity: "error", - message: localizeValidationMessage("expectedObject", localizer, { + message: localizeValidationMessage(ValidationMessageKeys.expectedObject, localizer, { subject, displayPath }) @@ -572,8 +591,8 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca if (!yamlNode.map.has(requiredProperty)) { diagnostics.push({ severity: "error", - message: localizeValidationMessage("missingRequired", localizer, { - displayPath: combinePath(displayPath, requiredProperty) + message: localizeValidationMessage(ValidationMessageKeys.missingRequired, localizer, { + displayPath: joinPropertyPath(displayPath, requiredProperty) }) }); } @@ -583,8 +602,8 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca if (!Object.prototype.hasOwnProperty.call(schemaNode.properties, entry.key)) { diagnostics.push({ severity: "error", - message: localizeValidationMessage("unknownProperty", localizer, { - displayPath: combinePath(displayPath, entry.key) + message: localizeValidationMessage(ValidationMessageKeys.unknownProperty, localizer, { + displayPath: joinPropertyPath(displayPath, entry.key) }) }); continue; @@ -593,7 +612,7 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca validateNode( schemaNode.properties[entry.key], entry.node, - combinePath(displayPath, entry.key), + joinPropertyPath(displayPath, entry.key), diagnostics, localizer); } @@ -602,29 +621,31 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca /** * Format one validation message in either English or Simplified Chinese. * - * @param {"expectedArray" | "expectedScalarShape" | "expectedScalarValue" | "enumMismatch" | "expectedObject" | "missingRequired" | "unknownProperty"} key Message key. + * @param {string} key Message key. * @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer. * @param {Record} params Message parameters. * @returns {string} Localized validation message. */ function localizeValidationMessage(key, localizer, params) { + if (localizer && typeof localizer.t === "function") { + return localizer.t(key, params); + } + if (localizer && localizer.isChinese) { switch (key) { - case "expectedArray": + case ValidationMessageKeys.expectedArray: return `属性“${params.displayPath}”应为数组。`; - case "expectedScalarShape": + case ValidationMessageKeys.expectedScalarShape: return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前 YAML 结构是“${params.yamlKind}”。`; - case "expectedScalarValue": + case ValidationMessageKeys.expectedScalarValue: return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前标量值不兼容。`; - case "enumMismatch": + case ValidationMessageKeys.enumMismatch: return `属性“${params.displayPath}”必须是以下值之一:${params.values}。`; - case "expectedObject": - return params.displayPath && params.displayPath.length > 0 - ? `属性“${params.displayPath}”应为对象。` - : "根对象应为对象。"; - case "missingRequired": + case ValidationMessageKeys.expectedObject: + return params.subject; + case ValidationMessageKeys.missingRequired: return `缺少必填属性“${params.displayPath}”。`; - case "unknownProperty": + case ValidationMessageKeys.unknownProperty: return `属性“${params.displayPath}”未在匹配的 schema 中声明。`; default: return key; @@ -632,19 +653,19 @@ function localizeValidationMessage(key, localizer, params) { } switch (key) { - case "expectedArray": + case ValidationMessageKeys.expectedArray: return `Property '${params.displayPath}' is expected to be an array.`; - case "expectedScalarShape": + case ValidationMessageKeys.expectedScalarShape: return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current YAML shape is '${params.yamlKind}'.`; - case "expectedScalarValue": + case ValidationMessageKeys.expectedScalarValue: return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current scalar value is incompatible.`; - case "enumMismatch": + case ValidationMessageKeys.enumMismatch: return `Property '${params.displayPath}' must be one of: ${params.values}.`; - case "expectedObject": - return `${params.subject} is expected to be an object.`; - case "missingRequired": + case ValidationMessageKeys.expectedObject: + return params.subject; + case ValidationMessageKeys.missingRequired: return `Required property '${params.displayPath}' is missing.`; - case "unknownProperty": + case ValidationMessageKeys.unknownProperty: return `Property '${params.displayPath}' is not declared in the matching schema.`; default: return key; @@ -719,14 +740,14 @@ function parseMapping(tokens, state, indent) { continue; } - const match = /^([A-Za-z0-9_]+):(.*)$/u.exec(token.text); - if (!match) { + const mapping = parseYamlMappingText(token.text); + if (!mapping) { state.index += 1; continue; } - const key = match[1]; - const rawValue = match[2].trim(); + const key = mapping.key; + const rawValue = mapping.rawValue.trim(); state.index += 1; let node; @@ -774,7 +795,7 @@ function parseSequence(tokens, state, indent) { continue; } - if (/^[A-Za-z0-9_]+:/u.test(rest)) { + if (parseYamlMappingText(rest)) { items.push(parseInlineObjectItem(tokens, state, indent, rest)); continue; } @@ -894,7 +915,7 @@ function renderYaml(node, indent = 0, currentPath = "", commentMap = {}) { function renderObjectNode(node, indent, currentPath, commentMap) { const lines = []; for (const entry of node.entries) { - const entryPath = currentPath ? `${currentPath}.${entry.key}` : entry.key; + const entryPath = joinPropertyPath(currentPath, entry.key); if (commentMap[entryPath]) { lines.push(...renderYamlComments(commentMap[entryPath], indent)); } @@ -927,7 +948,7 @@ function renderArrayNode(node, indent, currentPath, commentMap) { const lines = []; for (let index = 0; index < node.items.length; index += 1) { const item = node.items[index]; - const itemPath = `${currentPath}[${index}]`; + const itemPath = joinArrayIndexPath(currentPath, index); if (commentMap[itemPath]) { lines.push(...renderYamlComments(commentMap[itemPath], indent)); } @@ -1041,7 +1062,7 @@ function collectSchemaComments(schemaNode, currentPath, commentMap) { } for (const [key, propertySchema] of Object.entries(schemaNode.properties || {})) { - const propertyPath = currentPath ? `${currentPath}.${key}` : key; + const propertyPath = joinPropertyPath(currentPath, key); if (propertySchema.description) { commentMap[propertyPath] = propertySchema.description; } @@ -1052,7 +1073,7 @@ function collectSchemaComments(schemaNode, currentPath, commentMap) { } if (propertySchema.type === "array" && propertySchema.items.type === "object") { - collectSchemaComments(propertySchema.items, `${propertyPath}[0]`, commentMap); + collectSchemaComments(propertySchema.items, joinArrayIndexPath(propertyPath, 0), commentMap); } } } @@ -1201,14 +1222,66 @@ function splitYamlValueAndInlineComment(rawValue) { } /** - * Combine a parent path with one child segment. + * Parse one YAML mapping entry such as `key: value` or `"complex key": value`. * - * @param {string} parentPath Parent path. - * @param {string} key Child key. - * @returns {string} Combined path. + * @param {string} text Raw YAML line text without leading indentation. + * @returns {{key: string, rawValue: string} | undefined} Parsed mapping entry. */ -function combinePath(parentPath, key) { - return parentPath && parentPath !== "" ? `${parentPath}.${key}` : key; +function parseYamlMappingText(text) { + const separatorIndex = findYamlKeyValueSeparator(text); + if (separatorIndex < 0) { + return undefined; + } + + const rawKey = text.slice(0, separatorIndex).trim(); + if (rawKey.length === 0) { + return undefined; + } + + return { + key: normalizeYamlKey(rawKey), + rawValue: text.slice(separatorIndex + 1) + }; +} + +/** + * Find the first `:` that acts as a YAML key/value separator. + * + * @param {string} text Raw YAML line text without leading indentation. + * @returns {number} Separator index, or -1 when not found. + */ +function findYamlKeyValueSeparator(text) { + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + if (character === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + continue; + } + + if (character === "\"" && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + continue; + } + + if (character === ":" && !inSingleQuote && !inDoubleQuote) { + return index; + } + } + + return -1; +} + +/** + * Normalize a YAML key token into the logical key name used in the form model. + * + * @param {string} rawKey Raw YAML key token. + * @returns {string} Normalized key name. + */ +function normalizeYamlKey(rawKey) { + return unquoteScalar(rawKey.trim()); } module.exports = { diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index 56a3b99b..9695074e 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -12,6 +12,12 @@ const { unquoteScalar, validateParsedConfig } = require("./configValidation"); +const { + isTemplatePath, + joinArrayIndexPath, + joinArrayTemplatePath, + joinPropertyPath +} = require("./configPath"); const {createLocalizer} = require("./localization"); const localizer = createLocalizer(vscode.env.language); @@ -353,11 +359,11 @@ async function openFormPreview(item, diagnostics) { return; } - const yamlText = await fs.promises.readFile(configUri.fsPath, "utf8"); - const parsedYaml = parseTopLevelYaml(yamlText); - const commentLookup = extractYamlComments(yamlText); + let latestYamlText = await fs.promises.readFile(configUri.fsPath, "utf8"); + const parsedYaml = parseTopLevelYaml(latestYamlText); + const commentLookup = extractYamlComments(latestYamlText); const schemaInfo = await loadSchemaInfoForConfig(configUri, workspaceRoot); - const canInitializeFromSchema = schemaInfo.exists && yamlText.trim().length === 0; + const canInitializeFromSchema = schemaInfo.exists && latestYamlText.trim().length === 0; const panel = vscode.window.createWebviewPanel( "gframeworkConfigFormPreview", @@ -376,7 +382,7 @@ async function openFormPreview(item, diagnostics) { panel.webview.onDidReceiveMessage(async (message) => { if (message.type === "save") { - const latestYamlText = await fs.promises.readFile(configUri.fsPath, "utf8"); + latestYamlText = await fs.promises.readFile(configUri.fsPath, "utf8"); const updatedYaml = applyFormUpdates(latestYamlText, { scalars: message.scalars || {}, arrays: parseArrayFieldPayload(message.arrays || {}), @@ -402,17 +408,30 @@ async function openFormPreview(item, diagnostics) { return; } + const confirmLabel = localizer.t("button.initializeFromSchemaConfirm"); + const cancelLabel = localizer.t("button.cancel"); + const userChoice = await vscode.window.showWarningMessage( + localizer.t("message.initializeFromSchemaConfirm"), + {modal: true}, + confirmLabel, + cancelLabel); + + if (userChoice !== confirmLabel) { + return; + } + const sampleYaml = createSampleConfigYaml(schemaInfo); await fs.promises.writeFile(configUri.fsPath, sampleYaml, "utf8"); const document = await vscode.workspace.openTextDocument(configUri); await document.save(); + latestYamlText = sampleYaml; await validateConfigFile(configUri, diagnostics); panel.webview.html = renderFormHtml( path.basename(configUri.fsPath), schemaInfo, - parseTopLevelYaml(sampleYaml), + parseTopLevelYaml(latestYamlText), { - commentLookup: extractYamlComments(sampleYaml), + commentLookup: extractYamlComments(latestYamlText), canInitializeFromSchema: false }); void vscode.window.showInformationMessage(localizer.t("message.formInitialized")); @@ -1178,7 +1197,7 @@ function renderYamlCommentBlock(field) { */ function renderCommentEditor(field) { const commentPath = field.displayPath || field.path; - if (commentPath.includes("[]")) { + if (isTemplatePath(commentPath)) { return ""; } @@ -1273,7 +1292,7 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns const requiredSet = new Set(Array.isArray(schemaNode.required) ? schemaNode.required : []); for (const [key, propertySchema] of Object.entries(schemaNode.properties || {})) { - const propertyPath = currentPath ? `${currentPath}.${key}` : key; + const propertyPath = joinPropertyPath(currentPath, key); const label = propertySchema.title || key; const propertyValue = yamlMap.get(key); @@ -1317,7 +1336,7 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns propertySchema.items, undefined, "", - `${propertyPath}[]`, + joinArrayTemplatePath(propertyPath), depth + 1, itemFieldsTemplate, unsupported, @@ -1386,7 +1405,7 @@ function buildObjectArrayItemModels(itemSchema, yamlNode, propertyPath, depth, u const items = []; for (let index = 0; index < yamlNode.items.length; index += 1) { const itemNode = yamlNode.items[index]; - const itemPath = `${propertyPath}[${index}]`; + const itemPath = joinArrayIndexPath(propertyPath, index); if (!itemNode || itemNode.kind !== "object") { unsupported.push({ path: itemPath, @@ -1437,8 +1456,8 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa const requiredSet = new Set(Array.isArray(schemaNode.required) ? schemaNode.required : []); for (const [key, propertySchema] of Object.entries(schemaNode.properties || {})) { - const itemLocalPath = localPath ? `${localPath}.${key}` : key; - const itemDisplayPath = `${displayPath}.${key}`; + const itemLocalPath = joinPropertyPath(localPath, key); + const itemDisplayPath = joinPropertyPath(displayPath, key); const label = propertySchema.title || key; const propertyValue = yamlMap.get(key); diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js index 4cb43ad5..3c8a74d7 100644 --- a/tools/gframework-config-tool/src/localization.js +++ b/tools/gframework-config-tool/src/localization.js @@ -1,3 +1,5 @@ +const {ValidationMessageKeys} = require("./localizationKeys"); + /** * Create a tiny in-process localizer for the extension runtime and webview. * VS Code contribution points use package.nls files, while runtime strings are @@ -10,12 +12,26 @@ function createLocalizer(language) { const normalizedLanguage = String(language || "en").toLowerCase(); const isChinese = normalizedLanguage.startsWith("zh"); - const languageTag = isChinese ? "zh-CN" : "en"; - const dictionary = isChinese ? zhCnMessages : enMessages; + const isTraditionalChinese = + normalizedLanguage === "zh-tw" || + normalizedLanguage === "zh-hk" || + normalizedLanguage === "zh-mo" || + normalizedLanguage.startsWith("zh-hant"); + const isSimplifiedChinese = isChinese && !isTraditionalChinese; + const languageTag = isTraditionalChinese + ? normalizedLanguage + : isSimplifiedChinese + ? "zh-CN" + : "en"; + const dictionary = isTraditionalChinese + ? enMessages + : isSimplifiedChinese + ? zhCnMessages + : enMessages; return { languageTag, - isChinese, + isChinese: isSimplifiedChinese, t(key, params) { const template = dictionary[key] || enMessages[key] || key; return template.replace(/\{([A-Za-z0-9_]+)\}/gu, (match, token) => { @@ -38,6 +54,7 @@ const enMessages = { "message.schemaNotFound": "Matching schema file was not found.", "message.formSaved": "Config file saved from form preview.", "message.formInitialized": "Example config initialized from the schema.", + "message.initializeFromSchemaConfirm": "Initializing from the schema will replace the current configuration and may discard unsaved form changes. Do you want to continue?", "message.noYamlFilesInDomain": "No YAML config files were found in the selected domain.", "message.batchEditNeedsSchema": "Batch edit requires a matching schema file for the selected domain.", "message.batchEditNoEditableFields": "No top-level scalar or scalar-array fields were found in the matching schema.", @@ -55,6 +72,8 @@ const enMessages = { "detail.refTable": "ref: {refTable}", "detail.arrayType": "array<{itemType}>", "detail.default": "default", + "button.cancel": "Cancel", + "button.initializeFromSchemaConfirm": "Initialize from schema", "input.batchArray.title": "Batch Edit Array: {field}", "input.batchArray.prompt": "Enter comma-separated items for '{fieldKey}' (expected array<{itemType}>). Leave empty to clear the array.", "input.batchArray.placeholder.allowedItems": "Allowed items: {values}", @@ -90,7 +109,14 @@ const enMessages = { "webview.unsupported.array": "Unsupported array shapes are currently raw-YAML-only in the form preview.", "webview.unsupported.type": "{type} fields are currently raw-YAML-only.", "webview.unsupported.objectArrayMixed": "Object-array items must be mappings. Use raw YAML if the current file mixes scalar and object items.", - "webview.unsupported.nestedObjectArray": "Nested object-array fields are currently raw-YAML-only inside the object-array editor." + "webview.unsupported.nestedObjectArray": "Nested object-array fields are currently raw-YAML-only inside the object-array editor.", + [ValidationMessageKeys.enumMismatch]: "Property '{displayPath}' must be one of: {values}.", + [ValidationMessageKeys.expectedArray]: "Property '{displayPath}' is expected to be an array.", + [ValidationMessageKeys.expectedObject]: "{subject} is expected to be an object.", + [ValidationMessageKeys.expectedScalarShape]: "Property '{displayPath}' is expected to be '{schemaType}', but the current YAML shape is '{yamlKind}'.", + [ValidationMessageKeys.expectedScalarValue]: "Property '{displayPath}' is expected to be '{schemaType}', but the current scalar value is incompatible.", + [ValidationMessageKeys.missingRequired]: "Required property '{displayPath}' is missing.", + [ValidationMessageKeys.unknownProperty]: "Property '{displayPath}' is not declared in the matching schema." }; const zhCnMessages = { @@ -102,6 +128,7 @@ const zhCnMessages = { "message.schemaNotFound": "未找到匹配的 schema 文件。", "message.formSaved": "已从表单预览保存配置文件。", "message.formInitialized": "已根据 schema 初始化示例配置。", + "message.initializeFromSchemaConfirm": "从 schema 初始化会替换当前配置,并且可能丢失尚未保存的表单修改。是否继续?", "message.noYamlFilesInDomain": "所选配置域中没有找到 YAML 配置文件。", "message.batchEditNeedsSchema": "批量编辑要求该配置域存在匹配的 schema 文件。", "message.batchEditNoEditableFields": "匹配的 schema 中没有可批量编辑的顶层标量字段或标量数组字段。", @@ -119,6 +146,8 @@ const zhCnMessages = { "detail.refTable": "引用表:{refTable}", "detail.arrayType": "数组<{itemType}>", "detail.default": "默认值", + "button.cancel": "取消", + "button.initializeFromSchemaConfirm": "从 schema 初始化", "input.batchArray.title": "批量编辑数组:{field}", "input.batchArray.prompt": "请输入“{fieldKey}”的逗号分隔项(期望类型:数组<{itemType}>)。留空表示清空数组。", "input.batchArray.placeholder.allowedItems": "允许项:{values}", @@ -154,7 +183,14 @@ const zhCnMessages = { "webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。", "webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。", "webview.unsupported.objectArrayMixed": "对象数组中的每一项都必须是映射对象。如果当前文件混用了标量项和对象项,请改用原始 YAML。", - "webview.unsupported.nestedObjectArray": "对象数组编辑器内暂不支持更深层的对象数组字段,请改用原始 YAML。" + "webview.unsupported.nestedObjectArray": "对象数组编辑器内暂不支持更深层的对象数组字段,请改用原始 YAML。", + [ValidationMessageKeys.enumMismatch]: "属性“{displayPath}”必须是以下值之一:{values}。", + [ValidationMessageKeys.expectedArray]: "属性“{displayPath}”应为数组。", + [ValidationMessageKeys.expectedObject]: "{subject}", + [ValidationMessageKeys.expectedScalarShape]: "属性“{displayPath}”应为“{schemaType}”,但当前 YAML 结构是“{yamlKind}”。", + [ValidationMessageKeys.expectedScalarValue]: "属性“{displayPath}”应为“{schemaType}”,但当前标量值不兼容。", + [ValidationMessageKeys.missingRequired]: "缺少必填属性“{displayPath}”。", + [ValidationMessageKeys.unknownProperty]: "属性“{displayPath}”未在匹配的 schema 中声明。" }; module.exports = { diff --git a/tools/gframework-config-tool/src/localizationKeys.js b/tools/gframework-config-tool/src/localizationKeys.js new file mode 100644 index 00000000..caf2f635 --- /dev/null +++ b/tools/gframework-config-tool/src/localizationKeys.js @@ -0,0 +1,13 @@ +const ValidationMessageKeys = Object.freeze({ + enumMismatch: "validation.enumMismatch", + expectedArray: "validation.expectedArray", + expectedObject: "validation.expectedObject", + expectedScalarShape: "validation.expectedScalarShape", + expectedScalarValue: "validation.expectedScalarValue", + missingRequired: "validation.missingRequired", + unknownProperty: "validation.unknownProperty" +}); + +module.exports = { + ValidationMessageKeys +}; diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index e62415dc..225be952 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -85,6 +85,20 @@ phases: assert.equal(yaml.map.get("phases").items[0].map.get("wave").value, "1"); }); +test("parseTopLevelYaml should keep complex mapping keys", () => { + const yaml = parseTopLevelYaml(` +my-key: slime +"complex key": value +root: + item.id: potion +`); + + assert.equal(yaml.kind, "object"); + assert.equal(yaml.map.get("my-key").value, "slime"); + assert.equal(yaml.map.get("complex key").value, "value"); + assert.equal(yaml.map.get("root").map.get("item.id").value, "potion"); +}); + test("validateParsedConfig should report missing and unknown nested properties", () => { const schema = parseSchemaContent(` { @@ -312,6 +326,22 @@ skills: assert.equal(comments["skills[0].id"], "Skill id note"); }); +test("extractYamlComments should keep comments for complex YAML keys", () => { + const comments = extractYamlComments(` +# Dashed key comment +my-key: Slime +# Quoted key comment +"complex key": value +root: + # Dotted key comment + item.id: potion +`); + + assert.equal(comments["my-key"], "Dashed key comment"); + assert.equal(comments["complex key"], "Quoted key comment"); + assert.equal(comments["root.item.id"], "Dotted key comment"); +}); + test("applyFormUpdates should preserve and update YAML comments", () => { const updated = applyFormUpdates( [ diff --git a/tools/gframework-config-tool/test/localization.test.js b/tools/gframework-config-tool/test/localization.test.js index 30d2bf18..503eae15 100644 --- a/tools/gframework-config-tool/test/localization.test.js +++ b/tools/gframework-config-tool/test/localization.test.js @@ -23,3 +23,14 @@ test("createLocalizer should switch to Simplified Chinese for zh languages", () localizer.t("message.batchEditUpdated", {count: 2, domain: "monster"}), "已在“monster”中批量更新 2 个配置文件。"); }); + +test("createLocalizer should fall back to English for Traditional Chinese locales", () => { + const localizer = createLocalizer("zh-TW"); + + assert.equal(localizer.languageTag, "zh-tw"); + assert.equal(localizer.isChinese, false); + assert.equal(localizer.t("webview.button.save"), "Save Form"); + assert.equal( + localizer.t("message.batchEditUpdated", {count: 2, domain: "monster"}), + "Batch updated 2 config file(s) in 'monster'."); +});