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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 31 additions & 0 deletions packages/shared-frontend-utils/src/formatUtil.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getMediaTypeFromFilename,
getPathDetails,
highlightQuery,
escapeI18nMessage,
isCivitaiModelUrl,
isCivitaiUrl,
isPreviewableMediaType,
Expand Down Expand Up @@ -500,4 +501,34 @@ describe('formatUtil', () => {
expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—')
})
})

describe('escapeI18nMessage', () => {
it('wraps message-syntax characters in literal interpolations', () => {
expect(escapeI18nMessage('a@b')).toBe("a{'@'}b")
expect(escapeI18nMessage('{x}')).toBe("{'{'}x{'}'}")
expect(escapeI18nMessage('a|b')).toBe("a{'|'}b")
expect(escapeI18nMessage('50%')).toBe("50{'%'}")
expect(escapeI18nMessage('$5')).toBe("{'$'}5")
})

it('doubles backslashes rather than interpolating them', () => {
expect(escapeI18nMessage('\\')).toBe('\\\\')
expect(escapeI18nMessage('C:\\@home')).toBe("C:\\\\{'@'}home")
})

it('leaves text without message syntax untouched', () => {
expect(escapeI18nMessage('plain name')).toBe('plain name')
expect(escapeI18nMessage('')).toBe('')
})

it('is not idempotent, so it must be applied exactly once', () => {
const once = escapeI18nMessage('a@b')
expect(escapeI18nMessage(once)).not.toBe(once)
})

it('returns an empty string for non-string input', () => {
expect(escapeI18nMessage(42 as unknown as string)).toBe('')
expect(escapeI18nMessage(null as unknown as string)).toBe('')
})
})
})
27 changes: 27 additions & 0 deletions packages/shared-frontend-utils/src/formatUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,33 @@ export function normalizeI18nKey(key: string) {
return typeof key === 'string' ? key.replace(/\./g, '_') : ''
}

const VUE_I18N_BACKSLASH = /\\/g
const VUE_I18N_SYNTAX_CHARS = /[@${}|%]/g
Comment thread
christian-byrne marked this conversation as resolved.

/**
* Escapes vue-i18n message syntax so arbitrary text can be stored as a locale
* message and rendered verbatim by `t()`.
*
* Backslash is doubled rather than wrapped in a literal interpolation: since
* vue-i18n 11 the message compiler reads `\` as an escape introducer, so a
* backslash before an escaped character would swallow the `{` this emits, and
* `{'\'}` would escape its own closing quote. Doubling must therefore run
* first; the literal interpolations it emits contain no backslashes.
*
* Apply exactly once. This is NOT idempotent, because the escape output itself
* contains `{`/`}`.
*
* Apply only to values read back through `t()`/`st()`. Values read through
* `tm()`/`stRaw()` are never compiled, so escaping them renders the escape
* syntax literally.
*/
export function escapeI18nMessage(text: string): string {
Comment thread
christian-byrne marked this conversation as resolved.
if (typeof text !== 'string') return ''
return text
.replace(VUE_I18N_BACKSLASH, '\\\\')
.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
}

/**
* Takes a dynamic prompt in the format {opt1|opt2|{optA|optB}|} and randomly replaces groups. Supports C style comments.
* @param input The dynamic prompt to process
Expand Down
18 changes: 18 additions & 0 deletions scripts/nodeDefLocaleSerializer.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'

import { escapeI18nMessage } from '@/utils/formatUtil'

import { serializeNodeDefLocales } from './nodeDefLocaleSerializer'

function render(message: string): string {
Expand Down Expand Up @@ -128,3 +130,19 @@ describe('serializeNodeDefLocales', () => {
expect(Object.keys(nodeDefinitions)).toEqual(['A_Node', 'Z_Node'])
})
})

describe('escapeI18nMessage', () => {
it.for([
['plain name'],
['@ $ {value} | 50%{done}'],
['\\@home'],
['cost \\$5'],
['a\\{b}'],
['back\\\\slash'],
['D:\\output\\img.png'],
['\\'],
['Regex Replace (\\$1)']
])('round-trips %j through the vue-i18n compiler', ([raw]) => {
expect(render(escapeI18nMessage(raw))).toBe(raw)
})
})
28 changes: 14 additions & 14 deletions scripts/nodeDefLocaleSerializer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { normalizeI18nKey } from '@/utils/formatUtil'
import { escapeI18nMessage, normalizeI18nKey } from '@/utils/formatUtil'

interface LocalizableInput {
type: string
Expand Down Expand Up @@ -26,12 +26,6 @@ export type WidgetLabels = Record<
Record<string, { name: string | undefined }>
>

const VUE_I18N_SYNTAX_CHARS = /[@${}|%]/g

function escapeMessage(text: string): string {
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
}

export function serializeNodeDefLocales(
nodeDefs: readonly LocalizableNodeDef[],
widgetLabels: WidgetLabels = {}
Expand All @@ -43,7 +37,10 @@ export function serializeNodeDefLocales(
...nodeDef.outputs.map(({ type }) => type)
])
.flatMap((type) => type.split(','))
.map((dataType) => [normalizeI18nKey(dataType), escapeMessage(dataType)])
.map((dataType) => [
normalizeI18nKey(dataType),
escapeI18nMessage(dataType)
])
.sort((a, b) => a[0].localeCompare(b[0]))
)

Expand All @@ -56,7 +53,7 @@ export function serializeNodeDefLocales(
[
normalizeI18nKey(name ?? ''),
{
name: name === undefined ? undefined : escapeMessage(name),
name: name === undefined ? undefined : escapeI18nMessage(name),
tooltip
}
]
Expand All @@ -72,7 +69,7 @@ export function serializeNodeDefLocales(
const serializedName =
name === undefined || name in dataTypes
? undefined
: escapeMessage(name)
: escapeI18nMessage(name)
if (serializedName === undefined && tooltip === undefined) return []

return [[index.toString(), { name: serializedName, tooltip }]]
Expand All @@ -86,7 +83,8 @@ export function serializeNodeDefLocales(
Object.entries(widgetLabels[nodeName] ?? {}).map(([name, label]) => [
normalizeI18nKey(name),
{
name: label.name === undefined ? undefined : escapeMessage(label.name)
name:
label.name === undefined ? undefined : escapeI18nMessage(label.name)
}
])
)
Expand All @@ -104,9 +102,11 @@ export function serializeNodeDefLocales(
return [
normalizeI18nKey(nodeDef.name),
{
display_name: escapeMessage(nodeDef.display_name ?? nodeDef.name),
display_name: escapeI18nMessage(
nodeDef.display_name ?? nodeDef.name
),
description: nodeDef.description
? escapeMessage(nodeDef.description)
? escapeI18nMessage(nodeDef.description)
: undefined,
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
outputs: serializeOutputs(nodeDef)
Expand All @@ -119,7 +119,7 @@ export function serializeNodeDefLocales(
nodeDefs.flatMap(({ category }) =>
category
.split('/')
.map((part) => [normalizeI18nKey(part), escapeMessage(part)])
.map((part) => [normalizeI18nKey(part), escapeI18nMessage(part)])
)
)

Expand Down
4 changes: 1 addition & 3 deletions src/components/queue/QueueInlineProgressSummary.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'

import { st } from '@/i18n'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useExecutionStore } from '@/stores/executionStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
Expand All @@ -56,8 +55,7 @@ const {
const currentNodeName = computed(() => {
return resolveNodeDisplayName(executionStore.executingNode, {
emptyLabel: t('g.emDash'),
untitledLabel: t('g.untitled'),
st
untitledLabel: t('g.untitled')
})
})

Expand Down
4 changes: 1 addition & 3 deletions src/components/rightSidePanel/RightSidePanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
import { st } from '@/i18n'
import { app } from '@/scripts/app'
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
Expand Down Expand Up @@ -249,8 +248,7 @@ function resolveTitle() {
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(nodes[0], {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
untitledLabel: fallbackNodeTitle
})
}
}
Expand Down
7 changes: 2 additions & 5 deletions src/components/rightSidePanel/errors/useErrorGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
} from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { st } from '@/i18n'
import type { MissingNodeType } from '@/types/comfy'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import { shouldRenderExecutionItemList } from './executionItemList'
Expand Down Expand Up @@ -89,8 +88,7 @@ function resolveNodeInfo(nodeId: NodeExecutionId) {
return {
title: resolveNodeDisplayName(graphNode, {
emptyLabel: '',
untitledLabel: '',
st
untitledLabel: ''
}),
graphNodeId: graphNode ? String(graphNode.id) : undefined
}
Expand Down Expand Up @@ -273,8 +271,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return (
resolveNodeDisplayName(node, {
emptyLabel: '',
untitledLabel: '',
st
untitledLabel: ''
}) || null
)
})
Expand Down
4 changes: 1 addition & 3 deletions src/components/rightSidePanel/parameters/WidgetItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
Expand Down Expand Up @@ -106,8 +105,7 @@ const displayNodeName = computed((): string | null => {
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(node, {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
untitledLabel: fallbackNodeTitle
})
})

Expand Down
7 changes: 5 additions & 2 deletions src/composables/queue/useJobList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ const createTestI18n = () =>
})

vi.mock('@/i18n', () => ({
st: vi.fn((key: string, fallback?: string) => `i18n(${key})-${fallback}`)
st: vi.fn((key: string, fallback?: string) => `i18n(${key})-${fallback}`),
resolveNodeDefText: vi.fn(
(field: string, nodeName: string) => `i18n(${nodeName}.${field})`
)
}))

let totalPercent: Ref<number>
Expand Down Expand Up @@ -553,7 +556,7 @@ describe('useJobList', () => {
}
await flush()
expect(instance.currentNodeName.value).toBe(
'i18n(nodeDefs.My Node Type.display_name)-My Node Type'
'i18n(My Node Type.display_name)'
)
})

Expand Down
4 changes: 1 addition & 3 deletions src/composables/queue/useJobList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'

import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore'
Expand Down Expand Up @@ -190,8 +189,7 @@ export function useJobList() {
const currentNodeName = computed(() => {
return resolveNodeDisplayName(executionStore.executingNode, {
emptyLabel: t('g.emDash'),
untitledLabel: t('g.untitled'),
st
untitledLabel: t('g.untitled')
})
})

Expand Down
8 changes: 2 additions & 6 deletions src/composables/useContextMenuTranslation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { st, te } from '@/i18n'
import { resolveNodeDefText, st, te } from '@/i18n'
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
import type {
IContextMenuOptions,
Expand All @@ -8,7 +8,6 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { normalizeI18nKey } from '@/utils/formatUtil'

/**
* Add translation for litegraph context menu.
Expand Down Expand Up @@ -165,10 +164,7 @@ export const useContextMenuTranslation = () => {
options: IContextMenuOptions
) {
if (options.title) {
options.title = st(
`nodeDefs.${normalizeI18nKey(options.title)}.display_name`,
options.title
)
options.title = resolveNodeDefText('display_name', options.title)
}
translateMenus(values, options)
const ctx = new OriginalContextMenu(values, options)
Expand Down
Loading
Loading