2018-09-09 18:52:47 +02:00
import { Injectable } from '@angular/core' ;
2019-01-31 08:20:15 +01:00
2018-09-09 18:52:47 +02:00
import { LinenumberingService } from './linenumbering.service' ;
2019-01-31 13:40:27 +01:00
import { ViewMotion } from '../../site/motions/models/view-motion' ;
import { ViewUnifiedChange } from '../../site/motions/models/view-unified-change' ;
2018-09-09 18:52:47 +02:00
const ELEMENT_NODE = 1 ;
const TEXT_NODE = 3 ;
const DOCUMENT_FRAGMENT_NODE = 11 ;
/ * *
* Indicates the type of a modification when comparing ( "diff" ing ) two versions of a text .
* - TYPE_INSERTION indicates an insertion . An insertion is when the new version of a text contains a certain string
* that did not exist in the original version of the .
* - TYPE_DELETION indicates a replacement . A deletion is when the new version of a text does not contain a certain
* string contained in the original version of the text anymore .
* - TYPE_REPLACEMENT indicates both of the above : the new version of the text contains text not present in the original
* version , but also removes some parts of that text .
*
* This enumeration is used when _automatically_ detecting the change type of an amendment / change recommendation .
* /
export enum ModificationType {
TYPE_REPLACEMENT ,
TYPE_INSERTION ,
TYPE_DELETION
}
/ * *
* This data structure is used when determining the most specific common ancestor of two HTML nodes ( ` node1 ` and ` node2 ` )
* within the same Document Fragment .
* /
interface CommonAncestorData {
/ * *
* The most specific common ancestor node .
* /
commonAncestor : Node ;
/ * *
* The nodes inbetween ` commonAncestor ` and the ` node1 ` in the DOM hierarchy . Empty , if node1 is a direct descendant .
* /
trace1 : Node [ ] ;
/ * *
* The nodes inbetween ` commonAncestor ` and the ` node2 ` in the DOM hierarchy . Empty , if node2 is a direct descendant .
* /
trace2 : Node [ ] ;
/ * *
* Starting the root node , this indicates the depth level of the ` commonAncestor ` .
* /
index : number ;
}
/ * *
* An object produced by ` extractRangeByLineNumbers ` ` . It contains both the extracted lines as well as
* information about the context in which these lines occur .
* This additional information is meant to render the snippet correctly without producing broken HTML
* /
interface ExtractedContent {
/ * *
* The HTML between the two line numbers . Line numbers and automatically set line breaks are stripped .
* All HTML tags are converted to uppercase
* ( e . g . Line 2 < / LI > < LI > Line3 < / LI > < LI > Line 4 < br > )
* /
html : string ;
/ * *
* The most specific DOM element that contains the HTML snippet ( e . g . a UL , if several LIs are selected )
* /
ancestor : Node ;
/ * *
* An HTML string that opens all necessary tags to get the browser into the rendering mode
* of the ancestor element ( e . g . < DIV > < UL > in the case of the multiple LIs )
* /
outerContextStart : string ;
/ * *
* An HTML string that closes all necessary tags from the ancestor element ( e . g . < / UL > < / DIV >
* /
outerContextEnd : string ;
/ * *
* A string that opens all necessary tags between the ancestor and the beginning of the selection ( e . g . < LI > )
* /
innerContextStart : string ;
/ * *
* A string that closes all tags after the end of the selection to the ancestor ( e . g . < / LI > )
* /
innerContextEnd : string ;
/ * *
* The HTML before the selected area begins ( including line numbers )
* /
previousHtml : string ;
/ * *
* A HTML snippet that closes all open tags from previousHtml
* /
previousHtmlEndSnippet : string ;
/ * *
* The HTML after the selected area
* /
followingHtml : string ;
/ * *
* A HTML snippet that opens all HTML tags necessary to render "followingHtml"
* /
followingHtmlStartSnippet : string ;
}
/ * *
* An object specifying a range of line numbers .
* /
2018-09-30 18:43:20 +02:00
export interface LineRange {
2018-09-09 18:52:47 +02:00
/ * *
* The first line number to be included .
* /
from : number ;
/ * *
* The end line number .
* HINT : As this object is usually referring to actual line numbers , not lines ,
* the line starting by ` to ` is not included in the extracted content anymore , only the text between ` from ` and ` to ` .
* /
to : number ;
}
2018-11-04 11:11:48 +01:00
/ * *
* An object representing a paragraph with some changed lines
* /
export interface DiffLinesInParagraph {
/ * *
* The paragraph number
* /
paragraphNo : number ;
/ * *
* The first line of the paragraph
* /
paragraphLineFrom : number ;
/ * *
* The end line number ( after the paragraph )
* /
paragraphLineTo : number ;
/ * *
* The first line number with changes
* /
diffLineFrom : number ;
/ * *
* The line number after the last change
* /
diffLineTo : number ;
/ * *
* The HTML of the not - changed lines before the changed ones
* /
textPre : string ;
/ * *
* The HTML of the changed lines
* /
text : string ;
/ * *
* The HTML of the not - changed lines after the changed ones
* /
textPost : string ;
}
2018-09-09 18:52:47 +02:00
/ * *
* Functionality regarding diffing , merging and extracting line ranges .
*
* # # Examples
*
* Cleaning up a string generated by CKEditor :
*
* ` ` ` ts
* this . diffService . removeDuplicateClassesInsertedByCkeditor ( motion . text )
* ` ` `
*
* Extracting a range specified by line numbers from a motion text :
*
* ` ` ` ts
* const lineLength = 80 ;
* const lineNumberedText = this . lineNumbering . insertLineNumbers ( '<p>A line</p><p>Another line</p><ul><li>A list item</li><li>Yet another item</li></ul>' , lineLength ) ;
* const extractFrom = 2 ;
* const extractUntil = 3 ;
* const extractedData = this . diffService . extractRangeByLineNumbers ( lineNumberedText , extractFrom , extractUntil )
* ` ` `
*
* Creating a valid HTML from such a extracted text , including line numbers :
*
* ` ` ` ts
* const extractedHtml = this . diffService . formatDiffWithLineNumbers ( extractedData , lineLength , extractFrom ) ;
* ` ` `
*
* Creating the diff between two html strings :
*
* ` ` ` ts
* const before = '<P>Lorem ipsum dolor sit amet, sed diam voluptua. At </P>' ;
* const beforeLineNumbered = this . lineNumbering . insertLineNumbers ( before , 80 )
* const after = '<P>Lorem ipsum dolor sit amet, sed diam voluptua. At2</P>' ;
* const diff = this . diffService . diff ( before , after ) ;
* ` ` ` ts
*
* Given a ( line numbered ) diff string , detect the line number range with changes :
*
* ` ` ` ts
* this . diffService . detectAffectedLineRange ( diff ) ;
* ` ` `
*
* Given a diff ' ed string , apply all changes to receive the new version of the text :
*
* ` ` ` ts
* const diffedHtml = '<p>Test <span class="delete">Test 2</span> Another test <del>Test 3</del></p><p class="delete">Test 4</p>' ;
* const newVersion = this . diffService . diffHtmlToFinalText ( diffedHtml ) ;
* ` ` `
*
* Replace a line number range in a text by new text :
*
* ` ` ` ts
* const lineLength = 80 ;
* const lineNumberedText = this . lineNumbering . insertLineNumbers ( '<p>A line</p><p>Another line</p><ul><li>A list item</li><li>Yet another item</li></ul>' , lineLength ) ;
* const merged = this . diffService . replaceLines ( lineNumberedText , '<p>Replaced paragraph</p>' , 1 , 2 ) ;
* ` ` `
* /
@Injectable ( {
providedIn : 'root'
} )
export class DiffService {
2018-09-30 18:43:20 +02:00
// @TODO Decide on a more sophisticated implementation
2018-09-09 18:52:47 +02:00
private diffCache = {
2018-09-30 18:43:20 +02:00
_cache : { } ,
get : ( key : string ) : any = > {
return this . diffCache . _cache [ key ] === undefined ? null : this . diffCache . _cache [ key ] ;
} ,
put : ( key : string , val : any ) : void = > {
this . diffCache . _cache [ key ] = val ;
}
} ;
2018-09-09 18:52:47 +02:00
/ * *
* Creates the DiffService .
*
* @param { LinenumberingService } lineNumberingService
* /
public constructor ( private readonly lineNumberingService : LinenumberingService ) { }
/ * *
* Searches for the line breaking node within the given Document specified by the given lineNumber .
* This is performed by using a querySelector .
*
* @param { DocumentFragment } fragment
* @param { number } lineNumber
* @returns { Element }
* /
public getLineNumberNode ( fragment : DocumentFragment , lineNumber : number ) : Element {
return fragment . querySelector ( 'os-linebreak.os-line-number.line-number-' + lineNumber ) ;
}
/ * *
* This returns the first line breaking node within the given node .
* If none is found , ` null ` is returned .
*
* @param { Node } node
* @returns { Element }
* /
private getFirstLineNumberNode ( node : Node ) : Element {
if ( node . nodeType === TEXT_NODE ) {
return null ;
}
const element = < Element > node ;
if ( element . nodeName === 'OS-LINEBREAK' ) {
return element ;
}
const found = element . querySelectorAll ( 'OS-LINEBREAK' ) ;
if ( found . length > 0 ) {
return found . item ( 0 ) ;
} else {
return null ;
}
}
/ * *
* This returns the last line breaking node within the given node .
* If none is found , ` null ` is returned .
*
* @param { Node } node
* @returns { Element }
* /
private getLastLineNumberNode ( node : Node ) : Element {
if ( node . nodeType === TEXT_NODE ) {
return null ;
}
const element = < Element > node ;
if ( element . nodeName === 'OS-LINEBREAK' ) {
return element ;
}
const found = element . querySelectorAll ( 'OS-LINEBREAK' ) ;
if ( found . length > 0 ) {
return found . item ( found . length - 1 ) ;
} else {
return null ;
}
}
/ * *
* Given a node , this method returns an array containing all parent elements of this node , recursively .
*
* @param { Node } node
* @returns { Node [ ] }
* /
private getNodeContextTrace ( node : Node ) : Node [ ] {
const context = [ ] ;
let currNode = node ;
while ( currNode ) {
context . unshift ( currNode ) ;
currNode = currNode . parentNode ;
}
return context ;
}
/ * *
* This method checks if the given ` child ` - Node is the first non - empty child element of the given parent Node
* called ` node ` . Hence the name of this method .
*
* @param node
* @param child
* /
private isFirstNonemptyChild ( node : Node , child : Node ) : boolean {
for ( let i = 0 ; i < node . childNodes . length ; i ++ ) {
if ( node . childNodes [ i ] === child ) {
return true ;
}
if ( node . childNodes [ i ] . nodeType !== TEXT_NODE || node . childNodes [ i ] . nodeValue . match ( /\S/ ) ) {
return false ;
}
}
return false ;
}
/ * *
* Adds elements like < OS - LINEBREAK class = "os-line-number line-number-23" data - line - number = "23" / >
* to a given fragment
*
* @param { DocumentFragment } fragment
* /
public insertInternalLineMarkers ( fragment : DocumentFragment ) : void {
if ( fragment . querySelectorAll ( 'OS-LINEBREAK' ) . length > 0 ) {
// Prevent duplicate calls
return ;
}
const lineNumbers = fragment . querySelectorAll ( 'span.os-line-number' ) ;
let lineMarker ,
maxLineNumber = 0 ;
lineNumbers . forEach ( ( insertBefore : Node ) = > {
const lineNumberElement = < Element > insertBefore ;
while (
insertBefore . parentNode . nodeType !== DOCUMENT_FRAGMENT_NODE &&
this . isFirstNonemptyChild ( insertBefore . parentNode , insertBefore )
) {
insertBefore = insertBefore . parentNode ;
}
lineMarker = document . createElement ( 'OS-LINEBREAK' ) ;
lineMarker . setAttribute ( 'data-line-number' , lineNumberElement . getAttribute ( 'data-line-number' ) ) ;
lineMarker . setAttribute ( 'class' , lineNumberElement . getAttribute ( 'class' ) ) ;
insertBefore . parentNode . insertBefore ( lineMarker , insertBefore ) ;
maxLineNumber = parseInt ( lineNumberElement . getAttribute ( 'data-line-number' ) , 10 ) ;
} ) ;
// Add one more "fake" line number at the end and beginning, so we can select the last line as well
lineMarker = document . createElement ( 'OS-LINEBREAK' ) ;
lineMarker . setAttribute ( 'data-line-number' , ( maxLineNumber + 1 ) . toString ( 10 ) ) ;
lineMarker . setAttribute ( 'class' , 'os-line-number line-number-' + ( maxLineNumber + 1 ) . toString ( 10 ) ) ;
fragment . appendChild ( lineMarker ) ;
lineMarker = document . createElement ( 'OS-LINEBREAK' ) ;
lineMarker . setAttribute ( 'data-line-number' , '0' ) ;
lineMarker . setAttribute ( 'class' , 'os-line-number line-number-0' ) ;
fragment . insertBefore ( lineMarker , fragment . firstChild ) ;
}
/ * *
* An OL element has a number of child LI nodes . Given a ` descendantNode ` that might be anywhere within
* the hierarchy of this OL element , this method returns the index ( starting with 1 ) of the LI element
* that contains this node .
*
* @param olNode
* @param descendantNode
* /
private isWithinNthLIOfOL ( olNode : Element , descendantNode : Node ) : number {
let nthLIOfOL = null ;
while ( descendantNode . parentNode ) {
if ( descendantNode . parentNode === olNode ) {
let lisBeforeOl = 0 ,
foundMe = false ;
for ( let i = 0 ; i < olNode . childNodes . length && ! foundMe ; i ++ ) {
if ( olNode . childNodes [ i ] === descendantNode ) {
foundMe = true ;
} else if ( olNode . childNodes [ i ] . nodeName === 'LI' ) {
lisBeforeOl ++ ;
}
}
nthLIOfOL = lisBeforeOl + 1 ;
}
descendantNode = descendantNode . parentNode ;
}
return nthLIOfOL ;
}
/ * *
* Returns information about the common ancestors of two given nodes .
*
* @param { Node } node1
* @param { Node } node2
* @returns { CommonAncestorData }
* /
public getCommonAncestor ( node1 : Node , node2 : Node ) : CommonAncestorData {
const trace1 = this . getNodeContextTrace ( node1 ) ,
trace2 = this . getNodeContextTrace ( node2 ) ,
childTrace1 = [ ] ,
childTrace2 = [ ] ;
let commonAncestor = null ,
commonIndex = null ;
for ( let i = 0 ; i < trace1 . length && i < trace2 . length ; i ++ ) {
if ( trace1 [ i ] === trace2 [ i ] ) {
commonAncestor = trace1 [ i ] ;
commonIndex = i ;
}
}
for ( let i = commonIndex + 1 ; i < trace1 . length ; i ++ ) {
childTrace1 . push ( trace1 [ i ] ) ;
}
for ( let i = commonIndex + 1 ; i < trace2 . length ; i ++ ) {
childTrace2 . push ( trace2 [ i ] ) ;
}
return {
commonAncestor : commonAncestor ,
trace1 : childTrace1 ,
trace2 : childTrace2 ,
index : commonIndex
} ;
}
/ * *
* This converts a HTML Node element into a rendered HTML string .
*
* @param { Node } node
* @returns { string }
* /
private serializeTag ( node : Node ) : string {
if ( node . nodeType !== ELEMENT_NODE ) {
// Fragments are only placeholders and do not have an HTML representation
return '' ;
}
const element = < Element > node ;
let html = '<' + element . nodeName ;
for ( let i = 0 ; i < element . attributes . length ; i ++ ) {
const attr = element . attributes [ i ] ;
if ( attr . name !== 'os-li-number' ) {
html += ' ' + attr . name + '="' + attr . value + '"' ;
}
}
html += '>' ;
return html ;
}
/ * *
* This converts the given HTML string into a DOM tree contained by a DocumentFragment , which is reqturned .
*
* @param { string } html
* @return { DocumentFragment }
* /
public htmlToFragment ( html : string ) : DocumentFragment {
const fragment = document . createDocumentFragment ( ) ,
div = document . createElement ( 'DIV' ) ;
div . innerHTML = html ;
while ( div . childElementCount ) {
const child = div . childNodes [ 0 ] ;
div . removeChild ( child ) ;
fragment . appendChild ( child ) ;
}
return fragment ;
}
/ * *
* This performs HTML normalization to prevent the Diff - Algorithm from detecting changes when there are actually
* none . Common problems covered by this method are differently ordered Attributes of HTML elements or HTML - encoded
* special characters .
* Unfortunately , the conversion of HTML - encoded characters to the actual characters is done by a lookup - table for
* now , as we haven ' t figured out a way to decode them automatically .
*
* @param { string } html
* @returns { string }
* @private
* /
public normalizeHtmlForDiff ( html : string ) : string {
// Convert all HTML tags to uppercase, but leave the values of attributes unchanged
// All attributes and CSS class names are sorted alphabetically
// If an attribute is empty, it is removed
html = html . replace (
/<(\/?[a-z]*)( [^>]*)?>/gi ,
( _fullHtml : string , tag : string , attributes : string ) : string = > {
const tagNormalized = tag . toUpperCase ( ) ;
if ( attributes === undefined ) {
attributes = '' ;
}
const attributesList = [ ] ,
attributesMatcher = /( [^"'=]*)(= *((["'])(.*?)\4))?/gi ;
let match ;
do {
match = attributesMatcher . exec ( attributes ) ;
if ( match ) {
let attrNormalized = match [ 1 ] . toUpperCase ( ) ,
attrValue = match [ 5 ] ;
if ( match [ 2 ] !== undefined ) {
if ( attrNormalized === ' CLASS' ) {
attrValue = attrValue
. split ( ' ' )
. sort ( )
. join ( ' ' )
. replace ( /^\s+/ , '' )
. replace ( /\s+$/ , '' ) ;
}
attrNormalized += '=' + match [ 4 ] + attrValue + match [ 4 ] ;
}
if ( attrValue !== '' ) {
attributesList . push ( attrNormalized ) ;
}
}
} while ( match ) ;
attributes = attributesList . sort ( ) . join ( '' ) ;
return '<' + tagNormalized + attributes + '>' ;
}
) ;
const entities = {
' ' : ' ' ,
'–' : '-' ,
'ä' : 'ä' ,
'ö' : 'ö' ,
'ü' : 'ü' ,
'Ä' : 'Ä' ,
'Ö' : 'Ö' ,
'Ü' : 'Ü' ,
'ß' : 'ß' ,
'„' : '„' ,
'“' : '“' ,
'•' : '•' ,
'§' : '§' ,
'é' : 'é' ,
2019-02-09 18:24:52 +01:00
'’' : '’ ' ,
2018-09-09 18:52:47 +02:00
'€' : '€'
} ;
html = html
. replace ( /\s+<\/P>/gi , '</P>' )
. replace ( /\s+<\/DIV>/gi , '</DIV>' )
. replace ( /\s+<\/LI>/gi , '</LI>' ) ;
html = html . replace ( /\s+<LI>/gi , '<LI>' ) . replace ( /<\/LI>\s+/gi , '</LI>' ) ;
html = html . replace ( /\u00A0/g , ' ' ) ;
html = html . replace ( /\u2013/g , '-' ) ;
Object . keys ( entities ) . forEach ( ent = > {
html = html . replace ( new RegExp ( ent , 'g' ) , entities [ ent ] ) ;
} ) ;
// Newline characters: after closing block-level-elements, but not after BR (which is inline)
html = html . replace ( /(<br *\/?>)\n/gi , '$1' ) ;
html = html . replace ( /[ \n\t]+/gi , ' ' ) ;
html = html . replace ( /(<\/(div|p|ul|li|blockquote>)>) /gi , '$1\n' ) ;
return html ;
}
/ * *
* Get all the siblings of the given node _after_ this node , in the order as they appear in the DOM tree .
*
* @param { Node } node
* @returns { Node [ ] }
* /
private getAllNextSiblings ( node : Node ) : Node [ ] {
const nodes : Node [ ] = [ ] ;
while ( node . nextSibling ) {
nodes . push ( node . nextSibling ) ;
node = node . nextSibling ;
}
return nodes ;
}
/ * *
* Get all the siblings of the given node _before_ this node ,
* with the one closest to the given node first ( = > reversed order in regard to the DOM tree order )
*
* @param { Node } node
* @returns { Node [ ] }
* /
private getAllPrevSiblingsReversed ( node : Node ) : Node [ ] {
const nodes = [ ] ;
while ( node . previousSibling ) {
nodes . push ( node . previousSibling ) ;
node = node . previousSibling ;
}
return nodes ;
}
/ * *
* Given two strings , this method tries to guess if ` htmlNew ` can be produced from ` htmlOld ` by inserting
* or deleting text , or if both is necessary ( replac )
*
* @param { string } htmlOld
* @param { string } htmlNew
* @returns { number }
* /
public detectReplacementType ( htmlOld : string , htmlNew : string ) : ModificationType {
htmlOld = this . normalizeHtmlForDiff ( htmlOld ) ;
htmlNew = this . normalizeHtmlForDiff ( htmlNew ) ;
if ( htmlOld === htmlNew ) {
return ModificationType . TYPE_REPLACEMENT ;
}
let i , foundDiff ;
for ( i = 0 , foundDiff = false ; i < htmlOld . length && i < htmlNew . length && foundDiff === false ; i ++ ) {
if ( htmlOld [ i ] !== htmlNew [ i ] ) {
foundDiff = true ;
}
}
const remainderOld = htmlOld . substr ( i - 1 ) ,
remainderNew = htmlNew . substr ( i - 1 ) ;
let type = ModificationType . TYPE_REPLACEMENT ;
if ( remainderOld . length > remainderNew . length ) {
if ( remainderOld . substr ( remainderOld . length - remainderNew . length ) === remainderNew ) {
type = ModificationType . TYPE_DELETION ;
}
} else if ( remainderOld . length < remainderNew . length ) {
if ( remainderNew . substr ( remainderNew . length - remainderOld . length ) === remainderOld ) {
type = ModificationType . TYPE_INSERTION ;
}
}
return type ;
}
/ * *
* This method adds a CSS class name to a given node .
*
* @param { Node } node
* @param { string } className
* /
public addCSSClass ( node : Node , className : string ) : void {
if ( node . nodeType !== ELEMENT_NODE ) {
return ;
}
const element = < Element > node ;
const classesStr = element . getAttribute ( 'class' ) ;
const classes = classesStr ? classesStr . split ( ' ' ) : [ ] ;
if ( classes . indexOf ( className ) === - 1 ) {
classes . push ( className ) ;
}
element . setAttribute ( 'class' , classes . join ( ' ' ) ) ;
}
/ * *
* This method removes a CSS class name from a given node .
*
* @param { Node } node
* @param { string } className
* /
public removeCSSClass ( node : Node , className : string ) : void {
if ( node . nodeType !== ELEMENT_NODE ) {
return ;
}
const element = < Element > node ;
const classesStr = element . getAttribute ( 'class' ) ;
const newClasses = [ ] ;
const classes = classesStr ? classesStr . split ( ' ' ) : [ ] ;
for ( let i = 0 ; i < classes . length ; i ++ ) {
if ( classes [ i ] !== className ) {
newClasses . push ( classes [ i ] ) ;
}
}
if ( newClasses . length === 0 ) {
element . removeAttribute ( 'class' ) ;
} else {
element . setAttribute ( 'class' , newClasses . join ( ' ' ) ) ;
}
}
/ * *
* Adapted from http : //ejohn.org/projects/javascript-diff-algorithm/
* by John Resig , MIT License
* @param { array } oldArr
* @param { array } newArr
* @returns { object }
* /
private diffArrays ( oldArr : any , newArr : any ) : any {
const ns = { } ,
os = { } ;
let i ;
for ( i = 0 ; i < newArr . length ; i ++ ) {
if ( ns [ newArr [ i ] ] === undefined ) {
ns [ newArr [ i ] ] = { rows : [ ] , o : null } ;
}
ns [ newArr [ i ] ] . rows . push ( i ) ;
}
for ( i = 0 ; i < oldArr . length ; i ++ ) {
if ( os [ oldArr [ i ] ] === undefined ) {
os [ oldArr [ i ] ] = { rows : [ ] , n : null } ;
}
os [ oldArr [ i ] ] . rows . push ( i ) ;
}
for ( i in ns ) {
if ( ns [ i ] . rows . length === 1 && typeof os [ i ] !== 'undefined' && os [ i ] . rows . length === 1 ) {
newArr [ ns [ i ] . rows [ 0 ] ] = { text : newArr [ ns [ i ] . rows [ 0 ] ] , row : os [ i ] . rows [ 0 ] } ;
oldArr [ os [ i ] . rows [ 0 ] ] = { text : oldArr [ os [ i ] . rows [ 0 ] ] , row : ns [ i ] . rows [ 0 ] } ;
}
}
for ( i = 0 ; i < newArr . length - 1 ; i ++ ) {
if (
newArr [ i ] . text !== null &&
newArr [ i + 1 ] . text === undefined &&
newArr [ i ] . row + 1 < oldArr . length &&
oldArr [ newArr [ i ] . row + 1 ] . text === undefined &&
newArr [ i + 1 ] === oldArr [ newArr [ i ] . row + 1 ]
) {
newArr [ i + 1 ] = { text : newArr [ i + 1 ] , row : newArr [ i ] . row + 1 } ;
oldArr [ newArr [ i ] . row + 1 ] = { text : oldArr [ newArr [ i ] . row + 1 ] , row : i + 1 } ;
}
}
for ( i = newArr . length - 1 ; i > 0 ; i -- ) {
if (
newArr [ i ] . text !== null &&
newArr [ i - 1 ] . text === undefined &&
newArr [ i ] . row > 0 &&
oldArr [ newArr [ i ] . row - 1 ] . text === undefined &&
newArr [ i - 1 ] === oldArr [ newArr [ i ] . row - 1 ]
) {
newArr [ i - 1 ] = { text : newArr [ i - 1 ] , row : newArr [ i ] . row - 1 } ;
oldArr [ newArr [ i ] . row - 1 ] = { text : oldArr [ newArr [ i ] . row - 1 ] , row : i - 1 } ;
}
}
return { o : oldArr , n : newArr } ;
}
/ * *
* This method splits a string into an array of strings , such as that it can be used by the diff method .
* Mainly it tries to split it into single words , but prevents HTML tags from being split into different elements .
*
* @param { string } str
* @returns { string [ ] }
* /
private tokenizeHtml ( str : string ) : string [ ] {
const splitArrayEntriesEmbedSeparator = ( arrIn : string [ ] , by : string , prepend : boolean ) : string [ ] = > {
const newArr = [ ] ;
for ( let i = 0 ; i < arrIn . length ; i ++ ) {
if ( arrIn [ i ] [ 0 ] === '<' && ( by === ' ' || by === '\n' ) ) {
// Don't split HTML tags
newArr . push ( arrIn [ i ] ) ;
continue ;
}
const parts = arrIn [ i ] . split ( by ) ;
if ( parts . length === 1 ) {
newArr . push ( arrIn [ i ] ) ;
} else {
let j ;
if ( prepend ) {
if ( parts [ 0 ] !== '' ) {
newArr . push ( parts [ 0 ] ) ;
}
for ( j = 1 ; j < parts . length ; j ++ ) {
newArr . push ( by + parts [ j ] ) ;
}
} else {
for ( j = 0 ; j < parts . length - 1 ; j ++ ) {
newArr . push ( parts [ j ] + by ) ;
}
if ( parts [ parts . length - 1 ] !== '' ) {
newArr . push ( parts [ parts . length - 1 ] ) ;
}
}
}
}
return newArr ;
} ;
const splitArrayEntriesSplitSeparator = ( arrIn : string [ ] , by : string ) : string [ ] = > {
const newArr = [ ] ;
for ( let i = 0 ; i < arrIn . length ; i ++ ) {
if ( arrIn [ i ] [ 0 ] === '<' ) {
newArr . push ( arrIn [ i ] ) ;
continue ;
}
const parts = arrIn [ i ] . split ( by ) ;
for ( let j = 0 ; j < parts . length ; j ++ ) {
if ( j > 0 ) {
newArr . push ( by ) ;
}
newArr . push ( parts [ j ] ) ;
}
}
return newArr ;
} ;
let arr = splitArrayEntriesEmbedSeparator ( [ str ] , '<' , true ) ;
arr = splitArrayEntriesEmbedSeparator ( arr , '>' , false ) ;
arr = splitArrayEntriesSplitSeparator ( arr , ' ' ) ;
arr = splitArrayEntriesSplitSeparator ( arr , '.' ) ;
arr = splitArrayEntriesSplitSeparator ( arr , ',' ) ;
arr = splitArrayEntriesSplitSeparator ( arr , '!' ) ;
arr = splitArrayEntriesSplitSeparator ( arr , '-' ) ;
arr = splitArrayEntriesEmbedSeparator ( arr , '\n' , false ) ;
const arrWithoutEmpties = [ ] ;
for ( let i = 0 ; i < arr . length ; i ++ ) {
if ( arr [ i ] !== '' ) {
arrWithoutEmpties . push ( arr [ i ] ) ;
}
}
return arrWithoutEmpties ;
}
/ * *
* Given two strings , this method generates a consolidated new string that indicates the operations necessary
* to get from ` oldStr ` to ` newStr ` by < ins > . . . < / ins > and < del > . . . < / del > - Tags
*
* @param { string } oldStr
* @param { string } newStr
* @returns { string }
* /
private diffString ( oldStr : string , newStr : string ) : string {
oldStr = this . normalizeHtmlForDiff ( oldStr . replace ( /\s+$/ , '' ) . replace ( /^\s+/ , '' ) ) ;
newStr = this . normalizeHtmlForDiff ( newStr . replace ( /\s+$/ , '' ) . replace ( /^\s+/ , '' ) ) ;
const out = this . diffArrays ( this . tokenizeHtml ( oldStr ) , this . tokenizeHtml ( newStr ) ) ;
// This fixes the problem tested by "does not lose words when changes are moved X-wise"
let lastRow = 0 ;
for ( let z = 0 ; z < out . n . length ; z ++ ) {
if ( out . n [ z ] . row && out . n [ z ] . row > lastRow ) {
lastRow = out . n [ z ] . row ;
}
if ( out . n [ z ] . row && out . n [ z ] . row < lastRow ) {
out . o [ out . n [ z ] . row ] = out . o [ out . n [ z ] . row ] . text ;
out . n [ z ] = out . n [ z ] . text ;
}
}
let str = '' ;
let i ;
if ( out . n . length === 0 ) {
for ( i = 0 ; i < out . o . length ; i ++ ) {
str += '<del>' + out . o [ i ] + '</del>' ;
}
} else {
if ( out . n [ 0 ] . text === undefined ) {
for ( let k = 0 ; k < out . o . length && out . o [ k ] . text === undefined ; k ++ ) {
str += '<del>' + out . o [ k ] + '</del>' ;
}
}
let currOldRow = 0 ;
for ( i = 0 ; i < out . n . length ; i ++ ) {
if ( out . n [ i ] . text === undefined ) {
if ( out . n [ i ] !== '' ) {
str += '<ins>' + out . n [ i ] + '</ins>' ;
}
} else if ( out . n [ i ] . row < currOldRow ) {
str += '<ins>' + out . n [ i ] . text + '</ins>' ;
} else {
let pre = '' ;
if ( i + 1 < out . n . length && out . n [ i + 1 ] . row !== undefined && out . n [ i + 1 ] . row > out . n [ i ] . row + 1 ) {
for ( let n = out . n [ i ] . row + 1 ; n < out . n [ i + 1 ] . row ; n ++ ) {
if ( out . o [ n ] . text === undefined ) {
pre += '<del>' + out . o [ n ] + '</del>' ;
} else {
pre += '<del>' + out . o [ n ] . text + '</del>' ;
}
}
} else {
for ( let j = out . n [ i ] . row + 1 ; j < out . o . length && out . o [ j ] . text === undefined ; j ++ ) {
pre += '<del>' + out . o [ j ] + '</del>' ;
}
}
str += out . n [ i ] . text + pre ;
currOldRow = out . n [ i ] . row ;
}
}
}
return str
. replace ( /^\s+/g , '' )
. replace ( /\s+$/g , '' )
. replace ( / {2,}/g , ' ' ) ;
}
/ * *
* This checks if this string is valid inline HTML .
* It does so by leveraging the browser ' s auto - correction mechanism and coun the number of "<" s ( opening and closing
* HTML tags ) of the original and the cleaned - up string .
* This is mainly helpful to decide if a given string can be put into < del > . . . < / del > or < ins > . . . < / ins > - Tags without
* producing broken HTML .
*
* @param { string } html
* @return { boolean }
* @private
* /
private isValidInlineHtml ( html : string ) : boolean {
// If there are no HTML tags, we assume it's valid and skip further checks
if ( ! html . match ( /<[^>]*>/ ) ) {
return true ;
}
// We check if this is a valid HTML that closes all its tags again using the innerHTML-Hack to correct
// the string and check if the number of HTML tags changes by this
const doc = document . createElement ( 'div' ) ;
doc . innerHTML = html ;
const tagsBefore = ( html . match ( /</g ) || [ ] ) . length ;
const tagsCorrected = ( doc . innerHTML . match ( /</g ) || [ ] ) . length ;
if ( tagsBefore !== tagsCorrected ) {
// The HTML has changed => it was not valid
return false ;
}
// If there is any block element inside, we consider it as broken, as this string will be displayed
// inside of <ins>/<del> tags
if ( html . match ( /<(div|p|ul|li|blockquote)\W/i ) ) {
return false ;
}
return true ;
}
/ * *
* This detects if a given string contains broken HTML . This can happen when the Diff accidentally produces
* wrongly nested HTML tags .
*
* @param { string } html
* @returns { boolean }
* @private
* /
private diffDetectBrokenDiffHtml ( html : string ) : boolean {
// If other HTML tags are contained within INS/DEL (e.g. "<ins>Test</p></ins>"), let's better be cautious
// The "!!(found=...)"-construction is only used to make jshint happy :)
2019-02-17 19:39:05 +01:00
const findDel = /<del>([\s\S]*?)<\/del>/gi ,
findIns = /<ins>([\s\S]*?)<\/ins>/gi ;
2018-09-09 18:52:47 +02:00
let found , inner ;
while ( ! ! ( found = findDel . exec ( html ) ) ) {
inner = found [ 1 ] . replace ( /<br[^>]*>/gi , '' ) ;
if ( inner . match ( /<[^>]*>/ ) ) {
return true ;
}
}
while ( ! ! ( found = findIns . exec ( html ) ) ) {
inner = found [ 1 ] . replace ( /<br[^>]*>/gi , '' ) ;
if ( ! this . isValidInlineHtml ( inner ) ) {
return true ;
}
}
// If non of the conditions up to now is met, we consider the diff as being sane
return false ;
}
/ * *
* Adds a CSS class to the first opening HTML tag within the given string .
*
* @param { string } html
* @param { string } className
* @returns { string }
* /
public addCSSClassToFirstTag ( html : string , className : string ) : string {
return html . replace (
/<[a-z][^>]*>/i ,
( match : string ) : string = > {
if ( match . match ( /class=["'][a-z0-9 _-]*["']/i ) ) {
return match . replace (
/class=["']([a-z0-9 _-]*)["']/i ,
( match2 : string , previousClasses : string ) : string = > {
return 'class="' + previousClasses + ' ' + className + '"' ;
}
) ;
} else {
return match . substring ( 0 , match . length - 1 ) + ' class="' + className + '">' ;
}
}
) ;
}
/ * *
* Adds a CSS class to the last opening HTML tag within the given string .
*
* @param { string } html
* @param { string } className
* @returns { string }
* /
public addClassToLastNode ( html : string , className : string ) : string {
const node = document . createElement ( 'div' ) ;
node . innerHTML = html ;
let foundLast = false ;
for ( let i = node . childNodes . length - 1 ; i >= 0 && ! foundLast ; i -- ) {
if ( node . childNodes [ i ] . nodeType === ELEMENT_NODE ) {
const childElement = < Element > node . childNodes [ i ] ;
let classes = [ ] ;
if ( childElement . getAttribute ( 'class' ) ) {
classes = childElement . getAttribute ( 'class' ) . split ( ' ' ) ;
}
classes . push ( className ) ;
childElement . setAttribute (
'class' ,
classes
. sort ( )
. join ( ' ' )
. replace ( /^\s+/ , '' )
. replace ( /\s+$/ , '' )
) ;
foundLast = true ;
}
}
return node . innerHTML ;
}
/ * *
* This function removes color - Attributes from the styles of this node or a descendant ,
* as they interfer with the green / red color in HTML and PDF
*
* For the moment , it is sufficient to do this only in paragraph diff mode , as we fall back to this mode anyway
* once we encounter SPANs or other tags inside of INS / DEL - tags
*
* @param { Element } node
* @private
* /
private removeColorStyles ( node : Element ) : void {
const styles = node . getAttribute ( 'style' ) ;
if ( styles && styles . indexOf ( 'color' ) > - 1 ) {
const stylesNew = [ ] ;
styles . split ( ';' ) . forEach (
( style : string ) : void = > {
if ( ! style . match ( /^\s*color\s*:/i ) ) {
stylesNew . push ( style ) ;
}
}
) ;
if ( stylesNew . join ( ';' ) === '' ) {
node . removeAttribute ( 'style' ) ;
} else {
node . setAttribute ( 'style' , stylesNew . join ( ';' ) ) ;
}
}
for ( let i = 0 ; i < node . childNodes . length ; i ++ ) {
if ( node . childNodes [ i ] . nodeType === ELEMENT_NODE ) {
this . removeColorStyles ( < Element > node . childNodes [ i ] ) ;
}
}
}
2018-11-19 20:39:44 +01:00
/ * *
* Add the CSS - class to the existing "class" - attribute , or add one .
* Works on strings , not nodes
*
* @param { string } tagStr
* @param { string } className
* @returns { string }
* /
private addClassToHtmlTag ( tagStr : string , className : string ) : string {
return tagStr . replace (
/<(\w+)( [^>]*)?>/gi ,
( whole : string , tag : string , tagArguments : string ) : string = > {
2018-11-04 11:11:48 +01:00
tagArguments = tagArguments ? tagArguments : '' ;
2018-11-19 20:39:44 +01:00
if ( tagArguments . match ( /class="/gi ) ) {
// class="someclass" => class="someclass insert"
2018-11-04 11:11:48 +01:00
tagArguments = tagArguments . replace (
/(class\s*=\s*)(["'])([^\2]*)\2/gi ,
2018-11-19 20:39:44 +01:00
( classWhole : string , attr : string , para : string , content : string ) : string = > {
return attr + para + content + ' ' + className + para ;
}
) ;
} else {
tagArguments += ' class="' + className + '"' ;
}
return '<' + tag + tagArguments + '>' ;
}
) ;
2018-11-04 11:11:48 +01:00
}
2018-11-19 20:39:44 +01:00
2018-09-09 18:52:47 +02:00
/ * *
* This fixes a very specific , really weird bug that is tested in the test case "does not a change in a very specific case" .
*
* @param { string } diffStr
* @return { string }
* /
private fixWrongChangeDetection ( diffStr : string ) : string {
if ( diffStr . indexOf ( '<del>' ) === - 1 || diffStr . indexOf ( '<ins>' ) === - 1 ) {
return diffStr ;
}
const findDelGroupFinder = /(?:<del>.*?<\/del>)+/gi ;
let found ,
returnStr = diffStr ;
while ( ! ! ( found = findDelGroupFinder . exec ( diffStr ) ) ) {
const del = found [ 0 ] ,
split = returnStr . split ( del ) ;
const findInsGroupFinder = /^(?:<ins>.*?<\/ins>)+/gi ,
foundIns = findInsGroupFinder . exec ( split [ 1 ] ) ;
if ( foundIns ) {
const ins = foundIns [ 0 ] ;
let delShortened = del
. replace (
/<del>((<BR CLASS="os-line-break"><\/del><del>)?(<span[^>]+os-line-number[^>]+?>)(\s|<\/?del>)*<\/span>)<\/del>/gi ,
''
)
. replace ( /<\/del><del>/g , '' ) ;
const insConv = ins
. replace ( /<ins>/g , '<del>' )
. replace ( /<\/ins>/g , '</del>' )
. replace ( /<\/del><del>/g , '' ) ;
if ( delShortened . indexOf ( insConv ) !== - 1 ) {
delShortened = delShortened . replace ( insConv , '' ) ;
if ( delShortened === '' ) {
returnStr = returnStr . replace ( del + ins , del . replace ( /<del>/g , '' ) . replace ( /<\/del>/g , '' ) ) ;
}
}
}
}
return returnStr ;
}
/ * *
* Converts a given HTML node into HTML string and optionally strips line number nodes from it .
*
* @param { Node } node
* @param { boolean } stripLineNumbers
* @returns { string }
* /
private serializeDom ( node : Node , stripLineNumbers : boolean ) : string {
if ( node . nodeType === TEXT_NODE ) {
return node . nodeValue . replace ( /</g , '<' ) . replace ( />/g , '>' ) ;
}
if (
stripLineNumbers &&
( this . lineNumberingService . isOsLineNumberNode ( node ) || this . lineNumberingService . isOsLineBreakNode ( node ) )
) {
return '' ;
}
if ( node . nodeName === 'OS-LINEBREAK' ) {
return '' ;
}
if ( node . nodeName === 'BR' ) {
const element = < Element > node ;
let br = '<BR' ;
for ( let i = 0 ; i < element . attributes . length ; i ++ ) {
const attr = element . attributes [ i ] ;
br += ' ' + attr . name + '="' + attr . value + '"' ;
}
return br + '>' ;
}
let html = this . serializeTag ( node ) ;
for ( let i = 0 ; i < node . childNodes . length ; i ++ ) {
if ( node . childNodes [ i ] . nodeType === TEXT_NODE ) {
html += node . childNodes [ i ] . nodeValue
. replace ( /&/g , '&' )
. replace ( /</g , '<' )
. replace ( />/g , '>' ) ;
} else if (
! stripLineNumbers ||
( ! this . lineNumberingService . isOsLineNumberNode ( node . childNodes [ i ] ) &&
! this . lineNumberingService . isOsLineBreakNode ( node . childNodes [ i ] ) )
) {
html += this . serializeDom ( node . childNodes [ i ] , stripLineNumbers ) ;
}
}
if ( node . nodeType !== DOCUMENT_FRAGMENT_NODE ) {
html += '</' + node . nodeName + '>' ;
}
return html ;
}
/ * *
* When a < li > with a os - split - before - class ( set by extractRangeByLineNumbers ) is edited when creating a
* change recommendation and is split again in CKEditor , the second list items also gets that class .
* This is not correct however , as the second one actually is a new list item . So we need to remove it again .
*
* @param { string } html
* @returns { string }
* /
public removeDuplicateClassesInsertedByCkeditor ( html : string ) : string {
const fragment = this . htmlToFragment ( html ) ;
const items = fragment . querySelectorAll ( 'li.os-split-before' ) ;
for ( let i = 0 ; i < items . length ; i ++ ) {
if ( ! this . isFirstNonemptyChild ( items [ i ] . parentNode , items [ i ] ) ) {
this . removeCSSClass ( items [ i ] , 'os-split-before' ) ;
}
}
return this . serializeDom ( fragment , false ) ;
}
/ * *
* Given a DOM tree and a specific node within that tree , this method returns the HTML string from the beginning
* of this tree up to this node .
* The returned string in itself is not renderable , as it stops in the middle of the complete HTML , with opened tags .
*
* Implementation hint : the first element of "toChildTrace" array needs to be a child element of "node"
* @param { Node } node
* @param { Node [ ] } toChildTrace
* @param { boolean } stripLineNumbers
* @returns { string }
* /
public serializePartialDomToChild ( node : Node , toChildTrace : Node [ ] , stripLineNumbers : boolean ) : string {
if ( this . lineNumberingService . isOsLineNumberNode ( node ) || this . lineNumberingService . isOsLineBreakNode ( node ) ) {
return '' ;
}
if ( node . nodeName === 'OS-LINEBREAK' ) {
return '' ;
}
let html = this . serializeTag ( node ) ,
found = false ;
for ( let i = 0 ; i < node . childNodes . length && ! found ; i ++ ) {
if ( node . childNodes [ i ] === toChildTrace [ 0 ] ) {
found = true ;
const childElement = < Element > node . childNodes [ i ] ;
const remainingTrace = toChildTrace ;
remainingTrace . shift ( ) ;
if ( ! this . lineNumberingService . isOsLineNumberNode ( childElement ) ) {
html += this . serializePartialDomToChild ( childElement , remainingTrace , stripLineNumbers ) ;
}
} else if ( node . childNodes [ i ] . nodeType === TEXT_NODE ) {
html += node . childNodes [ i ] . nodeValue ;
} else {
const childElement = < Element > node . childNodes [ i ] ;
if (
! stripLineNumbers ||
( ! this . lineNumberingService . isOsLineNumberNode ( childElement ) &&
! this . lineNumberingService . isOsLineBreakNode ( childElement ) )
) {
html += this . serializeDom ( childElement , stripLineNumbers ) ;
}
}
}
if ( ! found ) {
throw new Error ( 'Inconsistency or invalid call of this function detected (to)' ) ;
}
return html ;
}
/ * *
* Given a DOM tree and a specific node within that tree , this method returns the HTML string beginning after this
* node to the end of the tree .
* The returned string in itself is not renderable , as it starts in the middle of the complete HTML , with opened tags .
*
* Implementation hint : the first element of "fromChildTrace" array needs to be a child element of "node"
* @param { Node } node
* @param { Node [ ] } fromChildTrace
* @param { boolean } stripLineNumbers
* @returns { string }
* /
public serializePartialDomFromChild ( node : Node , fromChildTrace : Node [ ] , stripLineNumbers : boolean ) : string {
if ( this . lineNumberingService . isOsLineNumberNode ( node ) || this . lineNumberingService . isOsLineBreakNode ( node ) ) {
return '' ;
}
if ( node . nodeName === 'OS-LINEBREAK' ) {
return '' ;
}
let html = '' ,
found = false ;
for ( let i = 0 ; i < node . childNodes . length ; i ++ ) {
if ( node . childNodes [ i ] === fromChildTrace [ 0 ] ) {
found = true ;
const childElement = < Element > node . childNodes [ i ] ;
const remainingTrace = fromChildTrace ;
remainingTrace . shift ( ) ;
if ( ! this . lineNumberingService . isOsLineNumberNode ( childElement ) ) {
html += this . serializePartialDomFromChild ( childElement , remainingTrace , stripLineNumbers ) ;
}
} else if ( found ) {
if ( node . childNodes [ i ] . nodeType === TEXT_NODE ) {
html += node . childNodes [ i ] . nodeValue ;
} else {
const childElement = < Element > node . childNodes [ i ] ;
if (
! stripLineNumbers ||
( ! this . lineNumberingService . isOsLineNumberNode ( childElement ) &&
! this . lineNumberingService . isOsLineBreakNode ( childElement ) )
) {
html += this . serializeDom ( childElement , stripLineNumbers ) ;
}
}
}
}
if ( ! found ) {
throw new Error ( 'Inconsistency or invalid call of this function detected (from)' ) ;
}
if ( node . nodeType !== DOCUMENT_FRAGMENT_NODE ) {
html += '</' + node . nodeName + '>' ;
}
return html ;
}
/ * *
* Returns the HTML snippet between two given line numbers .
* extractRangeByLineNumbers
* Hint :
* - The last line ( toLine ) is not included anymore , as the number refers to the line breaking element at the end of the line
* - if toLine === null , then everything from fromLine to the end of the fragment is returned
*
* In addition to the HTML snippet , additional information is provided regarding the most specific DOM element
* that contains the whole section specified by the line numbers ( like a P - element if only one paragraph is selected
* or the most outer DIV , if multiple sections selected ) .
*
* This additional information is meant to render the snippet correctly without producing broken HTML
*
* In some cases , the returned HTML tags receive additional CSS classes , providing information both for
* rendering it and for merging it again correctly .
* - os - split - * : These classes are set for all HTML Tags that have been split into two by this process ,
* e . g . if the fromLine - or toLine - line - break was somewhere in the middle of this tag .
* If a tag is split , the first one receives "os-split-after" , and the second one "os-split-before" .
* For example , for the following string < p > Line 1 < br > Line 2 < br > Line 3 < / p > :
* - extracting line 1 to 2 results in < p class = "os-split-after" > Line 1 < / p >
* - extracting line 2 to 3 results in < p class = "os-split-after os-split-before" > Line 2 < / p >
* - extracting line 3 to null / 4 results in < p class = "os-split-before" > Line 3 < / p >
*
* @param { string } htmlIn
* @param { number } fromLine
* @param { number } toLine
* @returns { ExtractedContent }
* /
public extractRangeByLineNumbers ( htmlIn : string , fromLine : number , toLine : number ) : ExtractedContent {
if ( typeof htmlIn !== 'string' ) {
throw new Error ( 'Invalid call - extractRangeByLineNumbers expects a string as first argument' ) ;
}
const cacheKey = fromLine + '-' + toLine + '-' + this . lineNumberingService . djb2hash ( htmlIn ) ,
cached = this . diffCache . get ( cacheKey ) ;
if ( cached ) {
return cached ;
}
const fragment = this . htmlToFragment ( htmlIn ) ;
this . insertInternalLineMarkers ( fragment ) ;
if ( toLine === null ) {
const internalLineMarkers = fragment . querySelectorAll ( 'OS-LINEBREAK' ) ,
lastMarker = < Element > internalLineMarkers [ internalLineMarkers . length - 1 ] ;
toLine = parseInt ( lastMarker . getAttribute ( 'data-line-number' ) , 10 ) ;
}
const fromLineNode = this . getLineNumberNode ( fragment , fromLine ) ,
toLineNode = toLine ? this . getLineNumberNode ( fragment , toLine ) : null ,
ancestorData = this . getCommonAncestor ( fromLineNode , toLineNode ) ;
const fromChildTraceRel = ancestorData . trace1 ,
fromChildTraceAbs = this . getNodeContextTrace ( fromLineNode ) ,
toChildTraceRel = ancestorData . trace2 ,
toChildTraceAbs = this . getNodeContextTrace ( toLineNode ) ,
ancestor = ancestorData . commonAncestor ;
let htmlOut = '' ,
outerContextStart = '' ,
outerContextEnd = '' ,
innerContextStart = '' ,
innerContextEnd = '' ,
previousHtmlEndSnippet = '' ,
followingHtmlStartSnippet = '' ,
fakeOl ,
offset ;
fromChildTraceAbs . shift ( ) ;
const previousHtml = this . serializePartialDomToChild ( fragment , fromChildTraceAbs , false ) ;
toChildTraceAbs . shift ( ) ;
const followingHtml = this . serializePartialDomFromChild ( fragment , toChildTraceAbs , false ) ;
let currNode : Node = fromLineNode ,
isSplit = false ;
while ( currNode . parentNode ) {
if ( ! this . isFirstNonemptyChild ( currNode . parentNode , currNode ) ) {
isSplit = true ;
}
if ( isSplit ) {
this . addCSSClass ( currNode . parentNode , 'os-split-before' ) ;
}
if ( currNode . nodeName !== 'OS-LINEBREAK' ) {
previousHtmlEndSnippet += '</' + currNode . nodeName + '>' ;
}
currNode = currNode . parentNode ;
}
currNode = toLineNode ;
isSplit = false ;
while ( currNode . parentNode ) {
if ( ! this . isFirstNonemptyChild ( currNode . parentNode , currNode ) ) {
isSplit = true ;
}
if ( isSplit ) {
this . addCSSClass ( currNode . parentNode , 'os-split-after' ) ;
}
if ( currNode . parentNode . nodeName === 'OL' ) {
const parentElement = < Element > currNode . parentNode ;
fakeOl = parentElement . cloneNode ( false ) ;
offset = parentElement . getAttribute ( 'start' )
? parseInt ( parentElement . getAttribute ( 'start' ) , 10 ) - 1
: 0 ;
fakeOl . setAttribute ( 'start' , ( this . isWithinNthLIOfOL ( parentElement , toLineNode ) + offset ) . toString ( ) ) ;
followingHtmlStartSnippet = this . serializeTag ( fakeOl ) + followingHtmlStartSnippet ;
} else {
followingHtmlStartSnippet = this . serializeTag ( currNode . parentNode ) + followingHtmlStartSnippet ;
}
currNode = currNode . parentNode ;
}
let found = false ;
isSplit = false ;
for ( let i = 0 ; i < fromChildTraceRel . length && ! found ; i ++ ) {
if ( fromChildTraceRel [ i ] . nodeName === 'OS-LINEBREAK' ) {
found = true ;
} else {
if ( ! this . isFirstNonemptyChild ( fromChildTraceRel [ i ] , fromChildTraceRel [ i + 1 ] ) ) {
isSplit = true ;
}
if ( fromChildTraceRel [ i ] . nodeName === 'OL' ) {
const element = < Element > fromChildTraceRel [ i ] ;
fakeOl = element . cloneNode ( false ) ;
offset = element . getAttribute ( 'start' ) ? parseInt ( element . getAttribute ( 'start' ) , 10 ) - 1 : 0 ;
fakeOl . setAttribute ( 'start' , ( offset + this . isWithinNthLIOfOL ( element , fromLineNode ) ) . toString ( ) ) ;
innerContextStart += this . serializeTag ( fakeOl ) ;
} else {
if ( i < fromChildTraceRel . length - 1 && isSplit ) {
this . addCSSClass ( fromChildTraceRel [ i ] , 'os-split-before' ) ;
}
innerContextStart += this . serializeTag ( fromChildTraceRel [ i ] ) ;
}
}
}
found = false ;
for ( let i = 0 ; i < toChildTraceRel . length && ! found ; i ++ ) {
if ( toChildTraceRel [ i ] . nodeName === 'OS-LINEBREAK' ) {
found = true ;
} else {
innerContextEnd = '</' + toChildTraceRel [ i ] . nodeName + '>' + innerContextEnd ;
}
}
found = false ;
for ( let i = 0 ; i < ancestor . childNodes . length ; i ++ ) {
if ( ancestor . childNodes [ i ] === fromChildTraceRel [ 0 ] ) {
found = true ;
fromChildTraceRel . shift ( ) ;
htmlOut += this . serializePartialDomFromChild ( ancestor . childNodes [ i ] , fromChildTraceRel , true ) ;
} else if ( ancestor . childNodes [ i ] === toChildTraceRel [ 0 ] ) {
found = false ;
toChildTraceRel . shift ( ) ;
htmlOut += this . serializePartialDomToChild ( ancestor . childNodes [ i ] , toChildTraceRel , true ) ;
} else if ( found === true ) {
htmlOut += this . serializeDom ( ancestor . childNodes [ i ] , true ) ;
}
}
currNode = ancestor ;
while ( currNode . parentNode ) {
if ( currNode . nodeName === 'OL' ) {
const currElement = < Element > currNode ;
fakeOl = currElement . cloneNode ( false ) ;
offset = currElement . getAttribute ( 'start' ) ? parseInt ( currElement . getAttribute ( 'start' ) , 10 ) - 1 : 0 ;
fakeOl . setAttribute ( 'start' , ( this . isWithinNthLIOfOL ( currElement , fromLineNode ) + offset ) . toString ( ) ) ;
outerContextStart = this . serializeTag ( fakeOl ) + outerContextStart ;
} else {
outerContextStart = this . serializeTag ( currNode ) + outerContextStart ;
}
outerContextEnd += '</' + currNode . nodeName + '>' ;
currNode = currNode . parentNode ;
}
const ret = {
html : htmlOut ,
ancestor : ancestor ,
outerContextStart : outerContextStart ,
outerContextEnd : outerContextEnd ,
innerContextStart : innerContextStart ,
innerContextEnd : innerContextEnd ,
previousHtml : previousHtml ,
previousHtmlEndSnippet : previousHtmlEndSnippet ,
followingHtml : followingHtml ,
followingHtmlStartSnippet : followingHtmlStartSnippet
} ;
this . diffCache . put ( cacheKey , ret ) ;
return ret ;
}
2018-11-04 11:11:48 +01:00
/ * *
* Convenience method that takes the html - attribute from an extractRangeByLineNumbers ( ) - method and
* wraps it with the context .
*
* @param { ExtractedContent } diff
* /
public formatDiff ( diff : ExtractedContent ) : string {
return (
diff . outerContextStart + diff . innerContextStart + diff . html + diff . innerContextEnd + diff . outerContextEnd
) ;
}
2018-09-09 18:52:47 +02:00
/ * *
* Convenience method that takes the html - attribute from an extractRangeByLineNumbers ( ) - method ,
* wraps it with the context and adds line numbers .
*
* @param { ExtractedContent } diff
* @param { number } lineLength
* @param { number } firstLine
* /
public formatDiffWithLineNumbers ( diff : ExtractedContent , lineLength : number , firstLine : number ) : string {
2018-11-04 11:11:48 +01:00
let text = this . formatDiff ( diff ) ;
2018-09-09 18:52:47 +02:00
text = this . lineNumberingService . insertLineNumbers ( text , lineLength , null , null , firstLine ) ;
return text ;
}
/ * *
* This is a workardoun to prevent the last word of the inserted text from accidently being merged with the
* first word of the following line .
*
* This happens as trailing spaces in the change recommendation ' s text are frequently stripped ,
* which is pretty nasty if the original text goes on after the affected line . So we insert a space
* if the original line ends with one .
*
* @param { Element | DocumentFragment } element
* /
private insertDanglingSpace ( element : Element | DocumentFragment ) : void {
if ( element . childNodes . length > 0 ) {
let lastChild = element . childNodes [ element . childNodes . length - 1 ] ;
if (
lastChild . nodeType === TEXT_NODE &&
! lastChild . nodeValue . match ( /[\S]/ ) &&
element . childNodes . length > 1
) {
// If the text node only contains whitespaces, chances are high it's just space between block elmeents,
// like a line break between </LI> and </UL>
lastChild = element . childNodes [ element . childNodes . length - 2 ] ;
}
if ( lastChild . nodeType === TEXT_NODE ) {
if ( lastChild . nodeValue === '' || lastChild . nodeValue . substr ( - 1 ) !== ' ' ) {
lastChild . nodeValue += ' ' ;
}
} else {
this . insertDanglingSpace ( < Element > lastChild ) ;
}
}
}
/ * *
* This functions merges to arrays of nodes . The last element of nodes1 and the first element of nodes2
* are merged , if they are of the same type .
*
* This is done recursively until a TEMPLATE - Tag is is found , which was inserted in this . replaceLines .
* Using a TEMPLATE - Tag is a rather dirty hack , as it is allowed inside of any other element , including < ul > .
*
* @param { Node [ ] } nodes1
* @param { Node [ ] } nodes2
* @returns { Node [ ] }
* /
public replaceLinesMergeNodeArrays ( nodes1 : Node [ ] , nodes2 : Node [ ] ) : Node [ ] {
if ( nodes1 . length === 0 ) {
return nodes2 ;
}
if ( nodes2 . length === 0 ) {
return nodes1 ;
}
const out = [ ] ;
for ( let i = 0 ; i < nodes1 . length - 1 ; i ++ ) {
out . push ( nodes1 [ i ] ) ;
}
const lastNode = nodes1 [ nodes1 . length - 1 ] ,
firstNode = nodes2 [ 0 ] ;
if ( lastNode . nodeType === TEXT_NODE && firstNode . nodeType === TEXT_NODE ) {
const newTextNode = lastNode . ownerDocument . createTextNode ( lastNode . nodeValue + firstNode . nodeValue ) ;
out . push ( newTextNode ) ;
} else if ( lastNode . nodeName === firstNode . nodeName ) {
const lastElement = < Element > lastNode ,
newNode = lastNode . ownerDocument . createElement ( lastNode . nodeName ) ;
for ( let i = 0 ; i < lastElement . attributes . length ; i ++ ) {
const attr = lastElement . attributes [ i ] ;
newNode . setAttribute ( attr . name , attr . value ) ;
}
// Remove #text nodes inside of List elements (OL/UL), as they are confusing
let lastChildren , firstChildren ;
if ( lastElement . nodeName === 'OL' || lastElement . nodeName === 'UL' ) {
lastChildren = [ ] ;
firstChildren = [ ] ;
for ( let i = 0 ; i < firstNode . childNodes . length ; i ++ ) {
if ( firstNode . childNodes [ i ] . nodeType === ELEMENT_NODE ) {
firstChildren . push ( firstNode . childNodes [ i ] ) ;
}
}
for ( let i = 0 ; i < lastElement . childNodes . length ; i ++ ) {
if ( lastElement . childNodes [ i ] . nodeType === ELEMENT_NODE ) {
lastChildren . push ( lastElement . childNodes [ i ] ) ;
}
}
} else {
lastChildren = lastElement . childNodes ;
firstChildren = firstNode . childNodes ;
}
const children = this . replaceLinesMergeNodeArrays ( lastChildren , firstChildren ) ;
for ( let i = 0 ; i < children . length ; i ++ ) {
newNode . appendChild ( children [ i ] ) ;
}
out . push ( newNode ) ;
} else {
if ( lastNode . nodeName !== 'TEMPLATE' ) {
out . push ( lastNode ) ;
}
if ( firstNode . nodeName !== 'TEMPLATE' ) {
out . push ( firstNode ) ;
}
}
for ( let i = 1 ; i < nodes2 . length ; i ++ ) {
out . push ( nodes2 [ i ] ) ;
}
return out ;
}
/ * *
* This returns the line number range in which changes ( insertions , deletions ) are encountered .
* As in extractRangeByLineNumbers ( ) , "to" refers to the line breaking element at the end , i . e . the start of the following line .
*
* @param { string } diffHtml
* @returns { LineRange }
* /
public detectAffectedLineRange ( diffHtml : string ) : LineRange {
const cacheKey = this . lineNumberingService . djb2hash ( diffHtml ) ,
cached = this . diffCache . get ( cacheKey ) ;
if ( cached ) {
return cached ;
}
const fragment = this . htmlToFragment ( diffHtml ) ;
this . insertInternalLineMarkers ( fragment ) ;
const changes = fragment . querySelectorAll ( 'ins, del, .insert, .delete' ) ,
firstChange = changes . item ( 0 ) ,
lastChange = changes . item ( changes . length - 1 ) ;
if ( ! firstChange || ! lastChange ) {
// There are no changes
return null ;
}
const firstTrace = this . getNodeContextTrace ( firstChange ) ;
let lastLineNumberBefore = null ;
for ( let j = firstTrace . length - 1 ; j >= 0 && lastLineNumberBefore === null ; j -- ) {
const prevSiblings = this . getAllPrevSiblingsReversed ( firstTrace [ j ] ) ;
for ( let i = 0 ; i < prevSiblings . length && lastLineNumberBefore === null ; i ++ ) {
lastLineNumberBefore = this . getLastLineNumberNode ( prevSiblings [ i ] ) ;
}
}
const lastTrace = this . getNodeContextTrace ( lastChange ) ;
let firstLineNumberAfter = null ;
for ( let j = lastTrace . length - 1 ; j >= 0 && firstLineNumberAfter === null ; j -- ) {
const nextSiblings = this . getAllNextSiblings ( lastTrace [ j ] ) ;
for ( let i = 0 ; i < nextSiblings . length && firstLineNumberAfter === null ; i ++ ) {
firstLineNumberAfter = this . getFirstLineNumberNode ( nextSiblings [ i ] ) ;
}
}
const range = {
from : parseInt ( lastLineNumberBefore . getAttribute ( 'data-line-number' ) , 10 ) ,
to : parseInt ( firstLineNumberAfter . getAttribute ( 'data-line-number' ) , 10 )
} ;
this . diffCache . put ( cacheKey , range ) ;
return range ;
}
/ * *
* Removes . delete - nodes and < del > - Tags ( including content )
* Removes the . insert - classes and the wrapping < ins > - Tags ( while maintaining content )
*
* @param { string } html
* @returns { string }
* /
public diffHtmlToFinalText ( html : string ) : string {
const fragment = this . htmlToFragment ( html ) ;
const delNodes = fragment . querySelectorAll ( '.delete, del' ) ;
for ( let i = 0 ; i < delNodes . length ; i ++ ) {
delNodes [ i ] . parentNode . removeChild ( delNodes [ i ] ) ;
}
const insNodes = fragment . querySelectorAll ( 'ins' ) ;
for ( let i = 0 ; i < insNodes . length ; i ++ ) {
const ins = insNodes [ i ] ;
while ( ins . childNodes . length > 0 ) {
const child = ins . childNodes . item ( 0 ) ;
ins . removeChild ( child ) ;
ins . parentNode . insertBefore ( child , ins ) ;
}
ins . parentNode . removeChild ( ins ) ;
}
const insertNodes = fragment . querySelectorAll ( '.insert' ) ;
for ( let i = 0 ; i < insertNodes . length ; i ++ ) {
this . removeCSSClass ( insertNodes [ i ] , 'insert' ) ;
}
return this . serializeDom ( fragment , false ) ;
}
/ * *
* Given a line numbered string ( ` oldHtml ` ) , this method removes the text between ` fromLine ` and ` toLine `
* and replaces it by the string given by ` newHTML ` .
* While replacing it , it also merges HTML tags that have been split to create the ` newHTML ` fragment ,
* indicated by the CSS classes . os - split - before and . os - split - after .
*
* This is used for creating the consolidated version of motions .
*
* @param { string } oldHtml
* @param { string } newHTML
* @param { number } fromLine
* @param { number } toLine
* /
public replaceLines ( oldHtml : string , newHTML : string , fromLine : number , toLine : number ) : string {
const data = this . extractRangeByLineNumbers ( oldHtml , fromLine , toLine ) ,
previousHtml = data . previousHtml + '<TEMPLATE></TEMPLATE>' + data . previousHtmlEndSnippet ,
previousFragment = this . htmlToFragment ( previousHtml ) ,
followingHtml = data . followingHtmlStartSnippet + '<TEMPLATE></TEMPLATE>' + data . followingHtml ,
followingFragment = this . htmlToFragment ( followingHtml ) ,
newFragment = this . htmlToFragment ( newHTML ) ;
if ( data . html . length > 0 && data . html . substr ( - 1 ) === ' ' ) {
this . insertDanglingSpace ( newFragment ) ;
}
let merged = this . replaceLinesMergeNodeArrays (
Array . prototype . slice . call ( previousFragment . childNodes ) ,
Array . prototype . slice . call ( newFragment . childNodes )
) ;
merged = this . replaceLinesMergeNodeArrays ( merged , Array . prototype . slice . call ( followingFragment . childNodes ) ) ;
const mergedFragment = document . createDocumentFragment ( ) ;
for ( let i = 0 ; i < merged . length ; i ++ ) {
mergedFragment . appendChild ( merged [ i ] ) ;
}
const forgottenTemplates = mergedFragment . querySelectorAll ( 'TEMPLATE' ) ;
for ( let i = 0 ; i < forgottenTemplates . length ; i ++ ) {
const el = forgottenTemplates [ i ] ;
el . parentNode . removeChild ( el ) ;
}
const forgottenSplitClasses = mergedFragment . querySelectorAll ( '.os-split-before, .os-split-after' ) ;
for ( let i = 0 ; i < forgottenSplitClasses . length ; i ++ ) {
this . removeCSSClass ( forgottenSplitClasses [ i ] , 'os-split-before' ) ;
this . removeCSSClass ( forgottenSplitClasses [ i ] , 'os-split-after' ) ;
}
return this . serializeDom ( mergedFragment , true ) ;
}
/ * *
* If the inline diff does not work , we fall back to showing the diff on a paragraph base , i . e . deleting the old
* paragraph ( adding the "deleted" - class ) and adding the new one ( adding the "added" class ) .
* If the provided Text is not wrapped in HTML elements but inline text , the returned text is using
* < ins > / < del > - tags instead of adding CSS - classes to the wrapping element .
*
* @param { string } oldText
* @param { string } newText
* @param { number | null } lineLength
* @param { number | null } firstLineNumber
* @returns { string }
* /
private diffParagraphs ( oldText : string , newText : string , lineLength : number , firstLineNumber : number ) : string {
let oldTextWithBreaks , newTextWithBreaks , currChild ;
if ( lineLength !== null ) {
oldTextWithBreaks = this . lineNumberingService . insertLineNumbersNode (
oldText ,
lineLength ,
null ,
firstLineNumber
) ;
2018-10-27 11:39:38 +02:00
newText = this . lineNumberingService . insertLineBreaksWithoutNumbers ( newText , lineLength ) ;
2018-09-09 18:52:47 +02:00
} else {
oldTextWithBreaks = document . createElement ( 'div' ) ;
oldTextWithBreaks . innerHTML = oldText ;
}
2018-10-27 11:39:38 +02:00
newText = newText . replace ( /^\s+/g , '' ) . replace ( /\s+$/g , '' ) ;
newTextWithBreaks = document . createElement ( 'div' ) ;
newTextWithBreaks . innerHTML = newText ;
2018-09-09 18:52:47 +02:00
for ( let i = 0 ; i < oldTextWithBreaks . childNodes . length ; i ++ ) {
currChild = oldTextWithBreaks . childNodes [ i ] ;
if ( currChild . nodeType === TEXT_NODE ) {
const wrapDel = document . createElement ( 'del' ) ;
oldTextWithBreaks . insertBefore ( wrapDel , currChild ) ;
oldTextWithBreaks . removeChild ( currChild ) ;
wrapDel . appendChild ( currChild ) ;
} else {
this . addCSSClass ( currChild , 'delete' ) ;
this . removeColorStyles ( currChild ) ;
}
}
for ( let i = 0 ; i < newTextWithBreaks . childNodes . length ; i ++ ) {
currChild = newTextWithBreaks . childNodes [ i ] ;
if ( currChild . nodeType === TEXT_NODE ) {
const wrapIns = document . createElement ( 'ins' ) ;
newTextWithBreaks . insertBefore ( wrapIns , currChild ) ;
newTextWithBreaks . removeChild ( currChild ) ;
wrapIns . appendChild ( currChild ) ;
} else {
this . addCSSClass ( currChild , 'insert' ) ;
this . removeColorStyles ( currChild ) ;
}
}
const mergedFragment = document . createDocumentFragment ( ) ;
let el ;
while ( oldTextWithBreaks . firstChild ) {
el = oldTextWithBreaks . firstChild ;
oldTextWithBreaks . removeChild ( el ) ;
mergedFragment . appendChild ( el ) ;
}
while ( newTextWithBreaks . firstChild ) {
el = newTextWithBreaks . firstChild ;
newTextWithBreaks . removeChild ( el ) ;
mergedFragment . appendChild ( el ) ;
}
return this . serializeDom ( mergedFragment , false ) ;
}
/ * *
* This function calculates the diff between two strings and tries to fix problems with the resulting HTML .
* If lineLength and firstLineNumber is given , line numbers will be returned es well
*
* @param { string } htmlOld
* @param { string } htmlNew
* @param { number } lineLength - optional
* @param { number } firstLineNumber - optional
* @returns { string }
* /
public diff ( htmlOld : string , htmlNew : string , lineLength : number = null , firstLineNumber : number = null ) : string {
const cacheKey =
lineLength +
' ' +
firstLineNumber +
' ' +
this . lineNumberingService . djb2hash ( htmlOld ) +
this . lineNumberingService . djb2hash ( htmlNew ) ,
cached = this . diffCache . get ( cacheKey ) ;
if ( cached ) {
return cached ;
}
// This fixes a really strange artefact with the diff that occures under the following conditions:
// - The first tag of the two texts is identical, e.g. <p>
// - A change happens in the next tag, e.g. inserted text
// - The first tag occures a second time in the text, e.g. another <p>
// In this condition, the first tag is deleted first and inserted afterwards again
// Test case: "does not break when an insertion followes a beginning tag occuring twice"
// The work around inserts to tags at the beginning and removes them afterwards again,
// to make sure this situation does not happen (and uses invisible pseudo-tags in case something goes wrong)
const workaroundPrepend = '<DUMMY><PREPEND>' ;
// os-split-after should not be considered for detecting changes in paragraphs, so we strip it here
// and add it afterwards.
// We only do this for P for now, as for more complex types like UL/LI that tend to be nestend,
// information would get lost by this that we will need to recursively merge it again later on.
let oldIsSplitAfter = false ,
newIsSplitAfter = false ;
htmlOld = htmlOld . replace (
/(\s*<p[^>]+class\s*=\s*["'][^"']*)os-split-after/gi ,
( match : string , beginning : string ) : string = > {
oldIsSplitAfter = true ;
return beginning ;
}
) ;
htmlNew = htmlNew . replace (
/(\s*<p[^>]+class\s*=\s*["'][^"']*)os-split-after/gi ,
( match : string , beginning : string ) : string = > {
newIsSplitAfter = true ;
return beginning ;
}
) ;
// Performing the actual diff
const str = this . diffString ( workaroundPrepend + htmlOld , workaroundPrepend + htmlNew ) ;
let diffUnnormalized = str
. replace ( /^\s+/g , '' )
. replace ( /\s+$/g , '' )
. replace ( / {2,}/g , ' ' ) ;
diffUnnormalized = this . fixWrongChangeDetection ( diffUnnormalized ) ;
// Remove <del> tags that only delete line numbers
// We need to do this before removing </del><del> as done in one of the next statements
diffUnnormalized = diffUnnormalized . replace (
/<del>((<BR CLASS="os-line-break"><\/del><del>)?(<span[^>]+os-line-number[^>]+?>)(\s|<\/?del>)*<\/span>)<\/del>/gi ,
( found : string , tag : string , br : string , span : string ) : string = > {
return ( br !== undefined ? br : '' ) + span + ' </span>' ;
}
) ;
2018-11-19 20:39:44 +01:00
// Merging individual insert/delete statements into bigger blocks
2018-09-09 18:52:47 +02:00
diffUnnormalized = diffUnnormalized . replace ( /<\/ins><ins>/gi , '' ) . replace ( /<\/del><del>/gi , '' ) ;
// If only a few characters of a word have changed, don't display this as a replacement of the whole word,
// but only of these specific characters
diffUnnormalized = diffUnnormalized . replace (
/<del>([a-z0-9,_-]* ?)<\/del><ins>([a-z0-9,_-]* ?)<\/ins>/gi ,
( found : string , oldText : string , newText : string ) : string = > {
let foundDiff = false ,
commonStart = '' ,
commonEnd = '' ,
remainderOld = oldText ,
remainderNew = newText ;
while ( remainderOld . length > 0 && remainderNew . length > 0 && ! foundDiff ) {
if ( remainderOld [ 0 ] === remainderNew [ 0 ] ) {
commonStart += remainderOld [ 0 ] ;
remainderOld = remainderOld . substr ( 1 ) ;
remainderNew = remainderNew . substr ( 1 ) ;
} else {
foundDiff = true ;
}
}
foundDiff = false ;
while ( remainderOld . length > 0 && remainderNew . length > 0 && ! foundDiff ) {
if ( remainderOld [ remainderOld . length - 1 ] === remainderNew [ remainderNew . length - 1 ] ) {
commonEnd = remainderOld [ remainderOld . length - 1 ] + commonEnd ;
remainderNew = remainderNew . substr ( 0 , remainderNew . length - 1 ) ;
remainderOld = remainderOld . substr ( 0 , remainderOld . length - 1 ) ;
} else {
foundDiff = true ;
}
}
let out = commonStart ;
if ( remainderOld !== '' ) {
out += '<del>' + remainderOld + '</del>' ;
}
if ( remainderNew !== '' ) {
out += '<ins>' + remainderNew + '</ins>' ;
}
out += commonEnd ;
return out ;
}
) ;
// Replace spaces in line numbers by
diffUnnormalized = diffUnnormalized . replace (
/<span[^>]+os-line-number[^>]+?>\s*<\/span>/gi ,
( found : string ) : string = > {
return found . toLowerCase ( ) . replace ( /> <\/span/gi , '> </span' ) ;
}
) ;
2018-11-19 20:39:44 +01:00
// If larger inserted HTML text contains block elements, we separate the inserted text into
// inline <ins> elements and "insert"-class-based block elements.
// <ins>...<div>...</div>...</ins> => <ins>...</ins><div class="insert">...</div><ins>...</ins>
2018-10-27 11:39:38 +02:00
diffUnnormalized = diffUnnormalized . replace (
2018-11-19 20:39:44 +01:00
/<(ins|del)>([\s\S]*?)<\/\1>/gi ,
( whole : string , insDel : string ) : string = > {
2018-11-04 11:11:48 +01:00
const modificationClass = insDel . toLowerCase ( ) === 'ins' ? 'insert' : 'delete' ;
2018-11-19 20:39:44 +01:00
return whole . replace (
/(<(p|div|blockquote|li)[^>]*>)([\s\S]*?)(<\/\2>)/gi ,
( whole2 : string , opening : string , blockTag : string , content : string , closing : string ) : string = > {
const modifiedTag = this . addClassToHtmlTag ( opening , modificationClass ) ;
return '</' + insDel + '>' + modifiedTag + content + closing + '<' + insDel + '>' ;
}
) ;
}
) ;
// Cleanup leftovers from the operation above, when <ins></ins>-tags ore <ins> </ins>-tags are left
// around block tags. It should be safe to remove them and just leave the whitespaces.
diffUnnormalized = diffUnnormalized . replace (
/<(ins|del)>(\s*)<\/\1>/gi ,
( whole : string , insDel : string , space : string ) : string = > space
) ;
// <del></p><ins> Added text</p></ins> -> <ins> Added text</ins></p>
diffUnnormalized = diffUnnormalized . replace (
/<del><\/(p|div|blockquote|li)><\/del><ins>([\s\S]*?)<\/\1>(\s*)<\/ins>/gi ,
( whole : string , blockTag : string , content : string , space : string ) : string = > {
return '<ins>' + content + '</ins></' + blockTag + '>' + space ;
}
) ;
// </p> </ins> -> </ins></p>
diffUnnormalized = diffUnnormalized . replace (
/(<\/(p|div|blockquote|li)>)(\s*)<\/(ins|del)>/gi ,
( whole : string , ending : string , blockTag : string , space : string , insdel : string ) : string = > {
return '</' + insdel + '>' + ending + space ;
2018-10-27 11:39:38 +02:00
}
) ;
2018-09-09 18:52:47 +02:00
if ( diffUnnormalized . substr ( 0 , workaroundPrepend . length ) === workaroundPrepend ) {
diffUnnormalized = diffUnnormalized . substring ( workaroundPrepend . length ) ;
}
let diff : string ;
if ( this . diffDetectBrokenDiffHtml ( diffUnnormalized ) ) {
diff = this . diffParagraphs ( htmlOld , htmlNew , lineLength , firstLineNumber ) ;
} else {
let node : Element = document . createElement ( 'div' ) ;
node . innerHTML = diffUnnormalized ;
diff = node . innerHTML ;
if ( lineLength !== null && firstLineNumber !== null ) {
node = this . lineNumberingService . insertLineNumbersNode ( diff , lineLength , null , firstLineNumber ) ;
diff = node . innerHTML ;
}
}
if ( oldIsSplitAfter || newIsSplitAfter ) {
diff = this . addClassToLastNode ( diff , 'os-split-after' ) ;
}
this . diffCache . put ( cacheKey , diff ) ;
return diff ;
}
2018-09-30 18:43:20 +02:00
/ * *
* Applies all given changes to the motion and returns the ( line - numbered ) text
*
* @param { ViewMotion } motion
* @param { ViewUnifiedChange [ ] } changes
* @param { number } lineLength
* @param { number } highlightLine
* /
public getTextWithChanges (
motion : ViewMotion ,
changes : ViewUnifiedChange [ ] ,
lineLength : number ,
highlightLine : number
) : string {
let html = motion . text ;
// Changes need to be applied from the bottom up, to prevent conflicts with changing line numbers.
changes . sort ( ( change1 : ViewUnifiedChange , change2 : ViewUnifiedChange ) = > {
if ( change1 . getLineFrom ( ) < change2 . getLineFrom ( ) ) {
return 1 ;
} else if ( change1 . getLineFrom ( ) > change2 . getLineFrom ( ) ) {
return - 1 ;
} else {
return 0 ;
}
} ) ;
changes . forEach ( ( change : ViewUnifiedChange ) = > {
html = this . lineNumberingService . insertLineNumbers ( html , lineLength , null , null , 1 ) ;
html = this . replaceLines ( html , change . getChangeNewText ( ) , change . getLineFrom ( ) , change . getLineTo ( ) ) ;
} ) ;
html = this . lineNumberingService . insertLineNumbers ( html , lineLength , highlightLine , null , 1 ) ;
return html ;
}
2018-11-04 11:11:48 +01:00
/ * *
* This is used to extract affected lines of a paragraph with the possibility to show the context ( lines before
* and after ) the changed lines and displaying the line numbers .
*
* @param { number } paragraphNo The paragraph number
* @param { string } origText The original text - needs to be line - numbered
* @param { string } newText The changed text
* @param { number } lineLength the line length
* @return { DiffLinesInParagraph | null }
* /
public getAmendmentParagraphsLinesByMode (
paragraphNo : number ,
origText : string ,
newText : string ,
lineLength : number
) : DiffLinesInParagraph {
const paragraph_line_range = this . lineNumberingService . getLineNumberRange ( origText ) ,
diff = this . diff ( origText , newText ) ,
affected_lines = this . detectAffectedLineRange ( diff ) ;
if ( affected_lines === null ) {
return null ;
}
let textPre = '' ;
let textPost = '' ;
if ( affected_lines . from > paragraph_line_range . from ) {
textPre = this . formatDiffWithLineNumbers (
this . extractRangeByLineNumbers ( diff , paragraph_line_range . from , affected_lines . from ) ,
lineLength ,
paragraph_line_range . from
) ;
}
if ( paragraph_line_range . to > affected_lines . to ) {
textPost = this . formatDiffWithLineNumbers (
this . extractRangeByLineNumbers ( diff , affected_lines . to , paragraph_line_range . to ) ,
lineLength ,
affected_lines . to
) ;
}
const text = this . formatDiffWithLineNumbers (
this . extractRangeByLineNumbers ( diff , affected_lines . from , affected_lines . to ) ,
lineLength ,
affected_lines . from
) ;
return {
paragraphNo : paragraphNo ,
paragraphLineFrom : paragraph_line_range.from ,
paragraphLineTo : paragraph_line_range.to ,
diffLineFrom : affected_lines.from ,
diffLineTo : affected_lines.to ,
textPre : textPre ,
text : text ,
textPost : textPost
} as DiffLinesInParagraph ;
}
2018-09-09 18:52:47 +02:00
}