/** * Mainly ported from difflib.py, the standard diff library of Python. * This code is distributed under the Python Software Foundation License. * Contributor(s): Sutou Kouhei (porting) * YUKI "Piro" Hiroshi * (encoded diff, DOM Updater) * ------------------------------------------------------------------------ * Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009 * Python Software Foundation. * All rights reserved. * * Copyright (c) 2000 BeOpen.com. * All rights reserved. * * Copyright (c) 1995-2001 Corporation for National Research Initiatives. * All rights reserved. * * Copyright (c) 1991-1995 Stichting Mathematisch Centrum. * All rights reserved. */ export class SequenceMatcher { constructor(from, to, junkPredicate) { this.from = from; this.to = to; this.junkPredicate = junkPredicate; this._updateToIndexes(); } longestMatch(fromStart, fromEnd, toStart, toEnd) { let bestInfo = this._findBestMatchPosition(fromStart, fromEnd, toStart, toEnd); const haveJunk = Object.keys(this.junks).length > 0; if (haveJunk) { const adjust = this._adjustBestInfoWithJunkPredicate; const args = [fromStart, fromEnd, toStart, toEnd]; bestInfo = adjust.apply(this, [false, bestInfo].concat(args)); bestInfo = adjust.apply(this, [true, bestInfo].concat(args)); } return bestInfo; } matches() { if (!this._matches) this._matches = this._computeMatches(); return this._matches; } blocks() { if (!this._blocks) this._blocks = this._computeBlocks(); return this._blocks; } operations() { if (!this._operations) this._operations = this._computeOperations(); return this._operations; } groupedOperations(contextSize) { if (!contextSize) contextSize = 3; let operations = this.operations(); if (operations.length == 0) operations = [['equal', 0, 0, 0, 0]]; operations = this._expandEdgeEqualOperations(operations, contextSize); const groupWindow = contextSize * 2; const groups = []; let group = []; for (const operation of operations) { const tag = operation[0]; let fromStart = operation[1]; const fromEnd = operation[2]; let toStart = operation[3]; const toEnd = operation[4]; if (tag == 'equal' && fromEnd - fromStart > groupWindow) { group.push([tag, fromStart, Math.min(fromEnd, fromStart + contextSize), toStart, Math.min(toEnd, toStart + contextSize)]); groups.push(group); group = []; fromStart = Math.max(fromStart, fromEnd - contextSize); toStart = Math.max(toStart, toEnd - contextSize); } group.push([tag, fromStart, fromEnd, toStart, toEnd]); } if (group.length > 0) groups.push(group); return groups; } ratio() { if (!this._ratio) this._ratio = this._computeRatio(); return this._ratio; } _updateToIndexes() { this.toIndexes = {}; this.junks = {}; for (let i = 0, length = this.to.length; i < length; i++) { const item = this.to[i]; if (!this.toIndexes[item]) this.toIndexes[item] = []; this.toIndexes[item].push(i); } if (!this.junkPredicate) return; const toIndexesWithoutJunk = {}; for (const item in this.toIndexes) { if (this.junkPredicate(item)) { this.junks[item] = true; } else { toIndexesWithoutJunk[item] = this.toIndexes[item]; } } this.toIndexes = toIndexesWithoutJunk; } _findBestMatchPosition(fromStart, fromEnd, toStart, toEnd) { let bestFrom = fromStart; let bestTo = toStart; let bestSize = 0; let lastSizes = {}; let fromIndex; for (fromIndex = fromStart; fromIndex <= fromEnd; fromIndex++) { const sizes = {}; const toIndexes = this.toIndexes[this.from[fromIndex]] || []; for (let i = 0, length = toIndexes.length; i < length; i++) { const toIndex = toIndexes[i]; if (toIndex < toStart) continue; if (toIndex > toEnd) break; const size = sizes[toIndex] = (lastSizes[toIndex - 1] || 0) + 1; if (size > bestSize) { bestFrom = fromIndex - size + 1; bestTo = toIndex - size + 1; bestSize = size; } } lastSizes = sizes; } return [bestFrom, bestTo, bestSize]; } _adjustBestInfoWithJunkPredicate(shouldJunk, bestInfo, fromStart, fromEnd, toStart, toEnd) { let [bestFrom, bestTo, bestSize] = bestInfo; while (bestFrom > fromStart && bestTo > toStart && (shouldJunk ? this.junks[this.to[bestTo - 1]] : !this.junks[this.to[bestTo - 1]]) && this.from[bestFrom - 1] == this.to[bestTo - 1]) { bestFrom -= 1; bestTo -= 1; bestSize += 1; } while (bestFrom + bestSize < fromEnd && bestTo + bestSize < toEnd && (shouldJunk ? this.junks[this.to[bestTo + bestSize]] : !this.junks[this.to[bestTo + bestSize]]) && this.from[bestFrom + bestSize] == this.to[bestTo + bestSize]) { bestSize += 1; } return [bestFrom, bestTo, bestSize]; } _computeMatches() { const matches = []; const queue = [[0, this.from.length, 0, this.to.length]]; while (queue.length > 0) { const target = queue.pop(); const [fromStart, fromEnd, toStart, toEnd] = target; const match = this.longestMatch(fromStart, fromEnd - 1, toStart, toEnd - 1); const matchFromIndex = match[0]; const matchToIndex = match[1]; const size = match[2]; if (size > 0) { if (fromStart < matchFromIndex && toStart < matchToIndex) queue.push([fromStart, matchFromIndex, toStart, matchToIndex]); matches.push(match); if (matchFromIndex + size < fromEnd && matchToIndex + size < toEnd) queue.push([matchFromIndex + size, fromEnd, matchToIndex + size, toEnd]); } } matches.sort((matchInfo1, matchInfo2) => { const fromIndex1 = matchInfo1[0]; const fromIndex2 = matchInfo2[0]; return fromIndex1 - fromIndex2; }); return matches; } _computeBlocks() { const blocks = []; let currentFromIndex = 0; let currentToIndex = 0; let currentSize = 0; for (const match of this.matches()) { const [fromIndex, toIndex, size] = match; if (currentFromIndex + currentSize == fromIndex && currentToIndex + currentSize == toIndex) { currentSize += size; } else { if (currentSize > 0) blocks.push([currentFromIndex, currentToIndex, currentSize]); currentFromIndex = fromIndex; currentToIndex = toIndex; currentSize = size; } } if (currentSize > 0) blocks.push([currentFromIndex, currentToIndex, currentSize]); blocks.push([this.from.length, this.to.length, 0]); return blocks; } _computeOperations() { let fromIndex = 0; let toIndex = 0; const operations = []; for (const block of this.blocks()) { const [matchFromIndex, matchToIndex, size] = block; const tag = this._determineTag(fromIndex, toIndex, matchFromIndex, matchToIndex); if (tag != 'equal') operations.push([tag, fromIndex, matchFromIndex, toIndex, matchToIndex]); fromIndex = matchFromIndex + size; toIndex = matchToIndex + size; if (size > 0) operations.push(['equal', matchFromIndex, fromIndex, matchToIndex, toIndex]); } return operations; } _determineTag(fromIndex, toIndex, matchFromIndex, matchToIndex) { if (fromIndex < matchFromIndex && toIndex < matchToIndex) { return 'replace'; } else if (fromIndex < matchFromIndex) { return 'delete'; } else if (toIndex < matchToIndex) { return 'insert'; } else { return 'equal'; } } _expandEdgeEqualOperations(operations, contextSize) { const expandedOperations = []; for (let index = 0, length = operations.length; index < length; index++) { const operation = operations[index]; const [tag, fromStart, fromEnd, toStart, toEnd] = operation; if (tag == 'equal' && index == 0) { expandedOperations.push([tag, Math.max(fromStart, fromEnd - contextSize), fromEnd, Math.max(toStart, toEnd - contextSize), toEnd]); } else if (tag == 'equal' && index == length - 1) { expandedOperations.push([tag, fromStart, Math.min(fromEnd, fromStart + contextSize), toStart, Math.min(toEnd, toStart + contextSize), toEnd]); } else { expandedOperations.push(operation); } } return expandedOperations; } _computeRatio() { const length = this.from.length + this.to.length; if (length == 0) return 1.0; let matches = 0; for (const block of this.blocks()) { const size = block[2]; matches += size; } return 2.0 * matches / length; } }; export class ReadableDiffer { constructor(from, to) { this.from = from; this.to = to; } diff() { let lines = []; const matcher = new SequenceMatcher(this.from, this.to); for (const operation of matcher.operations()) { const [tag, fromStart, fromEnd, toStart, toEnd] = operation; let target; switch (tag) { case 'replace': target = this._diffLines(fromStart, fromEnd, toStart, toEnd); lines = lines.concat(target); break; case 'delete': target = this.from.slice(fromStart, fromEnd); lines = lines.concat(this._tagDeleted(target)); break; case 'insert': target = this.to.slice(toStart, toEnd); lines = lines.concat(this._tagInserted(target)); break; case 'equal': target = this.from.slice(fromStart, fromEnd); lines = lines.concat(this._tagEqual(target)); break; default: throw 'unknown tag: ' + tag; break; } } return lines; } encodedDiff() { let lines = []; const matcher = new SequenceMatcher(this.from, this.to); for (const operation of matcher.operations()) { const [tag, fromStart, fromEnd, toStart, toEnd] = operation; let target; switch (tag) { case 'replace': target = this._diffLines(fromStart, fromEnd, toStart, toEnd, true); lines = lines.concat(target); break; case 'delete': target = this.from.slice(fromStart, fromEnd); lines = lines.concat(this._tagDeleted(target, true)); break; case 'insert': target = this.to.slice(toStart, toEnd); lines = lines.concat(this._tagInserted(target, true)); break; case 'equal': target = this.from.slice(fromStart, fromEnd); lines = lines.concat(this._tagEqual(target, true)); break; default: throw new Error(`unknown tag: ${tag}`); break; } } const blocks = []; let lastBlock = ''; let lastLineType = ''; for (const line of lines) { const lineType = line.match(/^' : '' )); lastBlock = ``; lastLineType = lineType; } lastBlock += line; } if (lastBlock) blocks.push(`${lastBlock}`); return blocks.join(''); } _tagLine(mark, contents) { return contents.map(content => `${mark} ${content}`); } _encodedTagLine(encodedClass, contents) { return contents.map(content => `${this._escapeForEncoded(content)}`); } _escapeForEncoded(string) { return string .replace(/&/g, '&') .replace(//g, '>'); } _tagDeleted(contents, encoded) { return encoded ? this._encodedTagLine('deleted', contents) : this._tagLine('-', contents); } _tagInserted(contents, encoded) { return encoded ? this._encodedTagLine('inserted', contents) : this._tagLine('+', contents); } _tagEqual(contents, encoded) { return encoded ? this._encodedTagLine('equal', contents) : this._tagLine(' ', contents); } _tagDifference(contents, encoded) { return encoded ? this._encodedTagLine('difference', contents) : this._tagLine('?', contents); } _findDiffLineInfo(fromStart, fromEnd, toStart, toEnd) { let bestRatio = 0.74; let fromEqualIndex, toEqualIndex; let fromBestIndex, toBestIndex; for (let toIndex = toStart; toIndex < toEnd; toIndex++) { for (let fromIndex = fromStart; fromIndex < fromEnd; fromIndex++) { if (this.from[fromIndex] == this.to[toIndex]) { if (fromEqualIndex === undefined) fromEqualIndex = fromIndex; if (toEqualIndex === undefined) toEqualIndex = toIndex; continue; } const matcher = new SequenceMatcher(this.from[fromIndex], this.to[toIndex], this._isSpaceCharacter); if (matcher.ratio() > bestRatio) { bestRatio = matcher.ratio(); fromBestIndex = fromIndex; toBestIndex = toIndex; } } } return [bestRatio, fromEqualIndex, toEqualIndex, fromBestIndex, toBestIndex]; } _diffLines(fromStart, fromEnd, toStart, toEnd, encoded) { const cutOff = 0.75; const info = this._findDiffLineInfo(fromStart, fromEnd, toStart, toEnd); let bestRatio = info[0]; const fromEqualIndex = info[1]; const toEqualIndex = info[2]; let fromBestIndex = info[3]; let toBestIndex = info[4]; if (bestRatio < cutOff) { if (fromEqualIndex === undefined) { const taggedFrom = this._tagDeleted(this.from.slice(fromStart, fromEnd), encoded); const taggedTo = this._tagInserted(this.to.slice(toStart, toEnd), encoded); if (toEnd - toStart < fromEnd - fromStart) return taggedTo.concat(taggedFrom); else return taggedFrom.concat(taggedTo); } fromBestIndex = fromEqualIndex; toBestIndex = toEqualIndex; bestRatio = 1.0; } return [].concat( this.__diffLines(fromStart, fromBestIndex, toStart, toBestIndex, encoded), (encoded ? this._diffLineEncoded(this.from[fromBestIndex], this.to[toBestIndex]) : this._diffLine(this.from[fromBestIndex], this.to[toBestIndex]) ), this.__diffLines(fromBestIndex + 1, fromEnd, toBestIndex + 1, toEnd, encoded) ); } __diffLines(fromStart, fromEnd, toStart, toEnd, encoded) { if (fromStart < fromEnd) { if (toStart < toEnd) { return this._diffLines(fromStart, fromEnd, toStart, toEnd, encoded); } else { return this._tagDeleted(this.from.slice(fromStart, fromEnd), encoded); } } else { return this._tagInserted(this.to.slice(toStart, toEnd), encoded); } } _diffLineEncoded(fromLine, toLine) { const fromChars = fromLine.split(''); const toChars = toLine.split(''); const matcher = new SequenceMatcher(fromLine, toLine, this._isSpaceCharacter); const phrases = []; for (const operation of matcher.operations()) { const [tag, fromStart, fromEnd, toStart, toEnd] = operation; const fromPhrase = fromChars.slice(fromStart, fromEnd).join(''); const toPhrase = toChars.slice(toStart, toEnd).join(''); switch (tag) { case 'replace': case 'delete': case 'insert': case 'equal': phrases.push({ tag : tag, from : fromPhrase, encodedFrom : this._escapeForEncoded(fromPhrase), to : toPhrase, encodedTo : this._escapeForEncoded(toPhrase), }); break; default: throw new Error(`unknown tag: ${tag}`); } } const encodedPhrases = []; let current; let replaced = 0; let inserted = 0; let deleted = 0; for (let i = 0, maxi = phrases.length; i < maxi; i++) { current = phrases[i]; switch (current.tag) { case 'replace': encodedPhrases.push(''); encodedPhrases.push(this._encodedTagPhrase('deleted', current.encodedFrom)); encodedPhrases.push(this._encodedTagPhrase('inserted', current.encodedTo)); encodedPhrases.push(''); replaced++; break; case 'delete': encodedPhrases.push(this._encodedTagPhrase('deleted', current.encodedFrom)); deleted++; break; case 'insert': encodedPhrases.push(this._encodedTagPhrase('inserted', current.encodedTo)); inserted++; break; case 'equal': // \95ύX\93_\82̊Ԃɋ\B2\82܂ꂽ1\95\B6\8E\9A\82\BE\82\AF\82̖\B3\95ύX\95\94\95\AA\82\BE\82\AF\82͓\C1\95ʈ\B5\82\A2 if ( current.from.length == 1 && (i > 0 && phrases[i-1].tag != 'equal') && (i < maxi-1 && phrases[i+1].tag != 'equal') ) { encodedPhrases.push(''); encodedPhrases.push(this._encodedTagPhrase('duplicated', current.encodedFrom)); encodedPhrases.push(this._encodedTagPhrase('duplicated', current.encodedTo)); encodedPhrases.push(''); } else { encodedPhrases.push(current.encodedFrom); } break; } } const extraClass = (replaced || (deleted && inserted)) ? ' includes-both-modification' : '' ; return [ `${encodedPhrases.join('')}` ]; } _encodedTagPhrase(encodedClass, content) { return `${content}`; } _diffLine(fromLine, toLine) { let fromTags = ''; let toTags = ''; const matcher = new SequenceMatcher(fromLine, toLine, this._isSpaceCharacter); for (const operation of matcher.operations()) { const [tag, fromStart, fromEnd, toStart, toEnd] = operation; const fromLength = fromEnd - fromStart; const toLength = toEnd - toStart; switch (tag) { case 'replace': fromTags += this._repeat('^', fromLength); toTags += this._repeat('^', toLength); break; case 'delete': fromTags += this._repeat('-', fromLength); break; case 'insert': toTags += this._repeat('+', toLength); break; case 'equal': fromTags += this._repeat(' ', fromLength); toTags += this._repeat(' ', toLength); break; default: throw new Error(`unknown tag: ${tag}`); break; } } return this._formatDiffPoint(fromLine, toLine, fromTags, toTags); } _formatDiffPoint(fromLine, toLine, fromTags, toTags) { let common; let result; common = Math.min(this._nLeadingCharacters(fromLine, '\t'), this._nLeadingCharacters(toLine, '\t')); common = Math.min(common, this._nLeadingCharacters(fromTags.slice(0, common), ' ')); fromTags = fromTags.slice(common).replace(/\s*$/, ''); toTags = toTags.slice(common).replace(/\s*$/, ''); result = this._tagDeleted([fromLine]); if (fromTags.length > 0) { fromTags = this._repeat('\t', common) + fromTags; result = result.concat(this._tagDifference([fromTags])); } result = result.concat(this._tagInserted([toLine])); if (toTags.length > 0) { toTags = this._repeat('\t', common) + toTags; result = result.concat(this._tagDifference([toTags])); } return result; } _nLeadingCharacters(string, character) { let n = 0; while (string[n] == character) { n++; } return n; } _isSpaceCharacter(character) { return character == ' ' || character == '\t'; } _repeat(string, n) { let result = ''; for (; n > 0; n--) { result += string; } return result; } }; export const Diff = { readable(from, to, encoded) { const differ = new ReadableDiffer(this._splitWithLine(from), this._splitWithLine(to)); return encoded ? differ.encodedDiff() : differ.diff(encoded).join('\n') ; }, foldedReadable(from, to, encoded) { const differ = new ReadableDiffer(this._splitWithLine(this._fold(from)), this._splitWithLine(this._fold(to))); return encoded ? differ.encodedDiff() : differ.diff(encoded).join('\n') ; }, isInterested(diff) { if (!diff) return false; if (diff.length == 0) return false; if (!diff.match(/^[-+]/mg)) return false; if (diff.match(/^[ ?]/mg)) return true; if (diff.match(/(?:.*\n){2,}/g)) return true; if (this.needFold(diff)) return true; return false; }, needFold(diff) { if (!diff) return false; if (diff.match(/^[-+].{79}/mg)) return true; return false; }, _splitWithLine(string) { string = String(string); return string.length == 0 ? [] : string.split(/\r?\n/); }, _fold(string) { string = String(string); const foldedLines = string.split('\n').map(line => line.replace(/(.{78})/g, '$1\n')); return foldedLines.join('\n'); } }; export const DOMUpdater = { /** * method * @param before {Node} - the node to be updated, e.g. Element * @param after {Node} - the node describing updated state, * e.g. DocumentFragment * @return count {number} - the count of appied changes */ update(before, after, counter = { count: 0 }) { if (before.nodeValue !== null || after.nodeValue !== null) { if (before.nodeValue != after.nodeValue) { //console.log('node value: ', after.nodeValue); before.nodeValue = after.nodeValue; counter.count++; } return counter.count; } const beforeNodes = Array.from(before.childNodes, this._getDiffableNodeString); const afterNodes = Array.from(after.childNodes, this._getDiffableNodeString); const nodeOerations = (new SequenceMatcher(beforeNodes, afterNodes)).operations(); // Update from back to front for safety! for (const operation of nodeOerations.reverse()) { const [tag, fromStart, fromEnd, toStart, toEnd] = operation; switch (tag) { case 'equal': for (let i = 0, maxi = fromEnd - fromStart; i < maxi; i++) { this.update( before.childNodes[fromStart + i], after.childNodes[toStart + i], counter ); } break; case 'delete': for (let i = fromEnd - 1; i >= fromStart; i--) { //console.log('delete: delete node: ', i, before.childNodes[i]); before.removeChild(before.childNodes[i]); counter.count++; } break; case 'insert': { const reference = before.childNodes[fromStart] || null; for (let i = toStart; i < toEnd; i++) { if (!after.childNodes[i]) continue; //console.console.log('insert: insert node: ', i, after.childNodes[i]); before.insertBefore(after.childNodes[i].cloneNode(true), reference); counter.count++; } }; break; case 'replace': { for (let i = fromEnd - 1; i >= fromStart; i--) { //console.log('replace: delete node: ', i, before.childNodes[i]); before.removeChild(before.childNodes[i]); counter.count++; } const reference = before.childNodes[fromStart] || null; for (let i = toStart; i < toEnd; i++) { if (!after.childNodes[i]) continue; //console.log('replace: insert node: ', i, after.childNodes[i]); before.insertBefore(after.childNodes[i].cloneNode(true), reference); counter.count++; } }; break; } } if (before.nodeType == before.ELEMENT_NODE && after.nodeType == after.ELEMENT_NODE) { const beforeAttrs = Array.from(before.attributes, attr => `${attr.name}:${attr.value}`). sort(); const afterAttrs = Array.from(after.attributes, attr => `${attr.name}:${attr.value}`). sort(); const attrOerations = (new SequenceMatcher(beforeAttrs, afterAttrs)).operations(); for (const operation of attrOerations) { const [tag, fromStart, fromEnd, toStart, toEnd] = operation; switch (tag) { case 'equal': break; case 'delete': for (let i = fromStart; i < fromEnd; i++) { const name = beforeAttrs[i].split(':')[0]; //console.log('delete: delete attr: ', name); before.removeAttribute(name); counter.count++; } break; case 'insert': for (let i = toStart; i < toEnd; i++) { const attr = afterAttrs[i].split(':'); const name = attr[0]; const value = attr.slice(1).join(':'); //console.log('insert: set attr: ', name, value); before.setAttribute(name, value); counter.count++; } break; case 'replace': const insertedAttrs = new Set(); for (let i = toStart; i < toEnd; i++) { const attr = afterAttrs[i].split(':'); const name = attr[0]; const value = attr.slice(1).join(':'); //console.log('replace: set attr: ', name, value); before.setAttribute(name, value); insertedAttrs.add(name); counter.count++; } for (let i = fromStart; i < fromEnd; i++) { const name = beforeAttrs[i].split(':')[0]; if (insertedAttrs.has(name)) continue; //console.log('replace: delete attr: ', name); before.removeAttribute(name); counter.count++; } break; } } } return counter.count; }, _getDiffableNodeString(node) { if (node.nodeType == node.ELEMENT_NODE) return `element:${node.tagName}#${node.id}#${node.getAttribute('anonid')}`; else return `node:${node.nodeType}`; } };