/** * * @param textOld - old secret * @param textNew - new (updated) secret * @param diffPlusFlag - a flag for whether we want to detect moving segments * - doesn't work in some examples (e.g., when we have a full reverse ordering of the text) * @returns */ function patienceDiff(textOld: string[], textNew: string[], diffPlusFlag?: boolean) { /** * findUnique finds all unique values in arr[lo..hi], inclusive. This * function is used in preparation for determining the longest common * subsequence. Specifically, it first reduces the array range in question * to unique values. * @param chars - an array of characters * @param lo * @param hi * @returns - an ordered Map, with the arr[i] value as the Map key and the * array index i as the Map value. */ function findUnique(chars: string[], lo: number, hi: number) { const characterMap = new Map(); for (let i=lo; i<=hi; i++) { const character = chars[i]; if (characterMap.has(character)) { characterMap.get(character).count++; characterMap.get(character).index = i; } else { characterMap.set(character, { count: 1, index: i }); } } characterMap.forEach((val, key, map) => { if (val.count !== 1) { map.delete(key); } else { map.set(key, val.index); } }); return characterMap; } /** * @param aArray * @param aLo * @param aHi * @param bArray * @param bLo * @param bHi * @returns an ordered Map, with the Map key as the common line between aArray * and bArray, with the Map value as an object containing the array indexes of * the matching unique lines. * */ function uniqueCommon(aArray: string[], aLo: number, aHi: number, bArray: string[], bLo: number, bHi: number) { const ma = findUnique(aArray, aLo, aHi); const mb = findUnique(bArray, bLo, bHi); ma.forEach((val, key, map) => { if (mb.has(key)) { map.set(key, { indexA: val, indexB: mb.get(key) }); } else { map.delete(key); } }); return ma; } /** * longestCommonSubsequence takes an ordered Map from the function uniqueCommon * and determines the Longest Common Subsequence (LCS). * @param abMap * @returns an ordered array of objects containing the array indexes of the * matching lines for a LCS. */ function longestCommonSubsequence(abMap: Map) { const ja: any = []; // First, walk the list creating the jagged array. abMap.forEach((val, key, map) => { let i = 0; while (ja[i] && ja[i][ja[i].length - 1].indexB < val.indexB) { i++; } if (!ja[i]) { ja[i] = []; } if (0 < i) { val.prev = ja[i-1][ja[i - 1].length - 1]; } ja[i].push(val); }); // Now, pull out the longest common subsequence. let lcs: any[] = []; if (0 < ja.length) { const n = ja.length - 1; lcs = [ja[n][ja[n].length - 1]]; while (lcs[lcs.length - 1].prev) { lcs.push(lcs[lcs.length - 1].prev); } } return lcs.reverse(); } // "result" is the array used to accumulate the textOld that are deleted, the // lines that are shared between textOld and textNew, and the textNew that were // inserted. const result: any[] = []; let deleted = 0; let inserted = 0; // aMove and bMove will contain the lines that don't match, and will be returned // for possible searching of lines that moved. const aMove: any[] = []; const aMoveIndex: any[] = []; const bMove: any[] = []; const bMoveIndex: any[] = []; /** * addToResult simply pushes the latest value onto the "result" array. This * array captures the diff of the line, aIndex, and bIndex from the textOld * and textNew array. * @param aIndex * @param bIndex */ function addToResult(aIndex: number, bIndex: number) { if (bIndex < 0) { aMove.push(textOld[aIndex]); aMoveIndex.push(result.length); deleted++; } else if (aIndex < 0) { bMove.push(textNew[bIndex]); bMoveIndex.push(result.length); inserted++; } result.push({ line: 0 <= aIndex ? textOld[aIndex] : textNew[bIndex], aIndex: aIndex, bIndex: bIndex, }); } /** * addSubMatch handles the lines between a pair of entries in the LCS. Thus, * this function might recursively call recurseLCS to further match the lines * between textOld and textNew. * @param aLo * @param aHi * @param bLo * @param bHi */ function addSubMatch(aLo: number, aHi: number, bLo: number, bHi: number) { // Match any lines at the beginning of textOld and textNew. while (aLo <= aHi && bLo <= bHi && textOld[aLo] === textNew[bLo]) { addToResult(aLo++, bLo++); } // Match any lines at the end of textOld and textNew, but don't place them // in the "result" array just yet, as the lines between these matches at // the beginning and the end need to be analyzed first. const aHiTemp = aHi; while (aLo <= aHi && bLo <= bHi && textOld[aHi] === textNew[bHi]) { aHi--; bHi--; } // Now, check to determine with the remaining lines in the subsequence // whether there are any unique common lines between textOld and textNew. // // If not, add the subsequence to the result (all textOld having been // deleted, and all textNew having been inserted). // // If there are unique common lines between textOld and textNew, then let's // recursively perform the patience diff on the subsequence. const uniqueCommonMap = uniqueCommon(textOld, aLo, aHi, textNew, bLo, bHi); if (uniqueCommonMap.size === 0) { while (aLo <= aHi) { addToResult(aLo++, -1); } while (bLo <= bHi) { addToResult(-1, bLo++); } } else { recurseLCS(aLo, aHi, bLo, bHi, uniqueCommonMap); } // Finally, let's add the matches at the end to the result. while (aHi < aHiTemp) { addToResult(++aHi, ++bHi); } } /** * recurseLCS finds the longest common subsequence (LCS) between the arrays * textOld[aLo..aHi] and textNew[bLo..bHi] inclusive. Then for each subsequence * recursively performs another LCS search (via addSubMatch), until there are * none found, at which point the subsequence is dumped to the result. * @param aLo * @param aHi * @param bLo * @param bHi * @param uniqueCommonMap */ function recurseLCS(aLo: number, aHi: number, bLo: number, bHi: number, uniqueCommonMap?: any) { const x = longestCommonSubsequence(uniqueCommonMap || uniqueCommon(textOld, aLo, aHi, textNew, bLo, bHi)); if (x.length === 0) { addSubMatch(aLo, aHi, bLo, bHi); } else { if (aLo < x[0].indexA || bLo < x[0].indexB) { addSubMatch(aLo, x[0].indexA - 1, bLo, x[0].indexB - 1); } let i; for (i = 0; i < x.length - 1; i++) { addSubMatch(x[i].indexA, x[i+1].indexA - 1, x[i].indexB, x[i+1].indexB - 1); } if (x[i].indexA <= aHi || x[i].indexB <= bHi) { addSubMatch(x[i].indexA, aHi, x[i].indexB, bHi); } } } recurseLCS(0, textOld.length - 1, 0, textNew.length - 1); if (diffPlusFlag) { return { lines: result, lineCountDeleted: deleted, lineCountInserted: inserted, lineCountMoved: 0, aMove: aMove, aMoveIndex: aMoveIndex, bMove: bMove, bMoveIndex: bMoveIndex, }; } return { lines: result, lineCountDeleted: deleted, lineCountInserted: inserted, lineCountMoved: 0, }; } /** * use: patienceDiffPlus( textOld[], textNew[] ) * * where: * textOld[] contains the original text lines. * textNew[] contains the new text lines. * * returns an object with the following properties: * lines[] with properties of: * line containing the line of text from textOld or textNew. * aIndex referencing the index in aLine[]. * bIndex referencing the index in textNew[]. * (Note: The line is text from either textOld or textNew, with aIndex and bIndex * referencing the original index. If aIndex === -1 then the line is new from textNew, * and if bIndex === -1 then the line is old from textOld.) * moved is true if the line was moved from elsewhere in textOld[] or textNew[]. * lineCountDeleted is the number of lines from textOld[] not appearing in textNew[]. * lineCountInserted is the number of lines from textNew[] not appearing in textOld[]. * lineCountMoved is the number of lines that moved. */ function patienceDiffPlus(textOld: string[], textNew: string[]) { const difference = patienceDiff(textOld, textNew, true); let aMoveNext = difference.aMove; let aMoveIndexNext = difference.aMoveIndex; let bMoveNext = difference.bMove; let bMoveIndexNext = difference.bMoveIndex; delete difference.aMove; delete difference.aMoveIndex; delete difference.bMove; delete difference.bMoveIndex; let lastLineCountMoved; do { const aMove = aMoveNext; const aMoveIndex = aMoveIndexNext; const bMove = bMoveNext; const bMoveIndex = bMoveIndexNext; aMoveNext = []; aMoveIndexNext = []; bMoveNext = []; bMoveIndexNext = []; const subDiff = patienceDiff(aMove!, bMove!); lastLineCountMoved = difference.lineCountMoved; subDiff.lines.forEach((v, i) => { if (0 <= v.aIndex && 0 <= v.bIndex) { difference.lines[aMoveIndex![v.aIndex]].moved = true; difference.lines[bMoveIndex![v.bIndex]].aIndex = aMoveIndex![v.aIndex]; difference.lines[bMoveIndex![v.bIndex]].moved = true; difference.lineCountInserted--; difference.lineCountDeleted--; difference.lineCountMoved++; } else if (v.bIndex < 0) { aMoveNext!.push(aMove![v.aIndex]); aMoveIndexNext!.push(aMoveIndex![v.aIndex]); } else { bMoveNext!.push(bMove![v.bIndex]); bMoveIndexNext!.push(bMoveIndex![v.bIndex]); } }); } while (0 < difference.lineCountMoved - lastLineCountMoved); return difference; } export default patienceDiff;