Files
simstudioai--sim/apps/sim/lib/workflows/search-replace/replacements.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

335 lines
12 KiB
TypeScript

import { replaceJsonStringLeafRange } from '@/lib/workflows/search-replace/json-value-fields'
import {
getWorkflowSearchReplacementIssue,
normalizeWorkflowSearchResourceReplacement,
replaceWorkflowSearchResourceValue,
workflowSearchResourceValueContains,
} from '@/lib/workflows/search-replace/resources'
import {
getWorkflowSearchSubflowField,
parseWorkflowSearchSubflowReplacement,
} from '@/lib/workflows/search-replace/subflow-fields'
import type {
WorkflowSearchBlockState,
WorkflowSearchMatch,
WorkflowSearchReplacementOption,
WorkflowSearchReplacePlan,
WorkflowSearchReplaceSubflowUpdate,
WorkflowSearchReplaceUpdate,
} from '@/lib/workflows/search-replace/types'
import {
getValueAtPath,
pathToKey,
setValueAtPath,
} from '@/lib/workflows/search-replace/value-walker'
interface BuildWorkflowSearchReplacePlanParams {
blocks: Record<string, WorkflowSearchBlockState>
matches: WorkflowSearchMatch[]
selectedMatchIds: Set<string>
replacementByMatchId?: Record<string, string>
defaultReplacement?: string
resourceReplacementOptions?: WorkflowSearchReplacementOption[]
}
function normalizeReplacement(match: WorkflowSearchMatch, replacement: string): string {
return normalizeWorkflowSearchResourceReplacement(match, replacement)
}
function replaceRange(value: string, start: number, end: number, replacement: string): string {
return `${value.slice(0, start)}${replacement}${value.slice(end)}`
}
function clearDependentValues(value: unknown, paths: WorkflowSearchMatch['dependentValuePaths']) {
return (paths ?? []).reduce((currentValue, path) => setValueAtPath(currentValue, path, ''), value)
}
function pathStartsWith(
path: WorkflowSearchMatch['valuePath'],
prefix: WorkflowSearchMatch['valuePath']
) {
return prefix.every((segment, index) => path[index] === segment)
}
function getTouchedPathsByField(matches: WorkflowSearchMatch[]) {
const touchedPathsByField = new Map<string, WorkflowSearchMatch['valuePath'][]>()
for (const match of matches) {
if (match.target.kind !== 'subblock') continue
const updateKey = `${match.blockId}:${match.subBlockId}`
const paths = touchedPathsByField.get(updateKey) ?? []
paths.push(match.valuePath)
touchedPathsByField.set(updateKey, paths)
}
return touchedPathsByField
}
function getResourceReplacementScope(
value: unknown,
match: WorkflowSearchMatch
): { value: unknown; path: WorkflowSearchMatch['valuePath'] } | null {
for (let prefixLength = match.valuePath.length; prefixLength >= 0; prefixLength -= 1) {
const path = match.valuePath.slice(0, prefixLength)
const candidateValue = path.length === 0 ? value : getValueAtPath(value, path)
if (workflowSearchResourceValueContains(match, candidateValue)) {
return { value: candidateValue, path }
}
}
return null
}
function getDependentValuePathsToClear(
match: WorkflowSearchMatch,
touchedPathsByField: Map<string, WorkflowSearchMatch['valuePath'][]>
) {
if (!match.dependentValuePaths?.length) return undefined
const updateKey = `${match.blockId}:${match.subBlockId}`
const touchedPaths = touchedPathsByField.get(updateKey) ?? []
return match.dependentValuePaths.filter(
(dependentPath) =>
!touchedPaths.some((touchedPath) => pathStartsWith(touchedPath, dependentPath))
)
}
function getReplacement(
match: WorkflowSearchMatch,
replacementByMatchId: Record<string, string> | undefined,
defaultReplacement: string | undefined
): string | undefined {
const replacement = replacementByMatchId?.[match.id] ?? defaultReplacement
if (replacement === undefined) return undefined
return normalizeReplacement(match, replacement)
}
export function buildWorkflowSearchReplacePlan({
blocks,
matches,
selectedMatchIds,
replacementByMatchId,
defaultReplacement,
resourceReplacementOptions,
}: BuildWorkflowSearchReplacePlanParams): WorkflowSearchReplacePlan {
const skipped: WorkflowSearchReplacePlan['skipped'] = []
const conflicts: WorkflowSearchReplacePlan['conflicts'] = []
const updatesByField = new Map<string, WorkflowSearchReplaceUpdate>()
const subflowUpdatesByField = new Map<string, WorkflowSearchReplaceSubflowUpdate>()
const selectedMatches = matches.filter((match) => selectedMatchIds.has(match.id))
const touchedPathsByField = getTouchedPathsByField(selectedMatches)
const orderedMatches = [...selectedMatches].sort((a, b) => {
const blockCompare = a.blockId.localeCompare(b.blockId)
if (blockCompare !== 0) return blockCompare
const subBlockCompare = a.subBlockId.localeCompare(b.subBlockId)
if (subBlockCompare !== 0) return subBlockCompare
const pathCompare = pathToKey(a.valuePath).localeCompare(pathToKey(b.valuePath))
if (pathCompare !== 0) return pathCompare
const occurrenceCompare =
(b.structuredOccurrenceIndex ?? 0) - (a.structuredOccurrenceIndex ?? 0)
if (occurrenceCompare !== 0) return occurrenceCompare
return (b.range?.start ?? 0) - (a.range?.start ?? 0)
})
for (const match of orderedMatches) {
const replacement = getReplacement(match, replacementByMatchId, defaultReplacement)
if (replacement === undefined || replacement === match.rawValue) {
skipped.push({ matchId: match.id, reason: 'No replacement value provided' })
continue
}
if (!match.editable) {
skipped.push({ matchId: match.id, reason: match.reason ?? 'Match is not editable' })
continue
}
const replacementIssue = getWorkflowSearchReplacementIssue({
matches: [match],
replacement,
resourceOptions: resourceReplacementOptions,
})
if (replacementIssue) {
conflicts.push({ matchId: match.id, reason: replacementIssue })
continue
}
const block = blocks[match.blockId]
if (!block) {
conflicts.push({ matchId: match.id, reason: 'Block no longer exists' })
continue
}
if (match.target.kind === 'subflow') {
if (block.type !== 'loop' && block.type !== 'parallel') {
conflicts.push({ matchId: match.id, reason: 'Subflow block no longer exists' })
continue
}
const currentField = getWorkflowSearchSubflowField(block, match.target.fieldId)
if (!currentField) {
conflicts.push({ matchId: match.id, reason: 'Subflow field is no longer available' })
continue
}
if (!currentField.editable) {
conflicts.push({
matchId: match.id,
reason: currentField.reason ?? 'Subflow field is not editable',
})
continue
}
const updateKey = `${match.blockId}:${match.target.fieldId}`
const existingUpdate = subflowUpdatesByField.get(updateKey)
const previousValue = existingUpdate?.previousValue ?? currentField.value
const currentValue = String(existingUpdate?.nextValue ?? currentField.value)
if (!match.range) {
conflicts.push({ matchId: match.id, reason: 'Subflow target is no longer text' })
continue
}
const currentRawValue = currentValue.slice(match.range.start, match.range.end)
if (currentRawValue !== match.rawValue) {
conflicts.push({ matchId: match.id, reason: 'Subflow target changed since search' })
continue
}
const nextTextValue = replaceRange(
currentValue,
match.range.start,
match.range.end,
replacement
)
const parsedReplacement = parseWorkflowSearchSubflowReplacement({
blockType: block.type,
fieldId: match.target.fieldId,
replacement: nextTextValue,
})
if (!parsedReplacement.success) {
conflicts.push({ matchId: match.id, reason: parsedReplacement.reason })
continue
}
subflowUpdatesByField.set(updateKey, {
blockId: match.blockId,
blockType: block.type,
fieldId: match.target.fieldId,
previousValue,
nextValue: parsedReplacement.value,
matchIds: [...(existingUpdate?.matchIds ?? []), match.id],
})
continue
}
const subBlock = block?.subBlocks?.[match.subBlockId]
if (!subBlock) {
conflicts.push({ matchId: match.id, reason: 'Block or subblock no longer exists' })
continue
}
const updateKey = `${match.blockId}:${match.subBlockId}`
const existingUpdate = updatesByField.get(updateKey)
const previousValue: unknown = existingUpdate?.previousValue ?? subBlock.value
let nextValue: unknown = existingUpdate?.nextValue ?? subBlock.value
const dependentValuePathsToClear = getDependentValuePathsToClear(match, touchedPathsByField)
if (match.range) {
const jsonReplacement = replaceJsonStringLeafRange({
value: nextValue,
subBlockType: match.subBlockType,
path: match.valuePath,
range: match.range,
rawValue: match.rawValue,
replacement,
})
if (jsonReplacement.handled) {
if (!jsonReplacement.success) {
conflicts.push({
matchId: match.id,
reason: jsonReplacement.reason ?? 'Target value is no longer text',
})
continue
}
nextValue = jsonReplacement.nextValue
nextValue = clearDependentValues(nextValue, dependentValuePathsToClear)
updatesByField.set(updateKey, {
blockId: match.blockId,
subBlockId: match.subBlockId,
previousValue,
nextValue,
matchIds: [...(existingUpdate?.matchIds ?? []), match.id],
})
continue
}
const currentLeaf = getValueAtPath(nextValue, match.valuePath)
if (typeof currentLeaf !== 'string') {
conflicts.push({ matchId: match.id, reason: 'Target value is no longer text' })
continue
}
const currentRawValue = currentLeaf.slice(match.range.start, match.range.end)
if (currentRawValue !== match.rawValue) {
conflicts.push({ matchId: match.id, reason: 'Target text changed since search' })
continue
}
nextValue = setValueAtPath(
nextValue,
match.valuePath,
replaceRange(currentLeaf, match.range.start, match.range.end, replacement)
)
nextValue = clearDependentValues(nextValue, dependentValuePathsToClear)
} else {
const replacementScope = getResourceReplacementScope(nextValue, match)
if (!replacementScope) {
conflicts.push({ matchId: match.id, reason: 'Target resource changed since search' })
continue
}
const resourceReplacement = replaceWorkflowSearchResourceValue(
match,
replacementScope.value,
replacement
)
if (!resourceReplacement.success) {
conflicts.push({
matchId: match.id,
reason: resourceReplacement.reason ?? 'Target resource is no longer replaceable',
})
continue
}
const replacedValue = resourceReplacement.nextValue
nextValue =
replacementScope.path.length === 0
? replacedValue
: setValueAtPath(nextValue, replacementScope.path, replacedValue)
nextValue = clearDependentValues(nextValue, dependentValuePathsToClear)
}
updatesByField.set(updateKey, {
blockId: match.blockId,
subBlockId: match.subBlockId,
previousValue,
nextValue,
matchIds: [...(existingUpdate?.matchIds ?? []), match.id],
})
}
if (conflicts.length > 0) {
return { updates: [], subflowUpdates: [], skipped, conflicts }
}
return {
updates: [...updatesByField.values()].filter(
(update) => update.previousValue !== update.nextValue
),
subflowUpdates: [...subflowUpdatesByField.values()].filter((update) => {
if (typeof update.nextValue === 'number')
return String(update.nextValue) !== update.previousValue
return update.nextValue !== update.previousValue
}),
skipped,
conflicts,
}
}