Compare commits

...

3 Commits

Author SHA1 Message Date
CodeRabbit Fixer
5edb16e7f3 fix: LGraphCanvas: add type guard for widget.getContextMenuOptions() and validate returned value (#9390)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:00:22 +01:00
CodeRabbit Fixer
610bd70e4e fix: LGraphCanvas: add type guard for widget.getContextMenuOptions() and validate returned value (#9390)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:54:57 +01:00
Johnpaul Chiwetelu
7cb07f9b2d fix: standardize i18n pluralization to two-part English format (#9384)
## Summary

Standardize 5 English pluralization strings from incorrect 3-part format
to proper 2-part `"singular | plural"` format.

## Changes

- **What**: Convert `nodesCount`, `asset`, `errorCount`,
`downloadsFailed`, and `exportFailed` i18n keys from redundant 3-part
pluralization (zero/one/many) to standard 2-part English format
(singular/plural)

## Review Focus

The 3-part format (`a | b | a`) was redundant for English since the
first and third parts were identical. vue-i18n only needs 2 parts for
English pluralization.

Fixes #9277

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9384-fix-standardize-i18n-pluralization-to-two-part-English-format-3196d73d365081cf97c4e7cfa310ce8e)
by [Unito](https://www.unito.io)
2026-03-06 14:53:13 +01:00
4 changed files with 147 additions and 7 deletions

View File

@@ -105,6 +105,7 @@ import type { IBaseWidget, TWidgetValue } from './types/widgets'
import { alignNodes, distributeNodes, getBoundaryNodes } from './utils/arrange'
import { findFirstNode, getAllNestedItems } from './utils/collections'
import { resolveConnectingLinkColor } from './utils/linkColors'
import { hasWidgetContextMenuOptions } from './utils/type'
import { createUuidv4 } from './utils/uuid'
import type { UUID } from './utils/uuid'
import { BaseWidget } from './widgets/BaseWidget'
@@ -8493,6 +8494,18 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
} else {
// on node
menu_info = this.getNodeMenuOptions(node)
const widget = node.getWidgetOnPos(event.canvasX, event.canvasY)
if (widget && hasWidgetContextMenuOptions(widget)) {
const widgetMenuItems = widget.getContextMenuOptions({
e: event,
node,
canvas: this
})
if (Array.isArray(widgetMenuItems) && widgetMenuItems.length) {
menu_info.unshift(...widgetMenuItems, null)
}
}
}
} else {
menu_info = this.getCanvasMenuOptions()

View File

@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { hasWidgetContextMenuOptions } from '@/lib/litegraph/src/utils/type'
function createMockWidget(
overrides: Partial<IBaseWidget> & Record<string, unknown> = {}
): IBaseWidget {
return {
name: 'test',
type: 'number',
y: 0,
options: {},
...overrides
} as IBaseWidget
}
describe('hasWidgetContextMenuOptions', () => {
it('returns true for a widget with a callable getContextMenuOptions', () => {
const widget = createMockWidget({
getContextMenuOptions: vi.fn().mockReturnValue([])
})
expect(hasWidgetContextMenuOptions(widget)).toBe(true)
})
it('returns false for a widget without getContextMenuOptions', () => {
const widget = createMockWidget()
expect(hasWidgetContextMenuOptions(widget)).toBe(false)
})
it('returns false for a widget where getContextMenuOptions is not a function', () => {
const widget = createMockWidget({
getContextMenuOptions: 'not-a-function' as unknown
})
expect(hasWidgetContextMenuOptions(widget)).toBe(false)
})
})
describe('widget context menu options Array.isArray guard', () => {
it('accepts a valid non-empty array', () => {
const getContextMenuOptions = vi
.fn()
.mockReturnValue([{ content: 'Test Option' }])
const widget = createMockWidget({ getContextMenuOptions })
if (hasWidgetContextMenuOptions(widget)) {
const result = widget.getContextMenuOptions({
e: {} as never,
node: {} as never,
canvas: {} as never
})
expect(Array.isArray(result)).toBe(true)
expect(result.length).toBe(1)
}
})
it('accepts an empty array', () => {
const getContextMenuOptions = vi.fn().mockReturnValue([])
const widget = createMockWidget({ getContextMenuOptions })
if (hasWidgetContextMenuOptions(widget)) {
const result = widget.getContextMenuOptions({
e: {} as never,
node: {} as never,
canvas: {} as never
})
expect(Array.isArray(result)).toBe(true)
expect(result.length).toBe(0)
}
})
it('safely handles undefined return value', () => {
const getContextMenuOptions = vi.fn().mockReturnValue(undefined)
const widget = createMockWidget({ getContextMenuOptions })
if (hasWidgetContextMenuOptions(widget)) {
const result = widget.getContextMenuOptions({
e: {} as never,
node: {} as never,
canvas: {} as never
})
expect(Array.isArray(result)).toBe(false)
}
})
it('safely handles non-array return value', () => {
const getContextMenuOptions = vi.fn().mockReturnValue('not an array')
const widget = createMockWidget({ getContextMenuOptions })
if (hasWidgetContextMenuOptions(widget)) {
const result = widget.getContextMenuOptions({
e: {} as never,
node: {} as never,
canvas: {} as never
})
expect(Array.isArray(result)).toBe(false)
}
})
})

View File

@@ -1,7 +1,35 @@
import { without } from 'es-toolkit'
import type { IColorable, ISlotType } from '@/lib/litegraph/src/interfaces'
import type { NodeBindable } from '@/lib/litegraph/src/types/widgets'
import type {
IColorable,
IContextMenuValue,
ISlotType
} from '@/lib/litegraph/src/interfaces'
import type {
IBaseWidget,
NodeBindable
} from '@/lib/litegraph/src/types/widgets'
import type { CanvasPointerEvent } from '../types/events'
import type { LGraphCanvas } from '../LGraphCanvas'
import type { LGraphNode } from '../LGraphNode'
interface IWidgetWithContextMenu extends IBaseWidget {
getContextMenuOptions(opts: {
e: CanvasPointerEvent
node: LGraphNode
canvas: LGraphCanvas
}): IContextMenuValue[]
}
export function hasWidgetContextMenuOptions(
widget: IBaseWidget
): widget is IWidgetWithContextMenu {
return (
'getContextMenuOptions' in widget &&
typeof widget.getContextMenuOptions === 'function'
)
}
/**
* Converts a plain object to a class instance if it is not already an instance of the class.

View File

@@ -178,7 +178,7 @@
"uploadAlreadyInProgress": "Upload already in progress",
"capture": "capture",
"nodes": "Nodes",
"nodesCount": "{count} nodes | {count} node | {count} nodes",
"nodesCount": "{count} node | {count} nodes",
"addNode": "Add a node...",
"filterBy": "Filter by:",
"filterByType": "Filter by {type}...",
@@ -222,7 +222,7 @@
"failed": "Failed",
"cancelled": "Cancelled",
"job": "Job",
"asset": "{count} assets | {count} asset | {count} assets",
"asset": "{count} asset | {count} assets",
"untitled": "Untitled",
"emDash": "—",
"enabling": "Enabling {id}",
@@ -3347,7 +3347,7 @@
}
},
"errorOverlay": {
"errorCount": "{count} ERRORS | {count} ERROR | {count} ERRORS",
"errorCount": "{count} ERROR | {count} ERRORS",
"seeErrors": "See Errors"
},
"help": {
@@ -3357,7 +3357,7 @@
"progressToast": {
"importingModels": "Importing Models",
"downloadingModel": "Downloading model...",
"downloadsFailed": "{count} downloads failed | {count} download failed | {count} downloads failed",
"downloadsFailed": "{count} download failed | {count} downloads failed",
"allDownloadsCompleted": "All downloads completed",
"noImportsInQueue": "No {filter} in queue",
"failed": "Failed",
@@ -3374,7 +3374,7 @@
"exportingAssets": "Exporting Assets",
"preparingExport": "Preparing export...",
"exportError": "Export failed",
"exportFailed": "{count} export failed | {count} export failed | {count} exports failed",
"exportFailed": "{count} export failed | {count} exports failed",
"allExportsCompleted": "All exports completed",
"noExportsInQueue": "No {filter} exports in queue",
"exportStarted": "Preparing ZIP download...",