import { ArrayDiffItem, computeArrayDiff, getArrayDiffMapping, } from "@noya-app/noya-array-diff"; import { Draft, Patch, create, apply as mutativeApply } from "mutative"; import type { CreateArrayPatchesParameters, Operation, } from "mutative/dist/interface.js"; import { Diff } from "./typeboxDiff"; export function applyPatchToString(str: string, patch: SimplePatch) { const index = Number(patch.path[patch.path.length - 1]); const length = patch.length ?? 1; switch (patch.op) { case "add": { return str.slice(0, index) + patch.value + str.slice(index); } case "remove": { return str.slice(0, index) + str.slice(index + length); } case "replace": { return str.slice(0, index) + patch.value + str.slice(index + length); } default: return str; } } export function apply(obj: S, patches: SimplePatch[]) { if (typeof obj === "object" && obj !== null) { return mutativeApply(obj, patches); } if (typeof obj === "string") { return patches.reduce((acc, patch) => { return applyPatchToString(acc, patch) as S & string; }, obj); } if (typeof obj === "number") { if (patches.length > 0) { const patch = patches[0]; if (patch.op === "replace") { return patch.value; } return apply(obj, patches.slice(1)); } } return obj; } export type SimplePatch = Patch & { length?: number }; export type SimplePatchWithStringPath = Patch<{ pathAsArray: false }>; export type ExtendedPatch = { op: (typeof Operation)[keyof typeof Operation]; value?: any; path: ExtendedPathKey[]; from?: ExtendedPathKey[]; length?: number; }; export type ExtendedPathKey = string | number | { id: string | number }; export function extractItemId(item: unknown): string | number | undefined { if ( typeof item === "object" && item !== null && "id" in item && (typeof item.id === "string" || typeof item.id === "number") ) { return item.id; } } export function toExtendedPathKey( obj: any, path: (string | number)[], type?: "insert" ): ExtendedPathKey[] { const extendedPath: ExtendedPathKey[] = []; let currentObj = obj; for (let i = 0; i < path.length; i++) { const key = path[i]; if (Array.isArray(currentObj)) { let numericKey = typeof key === "number" ? key : parseInt(key as string, 10); if (isNaN(numericKey)) { numericKey = -1; } const arrayItem = currentObj[numericKey]; const itemId = arrayItem ? extractItemId(arrayItem) : undefined; const nextKeyIsId = path[i + 1] === "id"; // If the last key is a number and we're performing an add/move operation, // we're inserting something new, so we shouldn't target the id of the existing item. const isLastKey = i === path.length - 1; if ( itemId !== undefined && !nextKeyIsId && !(isLastKey && type === "insert") ) { extendedPath.push({ id: itemId }); } else { extendedPath.push(numericKey !== -1 ? numericKey : key); } currentObj = arrayItem; } else { extendedPath.push(key); currentObj = currentObj[key]; } } return extendedPath; } export function toSimplePath( obj: any, extendedPath: ExtendedPathKey[] ): (string | number)[] { const simplePath: (string | number)[] = []; let currentObj = obj; for (let i = 0; i < extendedPath.length; i++) { const key = extendedPath[i]; if (typeof key === "object" && "id" in key) { if (Array.isArray(currentObj)) { const index = currentObj.findIndex((item: any) => item.id === key.id); if (index !== -1) { simplePath.push(index); currentObj = currentObj[index]; } else { throw new Error(`ID ${key.id} not found in array`); } } else { throw new Error(`Expected array but found ${typeof currentObj}`); } } else { simplePath.push(key); currentObj = currentObj[key]; } } return simplePath; } export function toExtendedPatch(obj: any, patch: SimplePatch): ExtendedPatch { return { op: patch.op, ...(patch.from ? { from: toExtendedPathKey(obj, patch.from) } : {}), path: toExtendedPathKey( obj, patch.path, patch.op === "add" || patch.op === "move" ? "insert" : undefined ), ...("value" in patch ? { value: patch.value } : {}), }; } export function toSimplePatch(obj: any, patch: ExtendedPatch): SimplePatch { return { op: patch.op, path: toSimplePath(obj, patch.path), ...("value" in patch ? { value: patch.value } : {}), ...(patch.from ? { from: toSimplePath(obj, patch.from) } : {}), ...(patch.length ? { length: patch.length } : {}), }; } export function applyExtendedPatch(obj: S, patches: ExtendedPatch[]): S { for (const patch of patches) { obj = apply(obj, [toSimplePatch(obj, patch)]); } return obj; } export function toExtendedPatches(obj: S, patches: SimplePatch[]) { const extendedPatches: ExtendedPatch[] = []; for (const patch of patches) { const extendedPatch = toExtendedPatch(obj, patch); obj = applyExtendedPatch(obj, [extendedPatch]); extendedPatches.push(extendedPatch); } return extendedPatches; } export function createExtended(obj: S, mutator: (state: Draft) => void) { const [updated, patches, inversePatches] = create(obj, mutator, { enablePatches: true, createArrayPatches, }); const extendedPatches = toExtendedPatches(obj, patches); const extendedInversePatches = toExtendedPatches(updated, inversePatches); return [updated, extendedPatches, extendedInversePatches] as const; } function identity(item: T): string { // return item as string; return typeof item === "object" && item !== null && "id" in item ? (item.id as string) : (item as string); } function createArrayPatches(params: CreateArrayPatchesParameters) { const arrayDiff = computeArrayDiff(params.original, params.copy, identity); const mapping = getArrayDiffMapping(params.original.length, arrayDiff); const itemPatches: Patch[] = []; const itemInversePatches: Patch[] = []; for (let i = 0; i < mapping.length; i++) { const originalIndex = mapping[i]; if ( originalIndex !== null && params.isAssigned(originalIndex) && params.original[originalIndex] !== params.copy[i] ) { const patches = Diff(params.original[originalIndex], params.copy[i]); const inversePatches = Diff( params.copy[i], params.original[originalIndex] ); // apply path prefix to patches const prefixedPatches = patches.map((patch) => ({ ...patch, path: [...params.basePath, originalIndex, ...patch.path], })); const prefixedInversePatches = inversePatches.map((patch) => ({ ...patch, path: [...params.basePath, originalIndex, ...patch.path], })); itemPatches.push(...prefixedPatches); itemInversePatches.push(...prefixedInversePatches); } } const inverseArrayDiff = computeArrayDiff( params.copy, params.original, identity ); function convert(item: ArrayDiffItem): Patch { switch (item[0]) { case "a": { const [, value, to] = item; return { op: "add", path: params.concatPath(to ?? "~"), value: params.cloneIfNeeded(value), }; } case "r": { return { op: "remove", path: params.concatPath(item[1]), }; } case "m": { return { op: "move", from: params.concatPath(item[1]), path: params.concatPath(item[2]), }; } } } const patches = [...itemPatches, ...arrayDiff.map(convert)]; const inversePatches = [ ...inverseArrayDiff.map(convert), ...itemInversePatches, ]; return { patches, inversePatches, }; }