Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/components/graph/GraphCanvas.vue
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ import { useCopy } from '@/composables/useCopy'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { usePaste } from '@/composables/usePaste'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { i18n, t } from '@/i18n'
import { mergeCustomNodesI18n, t } from '@/i18n'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
Expand Down Expand Up @@ -391,9 +391,7 @@ useEventListener(
const loadCustomNodesI18n = async () => {
try {
const i18nData = await api.getCustomNodesI18n()
Object.entries(i18nData).forEach(([locale, message]) => {
i18n.global.mergeLocaleMessage(locale, message)
})
mergeCustomNodesI18n(i18nData)
} catch (error) {
console.error('Failed to load custom nodes i18n', error)
}
Expand Down
200 changes: 200 additions & 0 deletions src/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n')

// Mock the JSON imports before importing i18n module
vi.mock('./locales/en/main.json', () => ({ default: { welcome: 'Welcome' } }))
vi.mock('./locales/en/nodeDefs.json', () => ({
default: { testNode: 'Test Node' }
}))
vi.mock('./locales/en/commands.json', () => ({
default: { save: 'Save' }
}))
vi.mock('./locales/en/settings.json', () => ({
default: { theme: 'Theme' }
}))

// Mock lazy-loaded locales
vi.mock('./locales/zh/main.json', () => ({ default: { welcome: '欢迎' } }))
vi.mock('./locales/zh/nodeDefs.json', () => ({
default: { testNode: '测试节点' }
}))
vi.mock('./locales/zh/commands.json', () => ({ default: { save: '保存' } }))
vi.mock('./locales/zh/settings.json', () => ({ default: { theme: '主题' } }))

describe('i18n', () => {
beforeEach(async () => {
vi.resetModules()
})

describe('mergeCustomNodesI18n', () => {
it('should immediately merge data for already loaded locales (en)', async () => {
// English is pre-loaded, so merge should work immediately
mergeCustomNodesI18n({
en: {
customNode: {
title: 'Custom Node Title'
}
}
})

// Verify the custom node data was merged
const messages = i18n.global.getLocaleMessage('en') as Record<
string,
unknown
>
expect(messages.customNode).toEqual({ title: 'Custom Node Title' })
})

it('should store data for not-yet-loaded locales', async () => {
const { i18n, mergeCustomNodesI18n } = await import('./i18n')

// Chinese is not pre-loaded, data should be stored but not merged yet
mergeCustomNodesI18n({
zh: {
customNode: {
title: '自定义节点标题'
}
}
})

// zh locale should not exist yet (not loaded)
const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
string,
unknown
>
// Either empty or doesn't have our custom data merged directly
// (since zh wasn't loaded yet, mergeLocaleMessage on non-existent locale
// may create an empty locale or do nothing useful)
expect(zhMessages.customNode).toBeUndefined()
})

it('should merge stored data when locale is lazily loaded', async () => {
// First, store custom nodes i18n data (before locale is loaded)
mergeCustomNodesI18n({
zh: {
customNode: {
title: '自定义节点标题'
}
}
})

await loadLocale('zh')

// Verify both the base locale data and custom node data are present
const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
string,
unknown
>
expect(zhMessages.welcome).toBe('欢迎')
expect(zhMessages.customNode).toEqual({ title: '自定义节点标题' })
})

it('should preserve custom node data when locale is loaded after merge', async () => {
// Simulate the real scenario:
// 1. Custom nodes i18n is loaded first
mergeCustomNodesI18n({
zh: {
customNode: {
title: '自定义节点标题'
},
settingsCategories: {
Hotkeys: '快捷键'
}
}
})

// 2. Then locale is lazily loaded (this would previously overwrite custom data)
await loadLocale('zh')

// 3. Verify custom node data is still present
const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
string,
unknown
>
expect(zhMessages.customNode).toEqual({ title: '自定义节点标题' })
expect(zhMessages.settingsCategories).toEqual({ Hotkeys: '快捷键' })

// 4. Also verify base locale data is present
expect(zhMessages.welcome).toBe('欢迎')
expect(zhMessages.nodeDefs).toEqual({ testNode: '测试节点' })
})

it('should handle multiple locales in custom nodes i18n data', async () => {
// Merge data for multiple locales
mergeCustomNodesI18n({
en: {
customPlugin: { name: 'Easy Use' }
},
zh: {
customPlugin: { name: '简单使用' }
}
})

// English should be merged immediately (pre-loaded)
const enMessages = i18n.global.getLocaleMessage('en') as Record<
string,
unknown
>
expect(enMessages.customPlugin).toEqual({ name: 'Easy Use' })

await loadLocale('zh')
const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
string,
unknown
>
expect(zhMessages.customPlugin).toEqual({ name: '简单使用' })
})

it('should handle calling mergeCustomNodesI18n multiple times', async () => {
// Use fresh module instance to ensure clean state
vi.resetModules()
const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n')

mergeCustomNodesI18n({
zh: { plugin1: { name: '插件1' } }
})

mergeCustomNodesI18n({
zh: { plugin2: { name: '插件2' } }
})

await loadLocale('zh')

const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
string,
unknown
>
// Only the second call's data should be present
expect(zhMessages.plugin2).toEqual({ name: '插件2' })
// First call's data is overwritten
expect(zhMessages.plugin1).toBeUndefined()
})
})

describe('loadLocale', () => {
it('should not reload already loaded locale', async () => {
await loadLocale('zh')
await loadLocale('zh')

// Should complete without error (second call returns early)
})

it('should warn for unsupported locale', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

await loadLocale('unsupported-locale')

expect(consoleSpy).toHaveBeenCalledWith(
'Locale "unsupported-locale" is not supported'
)
consoleSpy.mockRestore()
})

it('should handle concurrent load requests for same locale', async () => {
// Start multiple loads concurrently
const promises = [loadLocale('zh'), loadLocale('zh'), loadLocale('zh')]

await Promise.all(promises)
})
})
})
25 changes: 25 additions & 0 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ const loadedLocales = new Set<string>(['en'])
// Track locales currently being loaded to prevent race conditions
const loadingLocales = new Map<string, Promise<void>>()

// Store custom nodes i18n data for merging when locales are lazily loaded
const customNodesI18nData: Record<string, unknown> = {}

/**
* Dynamically load a locale and its associated files (nodeDefs, commands, settings)
*/
Expand Down Expand Up @@ -137,6 +140,10 @@ export async function loadLocale(locale: string): Promise<void> {

i18n.global.setLocaleMessage(locale, messages as LocaleMessages)
loadedLocales.add(locale)

if (customNodesI18nData[locale]) {
i18n.global.mergeLocaleMessage(locale, customNodesI18nData[locale])
}
} catch (error) {
console.error(`Failed to load locale "${locale}":`, error)
throw error
Expand All @@ -150,6 +157,24 @@ export async function loadLocale(locale: string): Promise<void> {
return loadPromise
}

/**
* Stores the data for later use when locales are lazily loaded,
* and immediately merges data for already-loaded locales.
*/
export function mergeCustomNodesI18n(i18nData: Record<string, unknown>): void {
// Clear existing data and replace with new data
for (const key of Object.keys(customNodesI18nData)) {
delete customNodesI18nData[key]
}
Object.assign(customNodesI18nData, i18nData)

for (const [locale, message] of Object.entries(i18nData)) {
if (loadedLocales.has(locale)) {
i18n.global.mergeLocaleMessage(locale, message)
}
}
}

// Only include English in the initial bundle
const messages = {
en: buildLocale(en, enNodes, enCommands, enSettings)
Expand Down
Loading