mirror of https://github.com/langgenius/dify.git
add script to for translations
This commit is contained in:
parent
bc0495d8f9
commit
6c781571c8
|
|
@ -0,0 +1,297 @@
|
|||
/**
|
||||
* This script compares i18n keys between current branch (flat JSON) and main branch (nested TS).
|
||||
*
|
||||
* It checks:
|
||||
* 1. All keys from main branch exist in current branch
|
||||
* 2. Values for existing keys haven't changed
|
||||
* 3. Lists newly added keys and values
|
||||
*
|
||||
* Usage: npx tsx scripts/analyze-i18n-diff.ts
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process'
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const I18N_DIR = path.join(__dirname, '../i18n/en-US')
|
||||
const LOCALE = 'en-US'
|
||||
|
||||
type TranslationValue = string | string[]
|
||||
|
||||
type FlatTranslation = {
|
||||
[key: string]: TranslationValue
|
||||
}
|
||||
|
||||
type NestedTranslation = {
|
||||
[key: string]: string | string[] | NestedTranslation
|
||||
}
|
||||
|
||||
type AnalysisResult = {
|
||||
file: string
|
||||
missingKeys: string[]
|
||||
changedValues: { key: string, oldValue: TranslationValue, newValue: TranslationValue }[]
|
||||
newKeys: { key: string, value: TranslationValue }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten nested object to dot-separated keys
|
||||
* Arrays are preserved as-is (not split into .0, .1, etc.)
|
||||
*/
|
||||
function flattenObject(obj: NestedTranslation, prefix = ''): FlatTranslation {
|
||||
const result: FlatTranslation = {}
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key
|
||||
|
||||
if (typeof value === 'string') {
|
||||
result[newKey] = value
|
||||
}
|
||||
else if (Array.isArray(value)) {
|
||||
// Preserve arrays as-is
|
||||
result[newKey] = value as string[]
|
||||
}
|
||||
else if (typeof value === 'object' && value !== null) {
|
||||
Object.assign(result, flattenObject(value as NestedTranslation, newKey))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two translation values (string or array)
|
||||
*/
|
||||
function valuesEqual(a: TranslationValue, b: TranslationValue): boolean {
|
||||
if (typeof a === 'string' && typeof b === 'string') {
|
||||
return a === b
|
||||
}
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length)
|
||||
return false
|
||||
return a.every((item, index) => item === b[index])
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Format value for display
|
||||
*/
|
||||
function formatValue(value: TranslationValue): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(v => `"${v}"`).join(', ')}]`
|
||||
}
|
||||
return `"${value}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse TS file content to extract the translation object
|
||||
*/
|
||||
function parseTsContent(content: string): NestedTranslation {
|
||||
// Remove 'const translation = ' and 'export default translation'
|
||||
let cleaned = content
|
||||
.replace(/const\s+translation\s*=\s*/, '')
|
||||
.replace(/export\s+default\s+translation\s*(?:;\s*)?$/, '')
|
||||
.trim()
|
||||
|
||||
// Remove trailing semicolon if present
|
||||
if (cleaned.endsWith(';'))
|
||||
cleaned = cleaned.slice(0, -1)
|
||||
|
||||
// Use Function constructor to safely evaluate the object literal
|
||||
// This handles JS object syntax like unquoted keys, template literals, etc.
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func, sonarjs/code-eval
|
||||
const fn = new Function(`return (${cleaned})`)
|
||||
return fn() as NestedTranslation
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to parse TS content:', e)
|
||||
console.error('Content preview:', cleaned.slice(0, 200))
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file content from main branch
|
||||
*/
|
||||
function getMainBranchFile(filePath: string): string | null {
|
||||
try {
|
||||
const relativePath = `web/i18n/${LOCALE}/${filePath}`
|
||||
// eslint-disable-next-line sonarjs/os-command
|
||||
return execSync(`git show main:${relativePath}`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of translation files
|
||||
*/
|
||||
function getTranslationFiles(): string[] {
|
||||
const files = fs.readdirSync(I18N_DIR)
|
||||
return files.filter(f => f.endsWith('.json')).map(f => f.replace('.json', ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a single translation file
|
||||
*/
|
||||
function analyzeFile(baseName: string): AnalysisResult {
|
||||
const result: AnalysisResult = {
|
||||
file: baseName,
|
||||
missingKeys: [],
|
||||
changedValues: [],
|
||||
newKeys: [],
|
||||
}
|
||||
|
||||
// Read current branch JSON file
|
||||
const jsonPath = path.join(I18N_DIR, `${baseName}.json`)
|
||||
const currentContent = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')) as Record<string, TranslationValue>
|
||||
|
||||
// Read main branch TS file
|
||||
const tsContent = getMainBranchFile(`${baseName}.ts`)
|
||||
if (!tsContent) {
|
||||
// New file, all keys are new
|
||||
for (const [key, value] of Object.entries(currentContent)) {
|
||||
result.newKeys.push({ key, value })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Parse and flatten the TS content
|
||||
const nestedObj = parseTsContent(tsContent)
|
||||
const mainFlat = flattenObject(nestedObj)
|
||||
|
||||
// Check for missing keys (in main but not in current)
|
||||
for (const key of Object.keys(mainFlat)) {
|
||||
if (!(key in currentContent)) {
|
||||
result.missingKeys.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for changed values
|
||||
for (const [key, oldValue] of Object.entries(mainFlat)) {
|
||||
if (key in currentContent && !valuesEqual(currentContent[key], oldValue)) {
|
||||
result.changedValues.push({
|
||||
key,
|
||||
oldValue,
|
||||
newValue: currentContent[key],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Find new keys (in current but not in main)
|
||||
for (const [key, value] of Object.entries(currentContent)) {
|
||||
if (!(key in mainFlat)) {
|
||||
result.newKeys.push({ key, value })
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Main analysis function
|
||||
*/
|
||||
function main() {
|
||||
console.log('🔍 Analyzing i18n differences between current branch (flat JSON) and main branch (nested TS)...\n')
|
||||
|
||||
const files = getTranslationFiles()
|
||||
const allResults: AnalysisResult[] = []
|
||||
|
||||
let totalMissing = 0
|
||||
let totalChanged = 0
|
||||
let totalNew = 0
|
||||
|
||||
for (const file of files) {
|
||||
const result = analyzeFile(file)
|
||||
allResults.push(result)
|
||||
|
||||
totalMissing += result.missingKeys.length
|
||||
totalChanged += result.changedValues.length
|
||||
totalNew += result.newKeys.length
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('📊 Summary')
|
||||
console.log('='.repeat(60))
|
||||
console.log(`Total files analyzed: ${files.length}`)
|
||||
console.log(`Missing keys (in main but not in current): ${totalMissing}`)
|
||||
console.log(`Changed values: ${totalChanged}`)
|
||||
console.log(`New keys: ${totalNew}`)
|
||||
console.log()
|
||||
|
||||
// Detailed report
|
||||
if (totalMissing > 0) {
|
||||
console.log('\n❌ MISSING KEYS (exist in main but not in current branch)')
|
||||
console.log('='.repeat(60))
|
||||
for (const result of allResults) {
|
||||
if (result.missingKeys.length > 0) {
|
||||
console.log(`\n📁 ${result.file}:`)
|
||||
for (const key of result.missingKeys) {
|
||||
console.log(` - ${key}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalChanged > 0) {
|
||||
console.log('\n⚠️ CHANGED VALUES (same key, different value)')
|
||||
console.log('='.repeat(60))
|
||||
for (const result of allResults) {
|
||||
if (result.changedValues.length > 0) {
|
||||
console.log(`\n📁 ${result.file}:`)
|
||||
for (const { key, oldValue, newValue } of result.changedValues) {
|
||||
console.log(` Key: ${key}`)
|
||||
console.log(` Old: ${formatValue(oldValue)}`)
|
||||
console.log(` New: ${formatValue(newValue)}`)
|
||||
console.log()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalNew > 0) {
|
||||
console.log('\n✨ NEW KEYS (exist in current branch but not in main)')
|
||||
console.log('='.repeat(60))
|
||||
for (const result of allResults) {
|
||||
if (result.newKeys.length > 0) {
|
||||
console.log(`\n📁 ${result.file}:`)
|
||||
for (const { key, value } of result.newKeys) {
|
||||
console.log(` + ${key}: ${formatValue(value)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write detailed report to JSON file
|
||||
const reportPath = path.join(__dirname, '../i18n-analysis-report.json')
|
||||
fs.writeFileSync(reportPath, JSON.stringify({
|
||||
summary: {
|
||||
totalFiles: files.length,
|
||||
missingKeys: totalMissing,
|
||||
changedValues: totalChanged,
|
||||
newKeys: totalNew,
|
||||
},
|
||||
details: allResults,
|
||||
}, null, 2))
|
||||
|
||||
console.log(`\n📄 Detailed report written to: i18n-analysis-report.json`)
|
||||
|
||||
// Exit with error code if there are missing keys
|
||||
if (totalMissing > 0) {
|
||||
console.log('\n⚠️ Warning: Some keys are missing in the current branch!')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('\n✅ All keys from main branch exist in current branch.')
|
||||
}
|
||||
|
||||
main()
|
||||
Loading…
Reference in New Issue