| @@ -0,0 +1,569 @@ | |||
| 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 | |||
| }) | |||
| }) | |||
| }) | |||
| @@ -58,9 +58,14 @@ async function getKeysFromLanguage(language) { | |||
| const iterateKeys = (obj, prefix = '') => { | |||
| for (const key in obj) { | |||
| 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) | |||
| } | |||
| else { | |||
| // This is a leaf node (string, number, boolean, array, etc.), add it as a key | |||
| nestedKeys.push(nestedKey) | |||
| } | |||
| } | |||
| } | |||
| iterateKeys(translationObj) | |||
| @@ -79,15 +84,176 @@ async function getKeysFromLanguage(language) { | |||
| }) | |||
| } | |||
| 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() { | |||
| 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 targetKeysCount = targetKeys.length | |||
| const comparison = languages.reduce((result, language, index) => { | |||
| const comparison = languagesToProcess.reduce((result, language, index) => { | |||
| const languageKeysCount = keysCount[index] | |||
| const difference = targetKeysCount - languageKeysCount | |||
| result[language] = difference | |||
| @@ -96,13 +262,52 @@ async function main() { | |||
| 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) | |||
| }) | |||
| // 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() | |||
| } | |||
| @@ -9,8 +9,6 @@ const translation = { | |||
| table: { | |||
| header: { | |||
| question: '提问', | |||
| match: '匹配', | |||
| response: '回复', | |||
| answer: '答案', | |||
| createdAt: '创建时间', | |||
| hits: '命中次数', | |||
| @@ -71,7 +69,6 @@ const translation = { | |||
| noHitHistory: '没有命中历史', | |||
| }, | |||
| hitHistoryTable: { | |||
| question: '问题', | |||
| query: '提问', | |||
| match: '匹配', | |||
| response: '回复', | |||
| @@ -254,7 +254,6 @@ const translation = { | |||
| noDataLine1: '在左侧描述您的用例,', | |||
| noDataLine2: '编排预览将在此处显示。', | |||
| apply: '应用', | |||
| noData: '在左侧描述您的用例,编排预览将在此处显示。', | |||
| loading: '为您编排应用程序中…', | |||
| overwriteTitle: '覆盖现有配置?', | |||
| overwriteMessage: '应用此提示将覆盖现有配置。', | |||
| @@ -35,7 +35,6 @@ const translation = { | |||
| learnMore: '了解更多', | |||
| startFromBlank: '创建空白应用', | |||
| startFromTemplate: '从应用模版创建', | |||
| captionAppType: '想要哪种应用类型?', | |||
| foundResult: '{{count}} 个结果', | |||
| foundResults: '{{count}} 个结果', | |||
| noAppsFound: '未找到应用', | |||
| @@ -45,7 +44,6 @@ const translation = { | |||
| chatbotUserDescription: '通过简单的配置快速搭建一个基于 LLM 的对话机器人。支持切换为 Chatflow 编排。', | |||
| completionShortDescription: '用于文本生成任务的 AI 助手', | |||
| completionUserDescription: '通过简单的配置快速搭建一个面向文本生成类任务的 AI 助手。', | |||
| completionWarning: '该类型不久后将不再支持创建', | |||
| agentShortDescription: '具备推理与自主工具调用的智能助手', | |||
| agentUserDescription: '能够迭代式的规划推理、自主工具调用,直至完成任务目标的智能助手。', | |||
| workflowShortDescription: '面向单轮自动化任务的编排工作流', | |||
| @@ -77,7 +77,6 @@ const translation = { | |||
| activated: '现在登录', | |||
| adminInitPassword: '管理员初始化密码', | |||
| validate: '验证', | |||
| sso: '使用 SSO 继续', | |||
| checkCode: { | |||
| checkYourEmail: '验证您的电子邮件', | |||
| tips: '验证码已经发送到您的邮箱 <strong>{{email}}</strong>', | |||
| @@ -26,7 +26,6 @@ const translation = { | |||
| now: '此刻', | |||
| ok: '确定', | |||
| cancel: '取消', | |||
| pickDate: '选择日期', | |||
| }, | |||
| title: { | |||
| pickTime: '选择时间', | |||
| @@ -213,7 +213,6 @@ const translation = { | |||
| startRun: '开始运行', | |||
| running: '运行中', | |||
| testRunIteration: '测试运行迭代', | |||
| testRunLoop: '测试运行循环', | |||
| back: '返回', | |||
| iteration: '迭代', | |||
| loop: '循环', | |||
| @@ -9,8 +9,6 @@ const translation = { | |||
| table: { | |||
| header: { | |||
| question: '提問', | |||
| match: '匹配', | |||
| response: '回覆', | |||
| answer: '答案', | |||
| createdAt: '建立時間', | |||
| hits: '命中次數', | |||
| @@ -71,7 +69,6 @@ const translation = { | |||
| noHitHistory: '沒有命中歷史', | |||
| }, | |||
| hitHistoryTable: { | |||
| question: '問題', | |||
| query: '提問', | |||
| match: '匹配', | |||
| response: '回覆', | |||
| @@ -26,21 +26,7 @@ const translation = { | |||
| newApp: { | |||
| startFromBlank: '建立空白應用', | |||
| startFromTemplate: '從應用模版建立', | |||
| captionAppType: '想要哪種應用類型?', | |||
| chatbotDescription: '使用大型語言模型構建聊天助手', | |||
| completionDescription: '構建一個根據提示生成高品質文字的應用程式,例如生成文章、摘要、翻譯等。', | |||
| completionWarning: '該類型不久後將不再支援建立', | |||
| agentDescription: '構建一個智慧 Agent,可以自主選擇工具來完成任務', | |||
| workflowDescription: '以工作流的形式編排生成型應用,提供更多的自訂設定。它適合有經驗的使用者。', | |||
| workflowWarning: '正在進行 Beta 測試', | |||
| chatbotType: '聊天助手編排方法', | |||
| basic: '基礎編排', | |||
| basicTip: '新手適用,可以切換成工作流編排', | |||
| basicFor: '新手適用', | |||
| basicDescription: '基本編排允許使用簡單的設定編排聊天機器人應用程式,而無需修改內建提示。它適合初學者。', | |||
| advanced: '工作流編排', | |||
| advancedFor: '進階使用者適用', | |||
| advancedDescription: '工作流編排以工作流的形式編排聊天機器人,提供自訂設定,包括編輯內建提示的能力。它適合有經驗的使用者。', | |||
| captionName: '應用名稱 & 圖示', | |||
| appNamePlaceholder: '給你的應用起個名字', | |||
| captionDescription: '描述', | |||
| @@ -23,18 +23,13 @@ const translation = { | |||
| contractOwner: '聯絡團隊管理員', | |||
| free: '免費', | |||
| startForFree: '免費開始', | |||
| getStartedWith: '開始使用', | |||
| contactSales: '聯絡銷售', | |||
| talkToSales: '聯絡銷售', | |||
| modelProviders: '支援的模型提供商', | |||
| teamMembers: '團隊成員', | |||
| buildApps: '構建應用程式數', | |||
| vectorSpace: '向量空間', | |||
| vectorSpaceTooltip: '向量空間是 LLMs 理解您的資料所需的長期記憶系統。', | |||
| vectorSpaceBillingTooltip: '向量儲存是將知識庫向量化處理後為讓 LLMs 理解資料而使用的長期記憶儲存,1MB 大約能滿足 1.2 million character 的向量化後資料儲存(以 OpenAI Embedding 模型估算,不同模型計算方式有差異)。在向量化過程中,實際的壓縮或尺寸減小取決於內容的複雜性和冗餘性。', | |||
| documentsUploadQuota: '文件上傳配額', | |||
| documentProcessingPriority: '文件處理優先順序', | |||
| documentProcessingPriorityTip: '如需更高的文件處理優先順序,請升級您的套餐', | |||
| documentProcessingPriorityUpgrade: '以更快的速度、更高的精度處理更多的資料。', | |||
| priority: { | |||
| 'standard': '標準', | |||
| @@ -103,19 +98,16 @@ const translation = { | |||
| sandbox: { | |||
| name: 'Sandbox', | |||
| description: '200 次 GPT 免費試用', | |||
| includesTitle: '包括:', | |||
| for: '核心功能免費試用', | |||
| }, | |||
| professional: { | |||
| name: 'Professional', | |||
| description: '讓個人和小團隊能夠以經濟實惠的方式釋放更多能力。', | |||
| includesTitle: 'Sandbox 計劃中的一切,加上:', | |||
| for: '適合獨立開發者/小型團隊', | |||
| }, | |||
| team: { | |||
| name: 'Team', | |||
| description: '協作無限制並享受頂級效能。', | |||
| includesTitle: 'Professional 計劃中的一切,加上:', | |||
| for: '適用於中型團隊', | |||
| }, | |||
| enterprise: { | |||
| @@ -123,15 +115,6 @@ const translation = { | |||
| description: '獲得大規模關鍵任務系統的完整功能和支援。', | |||
| includesTitle: 'Team 計劃中的一切,加上:', | |||
| features: { | |||
| 1: '商業許可證授權', | |||
| 6: '先進安全與控制', | |||
| 3: '多個工作區及企業管理', | |||
| 2: '專屬企業功能', | |||
| 4: '單一登入', | |||
| 8: '專業技術支援', | |||
| 0: '企業級可擴展部署解決方案', | |||
| 7: 'Dify 官方的更新和維護', | |||
| 5: '由 Dify 合作夥伴協商的服務水平協議', | |||
| }, | |||
| price: '自訂', | |||
| btnText: '聯繫銷售', | |||
| @@ -140,9 +123,6 @@ const translation = { | |||
| }, | |||
| community: { | |||
| features: { | |||
| 0: '所有核心功能均在公共存儲庫下釋出', | |||
| 2: '遵循 Dify 開源許可證', | |||
| 1: '單一工作區域', | |||
| }, | |||
| includesTitle: '免費功能:', | |||
| btnText: '開始使用社區', | |||
| @@ -153,10 +133,6 @@ const translation = { | |||
| }, | |||
| premium: { | |||
| features: { | |||
| 2: '網頁應用程序標誌及品牌自定義', | |||
| 0: '各種雲端服務提供商的自我管理可靠性', | |||
| 1: '單一工作區域', | |||
| 3: '優先電子郵件及聊天支持', | |||
| }, | |||
| for: '適用於中型組織和團隊', | |||
| comingSoon: '微軟 Azure 與 Google Cloud 支持即將推出', | |||
| @@ -173,8 +149,6 @@ const translation = { | |||
| fullSolution: '升級您的套餐以獲得更多空間。', | |||
| }, | |||
| apps: { | |||
| fullTipLine1: '升級您的套餐以', | |||
| fullTipLine2: '構建更多的程式。', | |||
| fullTip1: '升級以創建更多應用程序', | |||
| fullTip2des: '建議清除不活躍的應用程式以釋放使用空間,或聯繫我們。', | |||
| contactUs: '聯繫我們', | |||
| @@ -197,7 +197,6 @@ const translation = { | |||
| showAppLength: '顯示 {{length}} 個應用', | |||
| delete: '刪除帳戶', | |||
| deleteTip: '刪除您的帳戶將永久刪除您的所有資料並且無法恢復。', | |||
| deleteConfirmTip: '請將以下內容從您的註冊電子郵件發送至 ', | |||
| account: '帳戶', | |||
| myAccount: '我的帳戶', | |||
| studio: '工作室', | |||
| @@ -1,8 +1,6 @@ | |||
| const translation = { | |||
| steps: { | |||
| header: { | |||
| creation: '建立知識庫', | |||
| update: '上傳檔案', | |||
| fallbackRoute: '知識', | |||
| }, | |||
| one: '選擇資料來源', | |||
| @@ -341,7 +341,6 @@ const translation = { | |||
| keywords: '關鍵詞', | |||
| addKeyWord: '新增關鍵詞', | |||
| keywordError: '關鍵詞最大長度為 20', | |||
| characters: '字元', | |||
| hitCount: '召回次數', | |||
| vectorHash: '向量雜湊:', | |||
| questionPlaceholder: '在這裡新增問題', | |||
| @@ -2,7 +2,6 @@ const translation = { | |||
| title: '召回測試', | |||
| desc: '基於給定的查詢文字測試知識庫的召回效果。', | |||
| dateTimeFormat: 'YYYY-MM-DD HH:mm', | |||
| recents: '最近查詢', | |||
| table: { | |||
| header: { | |||
| source: '資料來源', | |||
| @@ -70,7 +70,6 @@ const translation = { | |||
| activated: '現在登入', | |||
| adminInitPassword: '管理員初始化密碼', | |||
| validate: '驗證', | |||
| sso: '繼續使用 SSO', | |||
| checkCode: { | |||
| verify: '驗證', | |||
| resend: '發送', | |||
| @@ -54,7 +54,6 @@ const translation = { | |||
| keyTooltip: 'HTTP 頭部名稱,如果你不知道是什麼,可以將其保留為 Authorization 或設定為自定義值', | |||
| types: { | |||
| none: '無', | |||
| api_key: 'API Key', | |||
| apiKeyPlaceholder: 'HTTP 頭部名稱,用於傳遞 API Key', | |||
| apiValuePlaceholder: '輸入 API Key', | |||
| api_key_query: '查詢參數', | |||
| @@ -107,10 +107,8 @@ const translation = { | |||
| loadMore: '載入更多工作流', | |||
| noHistory: '無歷史記錄', | |||
| publishUpdate: '發布更新', | |||
| referenceVar: '參考變量', | |||
| exportSVG: '匯出為 SVG', | |||
| exportPNG: '匯出為 PNG', | |||
| noExist: '沒有這個變數', | |||
| versionHistory: '版本歷史', | |||
| exitVersions: '退出版本', | |||
| exportImage: '匯出圖像', | |||
| @@ -610,7 +608,6 @@ const translation = { | |||
| }, | |||
| select: '選擇', | |||
| addSubVariable: '子變數', | |||
| condition: '條件', | |||
| }, | |||
| variableAssigner: { | |||
| title: '變量賦值', | |||