Skip to content
Draft
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
63 changes: 63 additions & 0 deletions shared-lib/lib/Expression/ExpressionParse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import jsepTemplateLiteral, { type TemplateLiteral } from '@jsep-plugin/template
import jsepComments from '@jsep-plugin/comment'
import { CompanionVariablesPlugin, type CompanionVariableExpression } from './Plugins/CompanionVariables.js'
import { AssignmentPlugin, type AssignmentExpression, type UpdateExpression } from './Plugins/Assignment.js'
import type { JsonValue } from 'type-fest'

// setup plugins
jsep.plugins.register(jsepNumbers)
Expand All @@ -29,6 +30,68 @@ export function ParseExpression(expression: string): SomeExpressionNode {
return parsed
}

/**
* Convert a JSON value to an expression literal string that round-trips cleanly.
* Strings are quoted, numbers/booleans are stringified, null/undefined become 'null'.
*/
export function valueToExpressionLiteral(value: JsonValue | undefined): string {
if (value === null || value === undefined) return 'null'
if (typeof value === 'string') return JSON.stringify(value)
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return JSON.stringify(value)
}

/**
* Try to extract the raw JSON value from an expression that is a plain value definition
* (a literal, a negative/positive number, an array or object of plain values).
* Returns { value } if plain (no modal needed), or null if the expression is lossy
* (involves computation, variable references, etc.) and a confirmation modal should be shown.
*/
export function tryExtractExpressionPlainValue(node: SomeExpressionNode): { value: JsonValue } | null {
if (node.type === 'Literal') {
// jsep Literal values are string | number | boolean | null (undefined is patched in by fixupExpression)
return { value: node.value as JsonValue }
}

if (
node.type === 'UnaryExpression' &&
(node.operator === '-' || node.operator === '+') &&
node.argument.type === 'Literal'
) {
const argValue = (node.argument as jsep.Literal).value
if (typeof argValue === 'number') {
return { value: node.operator === '-' ? -argValue : +argValue }
}
return null
}

if (node.type === 'ArrayExpression') {
const values: JsonValue[] = []
for (const element of node.elements) {
if (!element) return null
const extracted = tryExtractExpressionPlainValue(element as SomeExpressionNode)
if (extracted === null) return null
values.push(extracted.value)
}
return { value: values }
}

if (node.type === 'ObjectExpression') {
const result: Record<string, JsonValue> = {}
for (const prop of node.properties) {
if (!prop.value) return null
const keyNode = prop.key as jsep.Literal
if (keyNode.type !== 'Literal' || typeof keyNode.value !== 'string') return null
const extracted = tryExtractExpressionPlainValue(prop.value as SomeExpressionNode)
if (extracted === null) return null
result[keyNode.value] = extracted.value
}
return { value: result }
}

return null
}

/**
* Find all the referenced variables in an expression
*/
Expand Down
124 changes: 123 additions & 1 deletion shared-lib/lib/__tests__/expressions-parse.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest'
import { ParseExpression, FindAllReferencedVariables } from '../Expression/ExpressionParse.js'
import {
ParseExpression,
FindAllReferencedVariables,
tryExtractExpressionPlainValue,
} from '../Expression/ExpressionParse.js'

function ParseExpression2(str: string) {
const node = ParseExpression(str)
Expand Down Expand Up @@ -1129,3 +1133,121 @@ describe('parser', () => {
})
})
})

describe('tryExtractExpressionPlainValue', () => {
describe('plain values (should return extracted value)', () => {
it('string literal', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('"major"'))).toEqual({ value: 'major' })
})

it('number literal', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('42'))).toEqual({ value: 42 })
})

it('zero', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('0'))).toEqual({ value: 0 })
})

it('float literal', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('3.14'))).toEqual({ value: 3.14 })
})

it('boolean true', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('true'))).toEqual({ value: true })
})

it('boolean false', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('false'))).toEqual({ value: false })
})

it('null', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('null'))).toEqual({ value: null })
})

it('negative number', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('-42'))).toEqual({ value: -42 })
})

it('positive unary on number', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('+5'))).toEqual({ value: 5 })
})

it('simple array', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('[1, "a", true]'))).toEqual({ value: [1, 'a', true] })
})

it('array with negative number', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('[-42, 0]'))).toEqual({ value: [-42, 0] })
})

it('simple object', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('{ key: "a" }'))).toEqual({ value: { key: 'a' } })
})

it('object with numeric value', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('{ x: 1, y: -2 }'))).toEqual({ value: { x: 1, y: -2 } })
})

it('nested plain value', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('[-42, { x: 1 }]'))).toEqual({ value: [-42, { x: 1 }] })
})

it('empty array', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('[]'))).toEqual({ value: [] })
})

it('empty object', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('{}'))).toEqual({ value: {} })
})
})

describe('lossy values (should return null)', () => {
it('binary expression: addition', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('2 + 2'))).toBeNull()
})

it('binary expression: string concatenation', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('"a" + "b"'))).toBeNull()
})

it('variable reference', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('$(companion:time_hms)'))).toBeNull()
})

it('unary minus on variable', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('-$(var:x)'))).toBeNull()
})

it('function call', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('floor(3.7)'))).toBeNull()
})

it('conditional expression', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('true ? 1 : 2'))).toBeNull()
})

it('array containing variable', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('[$(var:x)]'))).toBeNull()
})

it('array containing expression', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('[1 + 2]'))).toBeNull()
})

it('object with variable value', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('{ key: $(var:x) }'))).toBeNull()
})

it('object with expression value', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('{ key: 1 + 2 }'))).toBeNull()
})

it('identifier (not a keyword)', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('major'))).toBeNull()
})

it('unary logical not', () => {
expect(tryExtractExpressionPlainValue(ParseExpression('!true'))).toBeNull()
})
})
})
153 changes: 153 additions & 0 deletions webui/src/Components/ExpressionConversionModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { CAlert, CButton, CModalBody, CModalFooter, CModalHeader, CSpinner } from '@coreui/react'
import { useSubscription } from '@trpc/tanstack-react-query'
import { trpc } from '~/Resources/TRPC.js'
import { CModalExt } from './CModalExt.js'
import { VariableValueDisplay } from './VariableValueDisplay.js'
import type { JsonValue } from 'type-fest'
import type { SomeCompanionInputField } from '@companion-app/shared/Model/Options.js'
import { validateInputValue } from '@companion-app/shared/ValidateInputValue.js'
import { ExpressionInputField } from './ExpressionInputField.js'

interface ExpressionConversionModalProps {
expression: string
controlId: string | null
fieldDefinition?: SomeCompanionInputField
onConfirm: (value: JsonValue | undefined) => void
onCancel: () => void
}

export function ExpressionConversionModal({
expression,
controlId,
fieldDefinition,
onConfirm,
onCancel,
}: ExpressionConversionModalProps): React.JSX.Element {
const sub = useSubscription(
trpc.preview.expressionStream.watchExpression.subscriptionOptions(
{
controlId: controlId,
expression: expression,
isVariableString: false,
},
{}
)
)

// Keep the latest result in a ref so the confirm handler always uses it, not a stale closure value
type SubData = typeof sub.data
const latestDataRef = useRef<SubData>(sub.data)
if (sub.data) {
latestDataRef.current = sub.data
}

const [showSpinner, setShowSpinner] = useState(false)
useEffect(() => {
if (sub.data) {
setShowSpinner(false)
return
}
const timer = setTimeout(() => setShowSpinner(true), 200)
return () => clearTimeout(timer)
}, [sub.data])

const displayData = sub.data ?? latestDataRef.current

const computedValueValidation = useMemo(() => {
if (!fieldDefinition || !displayData?.ok) return null

const { type } = fieldDefinition
// These types accept any value — no validation needed
if (
type === 'textinput' ||
type === 'secret-text' ||
type === 'static-text' ||
type === 'bonjour-device' ||
type === 'expression'
)
return null

return validateInputValue(fieldDefinition, displayData.value as JsonValue | undefined)
}, [fieldDefinition, displayData])

// The value to use when confirming — sanitised when validation ran, otherwise raw
const sanitisedValue =
computedValueValidation?.sanitisedValue ?? (displayData?.ok ? (displayData.value as JsonValue) : undefined)

const defaultValue =
fieldDefinition && 'default' in fieldDefinition ? (fieldDefinition.default as JsonValue) : undefined

const doConfirm = useCallback(() => {
onConfirm(sanitisedValue)
}, [onConfirm, sanitisedValue])

const doUseDefault = useCallback(() => {
onConfirm(defaultValue)
}, [onConfirm, defaultValue])

return (
<CModalExt visible onClose={onCancel} transition={false}>
<CModalHeader closeButton>
<h5>Convert to text mode</h5>
</CModalHeader>
<CModalBody>
<CAlert color="primary" className="py-2">
Do you want to replace the expression with its value? Warning: you will not be able to recover the expression!
</CAlert>

<h6>Expression</h6>
<ExpressionInputField value={expression} setValue={() => null} disabled />

<hr />

<h6>Computed value</h6>
{!displayData ? (
showSpinner ? (
<CSpinner size="sm" />
) : (
<em>Evaluating&hellip;</em>
)
) : !displayData.ok ? (
<span className="text-danger">Error: {displayData.error}</span>
) : (
<>
{!!computedValueValidation?.validationError && (
<CAlert color="danger" className="mb-2 py-2">
{computedValueValidation.validationError}
</CAlert>
)}
<VariableValueDisplay
value={
computedValueValidation ? computedValueValidation.sanitisedValue : (displayData.value as JsonValue)
}
showCopy={false}
onCopied={() => {}}
forceExpanded
/>
{!computedValueValidation?.validationError &&
(computedValueValidation?.validationWarnings.length ?? 0) > 0 && (
<CAlert color="warning" className="mt-2 mb-0 py-2">
{computedValueValidation!.validationWarnings.join(', ')}
</CAlert>
)}
</>
)}
</CModalBody>
<CModalFooter>
<CButton color="secondary" onClick={onCancel}>
Cancel
</CButton>
{computedValueValidation?.validationError ? (
<CButton color="primary" onClick={doUseDefault} disabled={defaultValue === undefined}>
Use default value
</CButton>
) : (
<CButton color="primary" onClick={doConfirm} disabled={!displayData?.ok}>
Use computed value
</CButton>
)}
</CModalFooter>
</CModalExt>
)
}
Loading