ai-chatbot/lib/video-canvas/merge-snapshot.ts

112 lines
4.1 KiB
TypeScript

export type CanvasConflictChoice = "local" | "remote";
const absent = Symbol("absent");
const record = (v: any) => v !== absent && v !== null && typeof v === "object" && !Array.isArray(v);
const same = (a: any, b: any): boolean => {
if (a === b) return true;
if (record(a) && record(b)) {
const keys = Object.keys(a);
return (
keys.length === Object.keys(b).length &&
keys.every((k) => Object.hasOwn(b, k) && same(a[k], b[k]))
);
}
return (
Array.isArray(a) &&
Array.isArray(b) &&
a.length === b.length &&
a.every((v, i) => same(v, b[i]))
);
};
/** Three-way merge. Never resolve edits to the same field implicitly. */
export function mergeCanvasSnapshots(
baseText: string,
localText: string,
remoteText: string,
choice?: CanvasConflictChoice,
) {
const conflicts: string[] = [];
function merge(base: any, local: any, remote: any, path: string): any {
// View and selection belong to this tab, not shared document content.
if (["canvasData.viewport", "canvasData.selectedNodeIds", "canvasData.tool"].includes(path))
return local;
if (same(local, remote) || same(base, remote)) return local;
if (same(base, local)) return remote;
if (record(base) && record(local) && record(remote)) {
const output: Record<string, any> = {};
for (const key of new Set([
...Object.keys(base),
...Object.keys(local),
...Object.keys(remote),
])) {
if (["__proto__", "prototype", "constructor"].includes(key)) continue;
const result = merge(
Object.hasOwn(base, key) ? base[key] : absent,
Object.hasOwn(local, key) ? local[key] : absent,
Object.hasOwn(remote, key) ? remote[key] : absent,
path ? `${path}.${key}` : key,
);
if (result !== absent) output[key] = result;
}
return output;
}
const keyed = (v: any) =>
Array.isArray(v) &&
v.every((item) => record(item) && typeof item.id === "string") &&
new Set(v.map((item) => item.id)).size === v.length;
if (keyed(base) && keyed(local) && keyed(remote)) {
const b = new Map(base.map((n: any) => [n.id, n])),
l = new Map(local.map((n: any) => [n.id, n])),
r = new Map(remote.map((n: any) => [n.id, n]));
const order = (items: any[]) =>
items.filter((n) => b.has(n.id) && l.has(n.id) && r.has(n.id)).map((n) => n.id);
const bo = order(base),
lo = order(local),
ro = order(remote);
let primary = local;
if (same(lo, bo)) primary = remote;
else if (!same(ro, bo) && !same(lo, ro)) {
conflicts.push(`${path}.顺序`);
primary = choice === "remote" ? remote : local;
}
const ids = new Set([
...primary.map((n: any) => n.id),
...remote.map((n: any) => n.id),
...local.map((n: any) => n.id),
...base.map((n: any) => n.id),
]);
return [...ids]
.map((id) =>
merge(
b.has(id) ? b.get(id) : absent,
l.has(id) ? l.get(id) : absent,
r.has(id) ? r.get(id) : absent,
`${path}[${id}]`,
),
)
.filter((v) => v !== absent);
}
conflicts.push(path || "画布");
return choice === "remote" ? remote : local;
}
const parse = (text: string) => {
const value = JSON.parse(text);
for (const node of value.canvasData?.nodes || []) {
delete node.createdAt;
delete node.updatedAt;
}
return value;
};
const local = parse(localText);
const snapshot = merge(parse(baseText), local, parse(remoteText), "");
if (snapshot.canvasData && local.canvasData)
for (const key of ["viewport", "selectedNodeIds", "tool"])
if (Object.hasOwn(local.canvasData, key)) snapshot.canvasData[key] = local.canvasData[key];
if (Array.isArray(snapshot.canvasData?.nodes) && Array.isArray(snapshot.canvasData?.edges)) {
const ids = new Set(snapshot.canvasData.nodes.map((n: any) => n.id));
snapshot.canvasData.edges = snapshot.canvasData.edges.filter(
(e: any) => ids.has(e.source) && ids.has(e.target),
);
}
return { snapshot: JSON.stringify(snapshot), conflicts };
}