| import fs from 'node:fs' | |||||
| import path from 'node:path' | |||||
| // Mock functions to simulate the check-i18n functionality | |||||
| const vm = require('node:vm') | |||||
| const transpile = require('typescript').transpile | |||||
| describe('check-i18n script functionality', () => { | |||||
| const testDir = path.join(__dirname, '../i18n-test') | |||||
| const testEnDir = path.join(testDir, 'en-US') | |||||
| const testZhDir = path.join(testDir, 'zh-Hans') | |||||
| // Helper function that replicates the getKeysFromLanguage logic | |||||
| async function getKeysFromLanguage(language: string, testPath = testDir): Promise<string[]> { | |||||
| return new Promise((resolve, reject) => { | |||||
| const folderPath = path.resolve(testPath, language) | |||||
| const allKeys: string[] = [] | |||||
| if (!fs.existsSync(folderPath)) { | |||||
| resolve([]) | |||||
| return | |||||
| } | |||||
| fs.readdir(folderPath, (err, files) => { | |||||
| if (err) { | |||||
| reject(err) | |||||
| return | |||||
| } | |||||
| const translationFiles = files.filter(file => /\.(ts|js)$/.test(file)) | |||||
| translationFiles.forEach((file) => { | |||||
| const filePath = path.join(folderPath, file) | |||||
| const fileName = file.replace(/\.[^/.]+$/, '') | |||||
| const camelCaseFileName = fileName.replace(/[-_](.)/g, (_, c) => | |||||
| c.toUpperCase(), | |||||
| ) | |||||
| try { | |||||
| const content = fs.readFileSync(filePath, 'utf8') | |||||
| const moduleExports = {} | |||||
| const context = { | |||||
| exports: moduleExports, | |||||
| module: { exports: moduleExports }, | |||||
| require, | |||||
| console, | |||||
| __filename: filePath, | |||||
| __dirname: folderPath, | |||||
| } | |||||
| vm.runInNewContext(transpile(content), context) | |||||
| const translationObj = moduleExports.default || moduleExports | |||||
| if(!translationObj || typeof translationObj !== 'object') | |||||
| throw new Error(`Error parsing file: ${filePath}`) | |||||
| const nestedKeys: string[] = [] | |||||
| const iterateKeys = (obj: any, prefix = '') => { | |||||
| for (const key in obj) { | |||||
| const nestedKey = prefix ? `${prefix}.${key}` : key | |||||
| if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { | |||||
| // This is an object (but not array), recurse into it but don't add it as a key | |||||
| iterateKeys(obj[key], nestedKey) | |||||
| } | |||||
| else { | |||||
| // This is a leaf node (string, number, boolean, array, etc.), add it as a key | |||||
| nestedKeys.push(nestedKey) | |||||
| } | |||||
| } | |||||
| } | |||||
| iterateKeys(translationObj) | |||||
| const fileKeys = nestedKeys.map(key => `${camelCaseFileName}.${key}`) | |||||
| allKeys.push(...fileKeys) | |||||
| } | |||||
| catch (error) { | |||||
| reject(error) | |||||
| } | |||||
| }) | |||||
| resolve(allKeys) | |||||
| }) | |||||
| }) | |||||
| } | |||||
| beforeEach(() => { | |||||
| // Clean up and create test directories | |||||
| if (fs.existsSync(testDir)) | |||||
| fs.rmSync(testDir, { recursive: true }) | |||||
| fs.mkdirSync(testDir, { recursive: true }) | |||||
| fs.mkdirSync(testEnDir, { recursive: true }) | |||||
| fs.mkdirSync(testZhDir, { recursive: true }) | |||||
| }) | |||||
| afterEach(() => { | |||||
| // Clean up test files | |||||
| if (fs.existsSync(testDir)) | |||||
| fs.rmSync(testDir, { recursive: true }) | |||||
| }) | |||||
| describe('Key extraction logic', () => { | |||||
| it('should extract only leaf node keys, not intermediate objects', async () => { | |||||
| const testContent = `const translation = { | |||||
| simple: 'Simple Value', | |||||
| nested: { | |||||
| level1: 'Level 1 Value', | |||||
| deep: { | |||||
| level2: 'Level 2 Value' | |||||
| } | |||||
| }, | |||||
| array: ['not extracted'], | |||||
| number: 42, | |||||
| boolean: true | |||||
| } | |||||
| export default translation | |||||
| ` | |||||
| fs.writeFileSync(path.join(testEnDir, 'test.ts'), testContent) | |||||
| const keys = await getKeysFromLanguage('en-US') | |||||
| expect(keys).toEqual([ | |||||
| 'test.simple', | |||||
| 'test.nested.level1', | |||||
| 'test.nested.deep.level2', | |||||
| 'test.array', | |||||
| 'test.number', | |||||
| 'test.boolean', | |||||
| ]) | |||||
| // Should not include intermediate object keys | |||||
| expect(keys).not.toContain('test.nested') | |||||
| expect(keys).not.toContain('test.nested.deep') | |||||
| }) | |||||
| it('should handle camelCase file name conversion correctly', async () => { | |||||
| const testContent = `const translation = { | |||||
| key: 'value' | |||||
| } | |||||
| export default translation | |||||
| ` | |||||
| fs.writeFileSync(path.join(testEnDir, 'app-debug.ts'), testContent) | |||||
| fs.writeFileSync(path.join(testEnDir, 'user_profile.ts'), testContent) | |||||
| const keys = await getKeysFromLanguage('en-US') | |||||
| expect(keys).toContain('appDebug.key') | |||||
| expect(keys).toContain('userProfile.key') | |||||
| }) | |||||
| }) | |||||
| describe('Missing keys detection', () => { | |||||
| it('should detect missing keys in target language', async () => { | |||||
| const enContent = `const translation = { | |||||
| common: { | |||||
| save: 'Save', | |||||
| cancel: 'Cancel', | |||||
| delete: 'Delete' | |||||
| }, | |||||
| app: { | |||||
| title: 'My App', | |||||
| version: '1.0' | |||||
| } | |||||
| } | |||||
| export default translation | |||||
| ` | |||||
| const zhContent = `const translation = { | |||||
| common: { | |||||
| save: '保存', | |||||
| cancel: '取消' | |||||
| // missing 'delete' | |||||
| }, | |||||
| app: { | |||||
| title: '我的应用' | |||||
| // missing 'version' | |||||
| } | |||||
| } | |||||
| export default translation | |||||
| ` | |||||
| fs.writeFileSync(path.join(testEnDir, 'test.ts'), enContent) | |||||
| fs.writeFileSync(path.join(testZhDir, 'test.ts'), zhContent) | |||||
| const enKeys = await getKeysFromLanguage('en-US') | |||||
| const zhKeys = await getKeysFromLanguage('zh-Hans') | |||||
| const missingKeys = enKeys.filter(key => !zhKeys.includes(key)) | |||||
| expect(missingKeys).toContain('test.common.delete') | |||||
| expect(missingKeys).toContain('test.app.version') | |||||
| expect(missingKeys).toHaveLength(2) | |||||
| }) | |||||
| }) | |||||
| describe('Extra keys detection', () => { | |||||
| it('should detect extra keys in target language', async () => { | |||||
| const enContent = `const translation = { | |||||
| common: { | |||||
| save: 'Save', | |||||
| cancel: 'Cancel' | |||||
| } | |||||
| } | |||||
| export default translation | |||||
| ` | |||||
| const zhContent = `const translation = { | |||||
| common: { | |||||
| save: '保存', | |||||
| cancel: '取消', | |||||
| delete: '删除', // extra key | |||||
| extra: '额外的' // another extra key | |||||
| }, | |||||
| newSection: { | |||||
| someKey: '某个值' // extra section | |||||
| } | |||||
| } | |||||
| export default translation | |||||
| ` | |||||
| fs.writeFileSync(path.join(testEnDir, 'test.ts'), enContent) | |||||
| fs.writeFileSync(path.join(testZhDir, 'test.ts'), zhContent) | |||||
| const enKeys = await getKeysFromLanguage('en-US') | |||||
| const zhKeys = await getKeysFromLanguage('zh-Hans') | |||||
| const extraKeys = zhKeys.filter(key => !enKeys.includes(key)) | |||||
| expect(extraKeys).toContain('test.common.delete') | |||||
| expect(extraKeys).toContain('test.common.extra') | |||||
| expect(extraKeys).toContain('test.newSection.someKey') | |||||
| expect(extraKeys).toHaveLength(3) | |||||
| }) | |||||
| }) | |||||
| describe('File filtering logic', () => { | |||||
| it('should filter keys by specific file correctly', async () => { | |||||
| // Create multiple files | |||||
| const file1Content = `const translation = { | |||||
| button: 'Button', | |||||
| text: 'Text' | |||||
| } | |||||
| export default translation | |||||
| ` | |||||
| const file2Content = `const translation = { | |||||
| title: 'Title', | |||||
| description: 'Description' | |||||
| } | |||||
| export default translation | |||||
| ` | |||||
| fs.writeFileSync(path.join(testEnDir, 'components.ts'), file1Content) | |||||
| fs.writeFileSync(path.join(testEnDir, 'pages.ts'), file2Content) | |||||
| fs.writeFileSync(path.join(testZhDir, 'components.ts'), file1Content) | |||||
| fs.writeFileSync(path.join(testZhDir, 'pages.ts'), file2Content) | |||||
| const allEnKeys = await getKeysFromLanguage('en-US') | |||||
| const allZhKeys = await getKeysFromLanguage('zh-Hans') | |||||
| // Test file filtering logic | |||||
| const targetFile = 'components' | |||||
| const filteredEnKeys = allEnKeys.filter(key => | |||||
| key.startsWith(targetFile.replace(/[-_](.)/g, (_, c) => c.toUpperCase())), | |||||
| ) | |||||
| const filteredZhKeys = allZhKeys.filter(key => | |||||
| key.startsWith(targetFile.replace(/[-_](.)/g, (_, c) => c.toUpperCase())), | |||||
| ) | |||||
| expect(allEnKeys).toHaveLength(4) // 2 keys from each file | |||||
| expect(filteredEnKeys).toHaveLength(2) // only components keys | |||||
| expect(filteredEnKeys).toContain('components.button') | |||||
| expect(filteredEnKeys).toContain('components.text') | |||||
| expect(filteredEnKeys).not.toContain('pages.title') | |||||
| expect(filteredEnKeys).not.toContain('pages.description') | |||||
| }) | |||||
| }) | |||||
| describe('Complex nested structure handling', () => { | |||||
| it('should handle deeply nested objects correctly', async () => { | |||||
| const complexContent = `const translation = { | |||||
| level1: { | |||||
| level2: { | |||||
| level3: { | |||||
| level4: { | |||||
| deepValue: 'Deep Value' | |||||
| }, | |||||
| anotherValue: 'Another Value' | |||||
| }, | |||||
| simpleValue: 'Simple Value' | |||||
| }, | |||||
| directValue: 'Direct Value' | |||||
| }, | |||||
| rootValue: 'Root Value' | |||||
| } | |||||
| export default translation | |||||
| ` | |||||
| fs.writeFileSync(path.join(testEnDir, 'complex.ts'), complexContent) | |||||
| const keys = await getKeysFromLanguage('en-US') | |||||
| expect(keys).toContain('complex.level1.level2.level3.level4.deepValue') | |||||
| expect(keys).toContain('complex.level1.level2.level3.anotherValue') | |||||
| expect(keys).toContain('complex.level1.level2.simpleValue') | |||||
| expect(keys).toContain('complex.level1.directValue') | |||||
| expect(keys).toContain('complex.rootValue') | |||||
| // Should not include intermediate objects | |||||
| expect(keys).not.toContain('complex.level1') | |||||
| expect(keys).not.toContain('complex.level1.level2') | |||||
| expect(keys).not.toContain('complex.level1.level2.level3') | |||||
| expect(keys).not.toContain('complex.level1.level2.level3.level4') | |||||
| }) | |||||
| }) | |||||
| describe('Edge cases', () => { | |||||
| it('should handle empty objects', async () => { | |||||
| const emptyContent = `const translation = { | |||||
| empty: {}, | |||||
| withValue: 'value' | |||||
| } | |||||
| export default translation | |||||
| ` | |||||
| fs.writeFileSync(path.join(testEnDir, 'empty.ts'), emptyContent) | |||||
| const keys = await getKeysFromLanguage('en-US') | |||||
| expect(keys).toContain('empty.withValue') | |||||
| expect(keys).not.toContain('empty.empty') | |||||
| }) | |||||
| it('should handle special characters in keys', async () => { | |||||
| const specialContent = `const translation = { | |||||
| 'key-with-dash': 'value1', | |||||
| 'key_with_underscore': 'value2', | |||||
| 'key.with.dots': 'value3', | |||||
| normalKey: 'value4' | |||||
| } | |||||
| export default translation | |||||
| ` | |||||
| fs.writeFileSync(path.join(testEnDir, 'special.ts'), specialContent) | |||||
| const keys = await getKeysFromLanguage('en-US') | |||||
| expect(keys).toContain('special.key-with-dash') | |||||
| expect(keys).toContain('special.key_with_underscore') | |||||
| expect(keys).toContain('special.key.with.dots') | |||||
| expect(keys).toContain('special.normalKey') | |||||
| }) | |||||
| it('should handle different value types', async () => { | |||||
| const typesContent = `const translation = { | |||||
| stringValue: 'string', | |||||
| numberValue: 42, | |||||
| booleanValue: true, | |||||
| nullValue: null, | |||||
| undefinedValue: undefined, | |||||
| arrayValue: ['array', 'values'], | |||||
| objectValue: { | |||||
| nested: 'nested value' | |||||
| } | |||||
| } | |||||
| export default translation | |||||
| ` | |||||
| fs.writeFileSync(path.join(testEnDir, 'types.ts'), typesContent) | |||||
| const keys = await getKeysFromLanguage('en-US') | |||||
| expect(keys).toContain('types.stringValue') | |||||
| expect(keys).toContain('types.numberValue') | |||||
| expect(keys).toContain('types.booleanValue') | |||||
| expect(keys).toContain('types.nullValue') | |||||
| expect(keys).toContain('types.undefinedValue') | |||||
| expect(keys).toContain('types.arrayValue') | |||||
| expect(keys).toContain('types.objectValue.nested') | |||||
| expect(keys).not.toContain('types.objectValue') | |||||
| }) | |||||
| }) | |||||
| describe('Real-world scenario tests', () => { | |||||
| it('should handle app-debug structure like real files', async () => { | |||||
| const appDebugEn = `const translation = { | |||||
| pageTitle: { | |||||
| line1: 'Prompt', | |||||
| line2: 'Engineering' | |||||
| }, | |||||
| operation: { | |||||
| applyConfig: 'Publish', | |||||
| resetConfig: 'Reset', | |||||
| debugConfig: 'Debug' | |||||
| }, | |||||
| generate: { | |||||
| instruction: 'Instructions', | |||||
| generate: 'Generate', | |||||
| resTitle: 'Generated Prompt', | |||||
| noDataLine1: 'Describe your use case on the left,', | |||||
| noDataLine2: 'the orchestration preview will show here.' | |||||
| } | |||||
| } | |||||
| export default translation | |||||
| ` | |||||
| const appDebugZh = `const translation = { | |||||
| pageTitle: { | |||||
| line1: '提示词', | |||||
| line2: '编排' | |||||
| }, | |||||
| operation: { | |||||
| applyConfig: '发布', | |||||
| resetConfig: '重置', | |||||
| debugConfig: '调试' | |||||
| }, | |||||
| generate: { | |||||
| instruction: '指令', | |||||
| generate: '生成', | |||||
| resTitle: '生成的提示词', | |||||
| noData: '在左侧描述您的用例,编排预览将在此处显示。' // This is extra | |||||
| } | |||||
| } | |||||
| export default translation | |||||
| ` | |||||
| fs.writeFileSync(path.join(testEnDir, 'app-debug.ts'), appDebugEn) | |||||
| fs.writeFileSync(path.join(testZhDir, 'app-debug.ts'), appDebugZh) | |||||
| const enKeys = await getKeysFromLanguage('en-US') | |||||
| const zhKeys = await getKeysFromLanguage('zh-Hans') | |||||
| const missingKeys = enKeys.filter(key => !zhKeys.includes(key)) | |||||
| const extraKeys = zhKeys.filter(key => !enKeys.includes(key)) | |||||
| expect(missingKeys).toContain('appDebug.generate.noDataLine1') | |||||
| expect(missingKeys).toContain('appDebug.generate.noDataLine2') | |||||
| expect(extraKeys).toContain('appDebug.generate.noData') | |||||
| expect(missingKeys).toHaveLength(2) | |||||
| expect(extraKeys).toHaveLength(1) | |||||
| }) | |||||
| it('should handle time structure with operation nested keys', async () => { | |||||
| const timeEn = `const translation = { | |||||
| months: { | |||||
| January: 'January', | |||||
| February: 'February' | |||||
| }, | |||||
| operation: { | |||||
| now: 'Now', | |||||
| ok: 'OK', | |||||
| cancel: 'Cancel', | |||||
| pickDate: 'Pick Date' | |||||
| }, | |||||
| title: { | |||||
| pickTime: 'Pick Time' | |||||
| }, | |||||
| defaultPlaceholder: 'Pick a time...' | |||||
| } | |||||
| export default translation | |||||
| ` | |||||
| const timeZh = `const translation = { | |||||
| months: { | |||||
| January: '一月', | |||||
| February: '二月' | |||||
| }, | |||||
| operation: { | |||||
| now: '此刻', | |||||
| ok: '确定', | |||||
| cancel: '取消', | |||||
| pickDate: '选择日期' | |||||
| }, | |||||
| title: { | |||||
| pickTime: '选择时间' | |||||
| }, | |||||
| pickDate: '选择日期', // This is extra - duplicates operation.pickDate | |||||
| defaultPlaceholder: '请选择时间...' | |||||
| } | |||||
| export default translation | |||||
| ` | |||||
| fs.writeFileSync(path.join(testEnDir, 'time.ts'), timeEn) | |||||
| fs.writeFileSync(path.join(testZhDir, 'time.ts'), timeZh) | |||||
| const enKeys = await getKeysFromLanguage('en-US') | |||||
| const zhKeys = await getKeysFromLanguage('zh-Hans') | |||||
| const missingKeys = enKeys.filter(key => !zhKeys.includes(key)) | |||||
| const extraKeys = zhKeys.filter(key => !enKeys.includes(key)) | |||||
| expect(missingKeys).toHaveLength(0) // No missing keys | |||||
| expect(extraKeys).toContain('time.pickDate') // Extra root-level pickDate | |||||
| expect(extraKeys).toHaveLength(1) | |||||
| // Should have both keys available | |||||
| expect(zhKeys).toContain('time.operation.pickDate') // Correct nested key | |||||
| expect(zhKeys).toContain('time.pickDate') // Extra duplicate key | |||||
| }) | |||||
| }) | |||||
| describe('Statistics calculation', () => { | |||||
| it('should calculate correct difference statistics', async () => { | |||||
| const enContent = `const translation = { | |||||
| key1: 'value1', | |||||
| key2: 'value2', | |||||
| key3: 'value3' | |||||
| } | |||||
| export default translation | |||||
| ` | |||||
| const zhContentMissing = `const translation = { | |||||
| key1: 'value1', | |||||
| key2: 'value2' | |||||
| // missing key3 | |||||
| } | |||||
| export default translation | |||||
| ` | |||||
| const zhContentExtra = `const translation = { | |||||
| key1: 'value1', | |||||
| key2: 'value2', | |||||
| key3: 'value3', | |||||
| key4: 'extra', | |||||
| key5: 'extra2' | |||||
| } | |||||
| export default translation | |||||
| ` | |||||
| fs.writeFileSync(path.join(testEnDir, 'stats.ts'), enContent) | |||||
| // Test missing keys scenario | |||||
| fs.writeFileSync(path.join(testZhDir, 'stats.ts'), zhContentMissing) | |||||
| const enKeys = await getKeysFromLanguage('en-US') | |||||
| const zhKeysMissing = await getKeysFromLanguage('zh-Hans') | |||||
| expect(enKeys.length - zhKeysMissing.length).toBe(1) // +1 means 1 missing key | |||||
| // Test extra keys scenario | |||||
| fs.writeFileSync(path.join(testZhDir, 'stats.ts'), zhContentExtra) | |||||
| const zhKeysExtra = await getKeysFromLanguage('zh-Hans') | |||||
| expect(enKeys.length - zhKeysExtra.length).toBe(-2) // -2 means 2 extra keys | |||||
| }) | |||||
| }) | |||||
| }) |
| const iterateKeys = (obj, prefix = '') => { | const iterateKeys = (obj, prefix = '') => { | ||||
| for (const key in obj) { | for (const key in obj) { | ||||
| const nestedKey = prefix ? `${prefix}.${key}` : key | const nestedKey = prefix ? `${prefix}.${key}` : key | ||||
| nestedKeys.push(nestedKey) | |||||
| if (typeof obj[key] === 'object' && obj[key] !== null) | |||||
| if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { | |||||
| // This is an object (but not array), recurse into it but don't add it as a key | |||||
| iterateKeys(obj[key], nestedKey) | iterateKeys(obj[key], nestedKey) | ||||
| } | |||||
| else { | |||||
| // This is a leaf node (string, number, boolean, array, etc.), add it as a key | |||||
| nestedKeys.push(nestedKey) | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| iterateKeys(translationObj) | iterateKeys(translationObj) | ||||
| }) | }) | ||||
| } | } | ||||
| function removeKeysFromObject(obj, keysToRemove, prefix = '') { | |||||
| let modified = false | |||||
| for (const key in obj) { | |||||
| const fullKey = prefix ? `${prefix}.${key}` : key | |||||
| if (keysToRemove.includes(fullKey)) { | |||||
| delete obj[key] | |||||
| modified = true | |||||
| console.log(`🗑️ Removed key: ${fullKey}`) | |||||
| } | |||||
| else if (typeof obj[key] === 'object' && obj[key] !== null) { | |||||
| const subModified = removeKeysFromObject(obj[key], keysToRemove, fullKey) | |||||
| modified = modified || subModified | |||||
| } | |||||
| } | |||||
| return modified | |||||
| } | |||||
| async function removeExtraKeysFromFile(language, fileName, extraKeys) { | |||||
| const filePath = path.resolve(__dirname, '../i18n', language, `${fileName}.ts`) | |||||
| if (!fs.existsSync(filePath)) { | |||||
| console.log(`⚠️ File not found: ${filePath}`) | |||||
| return false | |||||
| } | |||||
| try { | |||||
| // Filter keys that belong to this file | |||||
| const camelCaseFileName = fileName.replace(/[-_](.)/g, (_, c) => c.toUpperCase()) | |||||
| const fileSpecificKeys = extraKeys | |||||
| .filter(key => key.startsWith(`${camelCaseFileName}.`)) | |||||
| .map(key => key.substring(camelCaseFileName.length + 1)) // Remove file prefix | |||||
| if (fileSpecificKeys.length === 0) | |||||
| return false | |||||
| console.log(`🔄 Processing file: ${filePath}`) | |||||
| // Read the original file content | |||||
| const content = fs.readFileSync(filePath, 'utf8') | |||||
| const lines = content.split('\n') | |||||
| let modified = false | |||||
| const linesToRemove = [] | |||||
| // Find lines to remove for each key | |||||
| for (const keyToRemove of fileSpecificKeys) { | |||||
| const keyParts = keyToRemove.split('.') | |||||
| let targetLineIndex = -1 | |||||
| // Build regex pattern for the exact key path | |||||
| if (keyParts.length === 1) { | |||||
| // Simple key at root level like "pickDate: 'value'" | |||||
| for (let i = 0; i < lines.length; i++) { | |||||
| const line = lines[i] | |||||
| const simpleKeyPattern = new RegExp(`^\\s*${keyParts[0]}\\s*:`) | |||||
| if (simpleKeyPattern.test(line)) { | |||||
| targetLineIndex = i | |||||
| break | |||||
| } | |||||
| } | |||||
| } | |||||
| else { | |||||
| // Nested key - need to find the exact path | |||||
| const currentPath = [] | |||||
| let braceDepth = 0 | |||||
| for (let i = 0; i < lines.length; i++) { | |||||
| const line = lines[i] | |||||
| const trimmedLine = line.trim() | |||||
| // Track current object path | |||||
| const keyMatch = trimmedLine.match(/^(\w+)\s*:\s*{/) | |||||
| if (keyMatch) { | |||||
| currentPath.push(keyMatch[1]) | |||||
| braceDepth++ | |||||
| } | |||||
| else if (trimmedLine === '},' || trimmedLine === '}') { | |||||
| if (braceDepth > 0) { | |||||
| braceDepth-- | |||||
| currentPath.pop() | |||||
| } | |||||
| } | |||||
| // Check if this line matches our target key | |||||
| const leafKeyMatch = trimmedLine.match(/^(\w+)\s*:/) | |||||
| if (leafKeyMatch) { | |||||
| const fullPath = [...currentPath, leafKeyMatch[1]] | |||||
| const fullPathString = fullPath.join('.') | |||||
| if (fullPathString === keyToRemove) { | |||||
| targetLineIndex = i | |||||
| break | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| if (targetLineIndex !== -1) { | |||||
| linesToRemove.push(targetLineIndex) | |||||
| console.log(`🗑️ Found key to remove: ${keyToRemove} at line ${targetLineIndex + 1}`) | |||||
| modified = true | |||||
| } | |||||
| else { | |||||
| console.log(`⚠️ Could not find key: ${keyToRemove}`) | |||||
| } | |||||
| } | |||||
| if (modified) { | |||||
| // Remove lines in reverse order to maintain correct indices | |||||
| linesToRemove.sort((a, b) => b - a) | |||||
| for (const lineIndex of linesToRemove) { | |||||
| const line = lines[lineIndex] | |||||
| console.log(`🗑️ Removing line ${lineIndex + 1}: ${line.trim()}`) | |||||
| lines.splice(lineIndex, 1) | |||||
| // Also remove trailing comma from previous line if it exists and the next line is a closing brace | |||||
| if (lineIndex > 0 && lineIndex < lines.length) { | |||||
| const prevLine = lines[lineIndex - 1] | |||||
| const nextLine = lines[lineIndex] ? lines[lineIndex].trim() : '' | |||||
| if (prevLine.trim().endsWith(',') && (nextLine.startsWith('}') || nextLine === '')) | |||||
| lines[lineIndex - 1] = prevLine.replace(/,\s*$/, '') | |||||
| } | |||||
| } | |||||
| // Write back to file | |||||
| const newContent = lines.join('\n') | |||||
| fs.writeFileSync(filePath, newContent) | |||||
| console.log(`💾 Updated file: ${filePath}`) | |||||
| return true | |||||
| } | |||||
| return false | |||||
| } | |||||
| catch (error) { | |||||
| console.error(`Error processing file ${filePath}:`, error.message) | |||||
| return false | |||||
| } | |||||
| } | |||||
| // Add command line argument support | |||||
| const targetFile = process.argv.find(arg => arg.startsWith('--file='))?.split('=')[1] | |||||
| const targetLang = process.argv.find(arg => arg.startsWith('--lang='))?.split('=')[1] | |||||
| const autoRemove = process.argv.includes('--auto-remove') | |||||
| async function main() { | async function main() { | ||||
| const compareKeysCount = async () => { | const compareKeysCount = async () => { | ||||
| const targetKeys = await getKeysFromLanguage(targetLanguage) | |||||
| const languagesKeys = await Promise.all(languages.map(language => getKeysFromLanguage(language))) | |||||
| const allTargetKeys = await getKeysFromLanguage(targetLanguage) | |||||
| // Filter target keys by file if specified | |||||
| const targetKeys = targetFile | |||||
| ? allTargetKeys.filter(key => key.startsWith(targetFile.replace(/[-_](.)/g, (_, c) => c.toUpperCase()))) | |||||
| : allTargetKeys | |||||
| // Filter languages by target language if specified | |||||
| const languagesToProcess = targetLang ? [targetLang] : languages | |||||
| const allLanguagesKeys = await Promise.all(languagesToProcess.map(language => getKeysFromLanguage(language))) | |||||
| // Filter language keys by file if specified | |||||
| const languagesKeys = targetFile | |||||
| ? allLanguagesKeys.map(keys => keys.filter(key => key.startsWith(targetFile.replace(/[-_](.)/g, (_, c) => c.toUpperCase())))) | |||||
| : allLanguagesKeys | |||||
| const keysCount = languagesKeys.map(keys => keys.length) | const keysCount = languagesKeys.map(keys => keys.length) | ||||
| const targetKeysCount = targetKeys.length | const targetKeysCount = targetKeys.length | ||||
| const comparison = languages.reduce((result, language, index) => { | |||||
| const comparison = languagesToProcess.reduce((result, language, index) => { | |||||
| const languageKeysCount = keysCount[index] | const languageKeysCount = keysCount[index] | ||||
| const difference = targetKeysCount - languageKeysCount | const difference = targetKeysCount - languageKeysCount | ||||
| result[language] = difference | result[language] = difference | ||||
| console.log(comparison) | console.log(comparison) | ||||
| // Print missing keys | |||||
| languages.forEach((language, index) => { | |||||
| const missingKeys = targetKeys.filter(key => !languagesKeys[index].includes(key)) | |||||
| // Print missing keys and extra keys | |||||
| for (let index = 0; index < languagesToProcess.length; index++) { | |||||
| const language = languagesToProcess[index] | |||||
| const languageKeys = languagesKeys[index] | |||||
| const missingKeys = targetKeys.filter(key => !languageKeys.includes(key)) | |||||
| const extraKeys = languageKeys.filter(key => !targetKeys.includes(key)) | |||||
| console.log(`Missing keys in ${language}:`, missingKeys) | console.log(`Missing keys in ${language}:`, missingKeys) | ||||
| }) | |||||
| // Show extra keys only when there are extra keys (negative difference) | |||||
| if (extraKeys.length > 0) { | |||||
| console.log(`Extra keys in ${language} (not in ${targetLanguage}):`, extraKeys) | |||||
| // Auto-remove extra keys if flag is set | |||||
| if (autoRemove) { | |||||
| console.log(`\n🤖 Auto-removing extra keys from ${language}...`) | |||||
| // Get all translation files | |||||
| const i18nFolder = path.resolve(__dirname, '../i18n', language) | |||||
| const files = fs.readdirSync(i18nFolder) | |||||
| .filter(file => /\.ts$/.test(file)) | |||||
| .map(file => file.replace(/\.ts$/, '')) | |||||
| .filter(f => !targetFile || f === targetFile) // Filter by target file if specified | |||||
| let totalRemoved = 0 | |||||
| for (const fileName of files) { | |||||
| const removed = await removeExtraKeysFromFile(language, fileName, extraKeys) | |||||
| if (removed) totalRemoved++ | |||||
| } | |||||
| console.log(`✅ Auto-removal completed for ${language}. Modified ${totalRemoved} files.`) | |||||
| } | |||||
| } | |||||
| } | |||||
| } | } | ||||
| console.log('🚀 Starting check-i18n script...') | |||||
| if (targetFile) | |||||
| console.log(`📁 Checking file: ${targetFile}`) | |||||
| if (targetLang) | |||||
| console.log(`🌍 Checking language: ${targetLang}`) | |||||
| if (autoRemove) | |||||
| console.log('🤖 Auto-remove mode: ENABLED') | |||||
| compareKeysCount() | compareKeysCount() | ||||
| } | } | ||||
| table: { | table: { | ||||
| header: { | header: { | ||||
| question: '提问', | question: '提问', | ||||
| match: '匹配', | |||||
| response: '回复', | |||||
| answer: '答案', | answer: '答案', | ||||
| createdAt: '创建时间', | createdAt: '创建时间', | ||||
| hits: '命中次数', | hits: '命中次数', | ||||
| noHitHistory: '没有命中历史', | noHitHistory: '没有命中历史', | ||||
| }, | }, | ||||
| hitHistoryTable: { | hitHistoryTable: { | ||||
| question: '问题', | |||||
| query: '提问', | query: '提问', | ||||
| match: '匹配', | match: '匹配', | ||||
| response: '回复', | response: '回复', |
| noDataLine1: '在左侧描述您的用例,', | noDataLine1: '在左侧描述您的用例,', | ||||
| noDataLine2: '编排预览将在此处显示。', | noDataLine2: '编排预览将在此处显示。', | ||||
| apply: '应用', | apply: '应用', | ||||
| noData: '在左侧描述您的用例,编排预览将在此处显示。', | |||||
| loading: '为您编排应用程序中…', | loading: '为您编排应用程序中…', | ||||
| overwriteTitle: '覆盖现有配置?', | overwriteTitle: '覆盖现有配置?', | ||||
| overwriteMessage: '应用此提示将覆盖现有配置。', | overwriteMessage: '应用此提示将覆盖现有配置。', |
| learnMore: '了解更多', | learnMore: '了解更多', | ||||
| startFromBlank: '创建空白应用', | startFromBlank: '创建空白应用', | ||||
| startFromTemplate: '从应用模版创建', | startFromTemplate: '从应用模版创建', | ||||
| captionAppType: '想要哪种应用类型?', | |||||
| foundResult: '{{count}} 个结果', | foundResult: '{{count}} 个结果', | ||||
| foundResults: '{{count}} 个结果', | foundResults: '{{count}} 个结果', | ||||
| noAppsFound: '未找到应用', | noAppsFound: '未找到应用', | ||||
| chatbotUserDescription: '通过简单的配置快速搭建一个基于 LLM 的对话机器人。支持切换为 Chatflow 编排。', | chatbotUserDescription: '通过简单的配置快速搭建一个基于 LLM 的对话机器人。支持切换为 Chatflow 编排。', | ||||
| completionShortDescription: '用于文本生成任务的 AI 助手', | completionShortDescription: '用于文本生成任务的 AI 助手', | ||||
| completionUserDescription: '通过简单的配置快速搭建一个面向文本生成类任务的 AI 助手。', | completionUserDescription: '通过简单的配置快速搭建一个面向文本生成类任务的 AI 助手。', | ||||
| completionWarning: '该类型不久后将不再支持创建', | |||||
| agentShortDescription: '具备推理与自主工具调用的智能助手', | agentShortDescription: '具备推理与自主工具调用的智能助手', | ||||
| agentUserDescription: '能够迭代式的规划推理、自主工具调用,直至完成任务目标的智能助手。', | agentUserDescription: '能够迭代式的规划推理、自主工具调用,直至完成任务目标的智能助手。', | ||||
| workflowShortDescription: '面向单轮自动化任务的编排工作流', | workflowShortDescription: '面向单轮自动化任务的编排工作流', |
| activated: '现在登录', | activated: '现在登录', | ||||
| adminInitPassword: '管理员初始化密码', | adminInitPassword: '管理员初始化密码', | ||||
| validate: '验证', | validate: '验证', | ||||
| sso: '使用 SSO 继续', | |||||
| checkCode: { | checkCode: { | ||||
| checkYourEmail: '验证您的电子邮件', | checkYourEmail: '验证您的电子邮件', | ||||
| tips: '验证码已经发送到您的邮箱 <strong>{{email}}</strong>', | tips: '验证码已经发送到您的邮箱 <strong>{{email}}</strong>', |
| now: '此刻', | now: '此刻', | ||||
| ok: '确定', | ok: '确定', | ||||
| cancel: '取消', | cancel: '取消', | ||||
| pickDate: '选择日期', | |||||
| }, | }, | ||||
| title: { | title: { | ||||
| pickTime: '选择时间', | pickTime: '选择时间', |
| startRun: '开始运行', | startRun: '开始运行', | ||||
| running: '运行中', | running: '运行中', | ||||
| testRunIteration: '测试运行迭代', | testRunIteration: '测试运行迭代', | ||||
| testRunLoop: '测试运行循环', | |||||
| back: '返回', | back: '返回', | ||||
| iteration: '迭代', | iteration: '迭代', | ||||
| loop: '循环', | loop: '循环', |
| table: { | table: { | ||||
| header: { | header: { | ||||
| question: '提問', | question: '提問', | ||||
| match: '匹配', | |||||
| response: '回覆', | |||||
| answer: '答案', | answer: '答案', | ||||
| createdAt: '建立時間', | createdAt: '建立時間', | ||||
| hits: '命中次數', | hits: '命中次數', | ||||
| noHitHistory: '沒有命中歷史', | noHitHistory: '沒有命中歷史', | ||||
| }, | }, | ||||
| hitHistoryTable: { | hitHistoryTable: { | ||||
| question: '問題', | |||||
| query: '提問', | query: '提問', | ||||
| match: '匹配', | match: '匹配', | ||||
| response: '回覆', | response: '回覆', |
| newApp: { | newApp: { | ||||
| startFromBlank: '建立空白應用', | startFromBlank: '建立空白應用', | ||||
| startFromTemplate: '從應用模版建立', | startFromTemplate: '從應用模版建立', | ||||
| captionAppType: '想要哪種應用類型?', | |||||
| chatbotDescription: '使用大型語言模型構建聊天助手', | |||||
| completionDescription: '構建一個根據提示生成高品質文字的應用程式,例如生成文章、摘要、翻譯等。', | |||||
| completionWarning: '該類型不久後將不再支援建立', | |||||
| agentDescription: '構建一個智慧 Agent,可以自主選擇工具來完成任務', | |||||
| workflowDescription: '以工作流的形式編排生成型應用,提供更多的自訂設定。它適合有經驗的使用者。', | |||||
| workflowWarning: '正在進行 Beta 測試', | workflowWarning: '正在進行 Beta 測試', | ||||
| chatbotType: '聊天助手編排方法', | |||||
| basic: '基礎編排', | |||||
| basicTip: '新手適用,可以切換成工作流編排', | |||||
| basicFor: '新手適用', | |||||
| basicDescription: '基本編排允許使用簡單的設定編排聊天機器人應用程式,而無需修改內建提示。它適合初學者。', | |||||
| advanced: '工作流編排', | |||||
| advancedFor: '進階使用者適用', | |||||
| advancedDescription: '工作流編排以工作流的形式編排聊天機器人,提供自訂設定,包括編輯內建提示的能力。它適合有經驗的使用者。', | |||||
| captionName: '應用名稱 & 圖示', | captionName: '應用名稱 & 圖示', | ||||
| appNamePlaceholder: '給你的應用起個名字', | appNamePlaceholder: '給你的應用起個名字', | ||||
| captionDescription: '描述', | captionDescription: '描述', |
| contractOwner: '聯絡團隊管理員', | contractOwner: '聯絡團隊管理員', | ||||
| free: '免費', | free: '免費', | ||||
| startForFree: '免費開始', | startForFree: '免費開始', | ||||
| getStartedWith: '開始使用', | |||||
| contactSales: '聯絡銷售', | contactSales: '聯絡銷售', | ||||
| talkToSales: '聯絡銷售', | talkToSales: '聯絡銷售', | ||||
| modelProviders: '支援的模型提供商', | modelProviders: '支援的模型提供商', | ||||
| teamMembers: '團隊成員', | |||||
| buildApps: '構建應用程式數', | buildApps: '構建應用程式數', | ||||
| vectorSpace: '向量空間', | vectorSpace: '向量空間', | ||||
| vectorSpaceTooltip: '向量空間是 LLMs 理解您的資料所需的長期記憶系統。', | vectorSpaceTooltip: '向量空間是 LLMs 理解您的資料所需的長期記憶系統。', | ||||
| vectorSpaceBillingTooltip: '向量儲存是將知識庫向量化處理後為讓 LLMs 理解資料而使用的長期記憶儲存,1MB 大約能滿足 1.2 million character 的向量化後資料儲存(以 OpenAI Embedding 模型估算,不同模型計算方式有差異)。在向量化過程中,實際的壓縮或尺寸減小取決於內容的複雜性和冗餘性。', | |||||
| documentsUploadQuota: '文件上傳配額', | |||||
| documentProcessingPriority: '文件處理優先順序', | documentProcessingPriority: '文件處理優先順序', | ||||
| documentProcessingPriorityTip: '如需更高的文件處理優先順序,請升級您的套餐', | |||||
| documentProcessingPriorityUpgrade: '以更快的速度、更高的精度處理更多的資料。', | documentProcessingPriorityUpgrade: '以更快的速度、更高的精度處理更多的資料。', | ||||
| priority: { | priority: { | ||||
| 'standard': '標準', | 'standard': '標準', | ||||
| sandbox: { | sandbox: { | ||||
| name: 'Sandbox', | name: 'Sandbox', | ||||
| description: '200 次 GPT 免費試用', | description: '200 次 GPT 免費試用', | ||||
| includesTitle: '包括:', | |||||
| for: '核心功能免費試用', | for: '核心功能免費試用', | ||||
| }, | }, | ||||
| professional: { | professional: { | ||||
| name: 'Professional', | name: 'Professional', | ||||
| description: '讓個人和小團隊能夠以經濟實惠的方式釋放更多能力。', | description: '讓個人和小團隊能夠以經濟實惠的方式釋放更多能力。', | ||||
| includesTitle: 'Sandbox 計劃中的一切,加上:', | |||||
| for: '適合獨立開發者/小型團隊', | for: '適合獨立開發者/小型團隊', | ||||
| }, | }, | ||||
| team: { | team: { | ||||
| name: 'Team', | name: 'Team', | ||||
| description: '協作無限制並享受頂級效能。', | description: '協作無限制並享受頂級效能。', | ||||
| includesTitle: 'Professional 計劃中的一切,加上:', | |||||
| for: '適用於中型團隊', | for: '適用於中型團隊', | ||||
| }, | }, | ||||
| enterprise: { | enterprise: { | ||||
| description: '獲得大規模關鍵任務系統的完整功能和支援。', | description: '獲得大規模關鍵任務系統的完整功能和支援。', | ||||
| includesTitle: 'Team 計劃中的一切,加上:', | includesTitle: 'Team 計劃中的一切,加上:', | ||||
| features: { | features: { | ||||
| 1: '商業許可證授權', | |||||
| 6: '先進安全與控制', | |||||
| 3: '多個工作區及企業管理', | |||||
| 2: '專屬企業功能', | |||||
| 4: '單一登入', | |||||
| 8: '專業技術支援', | |||||
| 0: '企業級可擴展部署解決方案', | |||||
| 7: 'Dify 官方的更新和維護', | |||||
| 5: '由 Dify 合作夥伴協商的服務水平協議', | |||||
| }, | }, | ||||
| price: '自訂', | price: '自訂', | ||||
| btnText: '聯繫銷售', | btnText: '聯繫銷售', | ||||
| }, | }, | ||||
| community: { | community: { | ||||
| features: { | features: { | ||||
| 0: '所有核心功能均在公共存儲庫下釋出', | |||||
| 2: '遵循 Dify 開源許可證', | |||||
| 1: '單一工作區域', | |||||
| }, | }, | ||||
| includesTitle: '免費功能:', | includesTitle: '免費功能:', | ||||
| btnText: '開始使用社區', | btnText: '開始使用社區', | ||||
| }, | }, | ||||
| premium: { | premium: { | ||||
| features: { | features: { | ||||
| 2: '網頁應用程序標誌及品牌自定義', | |||||
| 0: '各種雲端服務提供商的自我管理可靠性', | |||||
| 1: '單一工作區域', | |||||
| 3: '優先電子郵件及聊天支持', | |||||
| }, | }, | ||||
| for: '適用於中型組織和團隊', | for: '適用於中型組織和團隊', | ||||
| comingSoon: '微軟 Azure 與 Google Cloud 支持即將推出', | comingSoon: '微軟 Azure 與 Google Cloud 支持即將推出', | ||||
| fullSolution: '升級您的套餐以獲得更多空間。', | fullSolution: '升級您的套餐以獲得更多空間。', | ||||
| }, | }, | ||||
| apps: { | apps: { | ||||
| fullTipLine1: '升級您的套餐以', | |||||
| fullTipLine2: '構建更多的程式。', | |||||
| fullTip1: '升級以創建更多應用程序', | fullTip1: '升級以創建更多應用程序', | ||||
| fullTip2des: '建議清除不活躍的應用程式以釋放使用空間,或聯繫我們。', | fullTip2des: '建議清除不活躍的應用程式以釋放使用空間,或聯繫我們。', | ||||
| contactUs: '聯繫我們', | contactUs: '聯繫我們', |
| showAppLength: '顯示 {{length}} 個應用', | showAppLength: '顯示 {{length}} 個應用', | ||||
| delete: '刪除帳戶', | delete: '刪除帳戶', | ||||
| deleteTip: '刪除您的帳戶將永久刪除您的所有資料並且無法恢復。', | deleteTip: '刪除您的帳戶將永久刪除您的所有資料並且無法恢復。', | ||||
| deleteConfirmTip: '請將以下內容從您的註冊電子郵件發送至 ', | |||||
| account: '帳戶', | account: '帳戶', | ||||
| myAccount: '我的帳戶', | myAccount: '我的帳戶', | ||||
| studio: '工作室', | studio: '工作室', |
| const translation = { | const translation = { | ||||
| steps: { | steps: { | ||||
| header: { | header: { | ||||
| creation: '建立知識庫', | |||||
| update: '上傳檔案', | |||||
| fallbackRoute: '知識', | fallbackRoute: '知識', | ||||
| }, | }, | ||||
| one: '選擇資料來源', | one: '選擇資料來源', |
| keywords: '關鍵詞', | keywords: '關鍵詞', | ||||
| addKeyWord: '新增關鍵詞', | addKeyWord: '新增關鍵詞', | ||||
| keywordError: '關鍵詞最大長度為 20', | keywordError: '關鍵詞最大長度為 20', | ||||
| characters: '字元', | |||||
| hitCount: '召回次數', | hitCount: '召回次數', | ||||
| vectorHash: '向量雜湊:', | vectorHash: '向量雜湊:', | ||||
| questionPlaceholder: '在這裡新增問題', | questionPlaceholder: '在這裡新增問題', |
| title: '召回測試', | title: '召回測試', | ||||
| desc: '基於給定的查詢文字測試知識庫的召回效果。', | desc: '基於給定的查詢文字測試知識庫的召回效果。', | ||||
| dateTimeFormat: 'YYYY-MM-DD HH:mm', | dateTimeFormat: 'YYYY-MM-DD HH:mm', | ||||
| recents: '最近查詢', | |||||
| table: { | table: { | ||||
| header: { | header: { | ||||
| source: '資料來源', | source: '資料來源', |
| activated: '現在登入', | activated: '現在登入', | ||||
| adminInitPassword: '管理員初始化密碼', | adminInitPassword: '管理員初始化密碼', | ||||
| validate: '驗證', | validate: '驗證', | ||||
| sso: '繼續使用 SSO', | |||||
| checkCode: { | checkCode: { | ||||
| verify: '驗證', | verify: '驗證', | ||||
| resend: '發送', | resend: '發送', |
| keyTooltip: 'HTTP 頭部名稱,如果你不知道是什麼,可以將其保留為 Authorization 或設定為自定義值', | keyTooltip: 'HTTP 頭部名稱,如果你不知道是什麼,可以將其保留為 Authorization 或設定為自定義值', | ||||
| types: { | types: { | ||||
| none: '無', | none: '無', | ||||
| api_key: 'API Key', | |||||
| apiKeyPlaceholder: 'HTTP 頭部名稱,用於傳遞 API Key', | apiKeyPlaceholder: 'HTTP 頭部名稱,用於傳遞 API Key', | ||||
| apiValuePlaceholder: '輸入 API Key', | apiValuePlaceholder: '輸入 API Key', | ||||
| api_key_query: '查詢參數', | api_key_query: '查詢參數', |
| loadMore: '載入更多工作流', | loadMore: '載入更多工作流', | ||||
| noHistory: '無歷史記錄', | noHistory: '無歷史記錄', | ||||
| publishUpdate: '發布更新', | publishUpdate: '發布更新', | ||||
| referenceVar: '參考變量', | |||||
| exportSVG: '匯出為 SVG', | exportSVG: '匯出為 SVG', | ||||
| exportPNG: '匯出為 PNG', | exportPNG: '匯出為 PNG', | ||||
| noExist: '沒有這個變數', | |||||
| versionHistory: '版本歷史', | versionHistory: '版本歷史', | ||||
| exitVersions: '退出版本', | exitVersions: '退出版本', | ||||
| exportImage: '匯出圖像', | exportImage: '匯出圖像', | ||||
| }, | }, | ||||
| select: '選擇', | select: '選擇', | ||||
| addSubVariable: '子變數', | addSubVariable: '子變數', | ||||
| condition: '條件', | |||||
| }, | }, | ||||
| variableAssigner: { | variableAssigner: { | ||||
| title: '變量賦值', | title: '變量賦值', |