@smartimpact-it/json-merge-shopify
Version:
378 lines (335 loc) • 10.8 kB
text/typescript
import Comparer, { ArrayElementPosition, ArrayElementResult } from './Comparer';
export interface ArrayMergerOptions {
base: any[];
ours: any[];
theirs: any[];
preferred?: SourceType | null;
filename?: string | null;
path?: string[];
deletionsAllowed?: boolean;
}
export interface ArrayMergerVariant {
seemsValid: boolean;
positions: Array<{
unionPosition: number;
basePosition: number | null;
oursPosition: number | null;
theirsPosition: number | null;
}>;
similarityScore: number;
}
export interface UnionArrayElementResult extends ArrayElementResult {
unionIndex: number;
}
const sourceNames = ['base', 'ours', 'theirs'] as const;
export type SourceType = 'base' | 'ours' | 'theirs';
export class ArrayMerger {
ours: any[];
theirs: any[];
base: any[];
union: any[];
preferred: SourceType;
filename: string | null;
path: string[] | null;
variants: ArrayMergerVariant[] | null = null;
objectPossiblePositions: UnionArrayElementResult[] | null = null;
deletionsAllowed: boolean = true;
constructor({
base,
ours,
theirs,
preferred,
filename,
path,
deletionsAllowed,
}: ArrayMergerOptions) {
this.base = base;
this.ours = ours;
this.theirs = theirs;
this.preferred = preferred || 'theirs';
this.filename = filename || null;
this.path = path || [];
this.union = this.getUnion();
this.deletionsAllowed = deletionsAllowed ?? true;
}
private getUnion(): any[] {
let union: any[];
if (this.preferred === 'ours') {
union = [...new Set([...this.ours, ...this.theirs])];
} else {
union = [...new Set([...this.theirs, ...this.ours])];
}
return union;
}
getObjectPositions(): UnionArrayElementResult[] {
if (this.objectPossiblePositions) {
return this.objectPossiblePositions;
}
const comparer = new Comparer();
const objectPositions: UnionArrayElementResult[] = [];
this.union.forEach((object, index) => {
const result = comparer.getArrayElementDiff3(object, {
base: this.base,
theirs: this.theirs,
ours: this.ours,
});
objectPositions.push({
unionIndex: index,
...result,
});
});
this.objectPossiblePositions = objectPositions;
return objectPositions;
}
private getPositionsAsArray(
position: ArrayElementPosition,
type: SourceType,
currentVariants: ArrayMergerVariant[],
): Array<number | { index: number; similarity: number } | null> {
if (position === null) {
return [null];
}
let positions: Array<{ index: number; similarity: number } | null>;
if (Array.isArray(position)) {
positions = position;
} else {
positions = [{ index: position, similarity: 1 }];
}
positions = positions.filter((position) => {
return !currentVariants.find((variant) => {
return variant.positions.find((variantPosition) => {
if (position === null) {
return false;
}
switch (type) {
case 'base':
return position.index === variantPosition.basePosition;
case 'ours':
return position.index === variantPosition.oursPosition;
case 'theirs':
return position.index === variantPosition.theirsPosition;
}
});
});
});
if (positions.length === 0) {
return [null];
}
return positions;
}
private getPositionIndex(
position: number | { index: number; similarity: number } | null,
): number | null {
return typeof position === 'object' && position != null
? position.index
: position;
}
private getPositionScore(
position: number | { index: number; similarity: number } | null,
): number | null {
return typeof position === 'object' && position != null
? position.similarity
: position !== null
? 1
: null;
}
private getNextVariants(
currentVariants: ArrayMergerVariant[],
unionIndex: number,
): ArrayMergerVariant[] {
if (unionIndex === this.union.length) {
return currentVariants;
}
const objectPositions = this.getObjectPositions();
const object = this.union[unionIndex];
const objectPosition = objectPositions.find(
(objectPosition) => objectPosition.unionIndex === unionIndex,
);
if (!objectPosition) {
return currentVariants;
}
// Create new variants for each possible position of the current object.
const newVariants: ArrayMergerVariant[] = [];
const { basePosition, oursPosition, theirsPosition } = objectPosition;
const basePositions = this.getPositionsAsArray(
basePosition,
'base',
currentVariants,
);
const oursPositions = this.getPositionsAsArray(
oursPosition,
'ours',
currentVariants,
);
const theirsPositions = this.getPositionsAsArray(
theirsPosition,
'theirs',
currentVariants,
);
basePositions.forEach((basePosition) => {
oursPositions.forEach((oursPosition) => {
theirsPositions.forEach((theirsPosition) => {
const basePositionIndex = this.getPositionIndex(basePosition);
const oursPositionIndex = this.getPositionIndex(oursPosition);
const theirsPositionIndex = this.getPositionIndex(theirsPosition);
const basePositionScore = this.getPositionScore(basePosition);
const oursPositionScore = this.getPositionScore(oursPosition);
const theirsPositionScore = this.getPositionScore(theirsPosition);
let similarityScore =
(basePositionScore || 0) +
(oursPositionScore || 0) +
(theirsPositionScore || 0);
// If the object is in the same position in base and ours or base and theirs,
// it is more likely to be valid.
if (basePosition != null) {
if (
basePositionIndex === oursPositionIndex ||
basePositionIndex === theirsPositionIndex
) {
similarityScore *= 1.5;
}
}
const variant = {
seemsValid: true,
positions: [
{
unionPosition: unionIndex,
basePosition: basePositionIndex,
oursPosition: oursPositionIndex,
theirsPosition: theirsPositionIndex,
},
],
similarityScore: similarityScore,
};
newVariants.push(variant);
});
});
});
// If there are no variants yet, go to the next object.
if (currentVariants.length === 0) {
return this.getNextVariants(newVariants, unionIndex + 1);
}
// Append new variants to the current variants.
const nextVariants: ArrayMergerVariant[] = [];
currentVariants.forEach((currentVariant) => {
newVariants.forEach((newVariant) => {
const variant = {
seemsValid: currentVariant.seemsValid && newVariant.seemsValid,
positions: [...currentVariant.positions, ...newVariant.positions],
similarityScore:
currentVariant.similarityScore + newVariant.similarityScore,
};
nextVariants.push(variant);
});
});
// Go to the next object.
return this.getNextVariants(nextVariants, unionIndex + 1);
}
getVariants(): ArrayMergerVariant[] {
if (this.variants) {
return this.variants;
}
this.variants = this.getNextVariants([], 0);
return this.variants;
}
generateFromVariant(variant: ArrayMergerVariant): any[] {
const positions = variant.positions;
const result: any[] = [];
positions.forEach((position) => {
// Deleted object.
if (
position.basePosition !== null &&
(position.oursPosition === null ||
(position.theirsPosition === null && this.deletionsAllowed))
) {
return;
}
// Added object.
if (
position.basePosition === null &&
(position.oursPosition !== null || position.theirsPosition !== null)
) {
const ourObject = position.oursPosition
? this.ours[position.oursPosition]
: null;
const theirObject = position.theirsPosition
? this.theirs[position.theirsPosition]
: null;
result.push(this.getMergedObject(theirObject, ourObject));
return;
}
// Modified object.
let object;
if (position.theirsPosition !== null && position.oursPosition !== null) {
object = this.getMergedObject(
this.theirs[position.theirsPosition],
this.ours[position.oursPosition],
);
} else {
object = this.union[position.unionPosition];
}
result.push(object);
});
// Add objects that are missing from the variant.
const sources: Array<'ours' | 'theirs'> =
this.preferred === 'ours' ? ['ours', 'theirs'] : ['theirs', 'ours'];
sources.forEach((source: 'ours' | 'theirs') => {
const handledIndexes: number[] = positions
.map((position) => {
return position[`${source}Position`]!;
})
.filter((position) => position !== null);
for (let i = 0; i < this[source].length; i++) {
if (!handledIndexes.includes(i)) {
result.push(this[source][i]);
}
}
});
return result;
}
getMergedObject(theirs: any, ours: any): any {
const oursIsObject = typeof ours === 'object' || ours == null;
const theirsIsObject = typeof theirs === 'object' || theirs == null;
// If one of the objects is not an object, return the other one.
if ((!oursIsObject && !theirsIsObject) || oursIsObject != theirsIsObject) {
return this.preferred === 'ours' ? ours : theirs;
}
theirs = theirs || {};
ours = ours || {};
// If both objects are objects, merge them.
if (this.preferred === 'ours') {
return {
...theirs,
...ours,
};
} else {
return {
...ours,
...theirs,
};
}
}
merge(): any[] {
const variants = this.getVariants()
.sort((a, b) => {
return b.similarityScore - a.similarityScore;
})
.map((variant) => {
return {
...variant,
positions: variant.positions.filter((position) => {
return (
position.oursPosition !== null || position.theirsPosition !== null
);
}),
};
});
const bestVariant = variants[0];
if (!bestVariant) {
throw new Error('No valid variant found.');
}
const result = this.generateFromVariant(bestVariant);
return result;
}
}
export default ArrayMerger;