diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.html b/client/src/app/site/motions/components/motion-detail/motion-detail.component.html index ec17111f6..76d4db5fd 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.html +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.html @@ -275,8 +275,11 @@
' +
+ noMarkup(1) +
+ 'Line 1 ' +
+ brMarkup(2) +
+ 'Line 2 ' +
+ brMarkup(3) +
+ 'Line 3
' +
+ noMarkup(4) +
+ 'Line 4 ' +
+ brMarkup(5) +
+ 'Line 5
' + + noMarkup(10) + + 'Line 10 ' + + brMarkup(11) + + 'Line 11
'; + let baseHtmlDom1: DocumentFragment; + + const baseHtml2 = + '' + + noMarkup(1) + + 'Single text line
\ +' + + noMarkup(2) + + 'sdfsdfsdfsdf dsfsdfsdfdsflkewjrl ksjfl ksdjf klnlkjBavaria ipsum dolor sit amet Biazelt Auffisteign ' + + brMarkup(3) + + 'Schorsch mim Radl foahn Ohrwaschl Steckerleis wann griagd ma nacha wos z’dringa glacht Mamalad, ' + + brMarkup(4) + + 'muass? I bin a woschechta Bayer sowos oamoi und sei und glei wirds no fui lustiga: Jo mei khkhis des ' + + brMarkup(5) + + 'schee middn ognudelt, Trachtnhuat Biawambn gscheid: Griasd eich midnand etza nix Gwiass woass ma ned ' + + brMarkup(6) + + 'owe. Dahoam gscheckate middn Spuiratz des is a gmahde Wiesn. Des is schee so Obazda san da, Haferl ' + + brMarkup(7) + + 'pfenningguat schoo griasd eich midnand.
\ +' + + noMarkup(14) + + 'I waar soweid Blosmusi es nomoi. Broadwurschtbudn des is a gmahde Wiesn Kirwa mogsd a Bussal ' + + brMarkup(15) + + 'Guglhupf schüds nei. Luja i moan oiwei Baamwach Watschnbaam, wiavui baddscher! Biakriagal a fescha ' + + brMarkup(16) + + '1Bua Semmlkneedl iabaroi oba um Godds wujn Ledahosn wui Greichats. Geh um Godds wujn luja heid ' + + brMarkup(17) + + 'greaßt eich nachad woaß Breihaus eam! De om auf’n Gipfe auf gehds beim Schichtl mehra Baamwach a ' + + brMarkup(18) + + 'bissal wos gehd ollaweil gscheid:
\ +\ +\ +' + + noMarkup(19) + + 'Scheans Schdarmbeaga See i hob di narrisch gean i jo mei is des schee! Nia eam ' + + brMarkup(20) + + 'hod vasteh i sog ja nix, i red ja bloß sammawiedaguad, umma eana obandeln! Zwoa ' + + brMarkup(21) + + 'jo mei scheans amoi, san und hoggd Milli barfuaßat gscheit. Foidweg vui huift ' + + brMarkup(22) + + 'vui singan, mehra Biakriagal om auf’n Gipfe! Ozapfa sodala Charivari greaßt eich ' + + brMarkup(23) + + 'nachad Broadwurschtbudn do middn liberalitas Bavariae sowos Leonhardifahrt:
\ +
' + + noMarkup(24) + + 'Wui helfgod Wiesn, ognudelt schaugn: Dahoam gelbe Rüam Schneid singan wo hi sauba i moan scho aa no ' + + brMarkup(25) + + 'a Maß a Maß und no a Maß nimma. Is umananda a ganze Hoiwe zwoa, Schneid. Vui huift vui Brodzeid kumm ' + + brMarkup(26) + + 'geh naa i daad vo de allerweil, gor. Woaß wia Gams, damischa. A ganze Hoiwe Ohrwaschl Greichats ' + + brMarkup(27) + + 'iabaroi Prosd Engelgwand nix Reiwadatschi.Weibaleid ognudelt Ledahosn noch da Giasinga Heiwog i daad ' + + brMarkup(28) + + 'Almrausch, Ewig und drei Dog nackata wea ko, dea ko. Meidromml Graudwiggal nois dei, nackata. No ' + + brMarkup(29) + + 'Diandldrahn nix Gwiass woass ma ned hod boarischer: Samma sammawiedaguad wos, i hoam Brodzeid. Jo ' + + brMarkup(30) + + 'mei Sepp Gaudi, is ma Wuascht do Hendl Xaver Prosd eana an a bravs. Sauwedda an Brezn, abfieseln.
'; + let baseHtmlDom2: DocumentFragment; + + const baseHtml3 = + 'Line 1 '); + expect(diff.outerContextStart).toBe(''); + expect(diff.outerContextEnd).toBe(''); + })); + + it('extracts lines from nested UL/LI-structures', inject([DiffService], (service: DiffService) => { + const diff = service.extractRangeByLineNumbers(baseHtml1, 7, 9); + expect(diff.html).toBe( + 'Line 7
' + + noMarkup(1) + + 'Line 1
' + + '' + + noMarkup(2) + + 'Line 2' + + brMarkup(3) + + 'Line 3' + + brMarkup(4) + + 'Line 5
' + ); + expect(diff.outerContextEnd).toBe('
' + ); + })); + + it('extracts lines from double-nested UL/LI-structures (2)', inject([DiffService], (service: DiffService) => { + const html = + '
' + + noMarkup(1) + + 'Line 1
' + + '' + + noMarkup(2) + + 'Line 2' + + brMarkup(3) + + 'Line 3' + + brMarkup(4) + + '
Line 2' + ); + expect(diff.outerContextStart).toBe(''); + expect(diff.outerContextEnd).toBe(''); + expect(diff.innerContextStart).toBe(''); + expect(diff.innerContextEnd).toBe('
'); + })); + + it('extracts a single line right before a UL/LI', inject( + [DiffService, LinenumberingService], + (service: DiffService, lineNumbering: LinenumberingService) => { + // Test case for https://github.com/OpenSlides/OpenSlides/issues/3226 + let html = + '
A line
Another line
\nAnother line
\n'); + } + )); + + it('extracts lines from a more complex example', inject([DiffService], (service: DiffService) => { + const diff = service.extractRangeByLineNumbers(baseHtml2, 6, 11); + + expect(diff.html).toBe( + 'owe. Dahoam gscheckate middn Spuiratz des is a gmahde Wiesn. Des is schee so Obazda san da, Haferl pfenningguat schoo griasd eich midnand.'); + expect(diff.innerContextEnd).toBe('
'); + expect(diff.innerContextEnd).toBe(''); + expect(diff.previousHtmlEndSnippet).toBe('
'); + expect(diff.followingHtml).toBe(''); + expect(diff.followingHtmlStartSnippet).toBe(''); + })); + + it('preserves the numbering of OLs (1)', inject([DiffService], (service: DiffService) => { + const diff = service.extractRangeByLineNumbers(baseHtml3, 5, 7); + + expect(diff.html).toBe('' + + noMarkup(2) + + 'Another line
'; + const diff = service.extractRangeByLineNumbers(inHtml, 1, 2); + expect(diff.html).toBe('Replaced a UL by a P
', 6, 9); + expect(merged).toBe( + 'Line 1 Line 2 Line 3
Line 4 Line 5
Replaced a UL by a P
Line 10 Line 11
' + ); + })); + + it('replaces LIs by another LI', inject([DiffService], (service: DiffService) => { + const merged = service.replaceLines(baseHtml1, 'Line 1 Line 2 Line 3
Line 4 Line 5
Line 10 Line 11
' + ); + })); + + it('breaks up a paragraph into two', inject([DiffService], (service: DiffService) => { + const merged = service.replaceLines(baseHtml1, 'Replaced Line 10
Inserted Line 11
', 10, 11); + expect(merged).toBe( + 'Line 1 Line 2 Line 3
Line 4 Line 5
Replaced Line 10
Inserted Line 11 Line 11
' + ); + })); + + it('does not accidently merge two separate words', inject([DiffService], (service: DiffService) => { + const merged = service.replaceLines(baseHtml1, 'Line 1INSERTION
', 1, 2), + containsError = merged.indexOf('Line 1INSERTIONLine 2'), + containsCorrectVersion = merged.indexOf('Line 1INSERTION Line 2'); + expect(containsError).toBe(-1); + expect(containsCorrectVersion).toBe(3); + })); + + it('does not accidently merge two separate words, even in lists', inject( + [DiffService], + (service: DiffService) => { + // The newlines between UL and LI are the problem here + const merged = service.replaceLines( + baseHtml1, + '' + noMarkup(1) + 'foo & bar
', + after = '' + noMarkup(1) + 'foo & bar ins
'; + const merged = service.replaceLines(pre, after, 1, 2); + expect(merged).toBe('foo & bar ins
'); + })); + }); + + describe('detecting the type of change', () => { + it('detects a simple insertion', inject([DiffService], (service: DiffService) => { + const htmlBefore = 'Test 1
', + htmlAfter = 'Test 1 Test 2
' + '\n' + 'Test 3
'; + const calculatedType = service.detectReplacementType(htmlBefore, htmlAfter); + expect(calculatedType).toBe(ModificationType.TYPE_INSERTION); + })); + + it('detects a simple insertion, ignoring case of tags', inject([DiffService], (service: DiffService) => { + const htmlBefore = 'Test 1
', + htmlAfter = 'Test 1 Test 2
' + '\n' + 'Test 3
'; + const calculatedType = service.detectReplacementType(htmlBefore, htmlAfter); + expect(calculatedType).toBe(ModificationType.TYPE_INSERTION); + })); + + it('detects a simple insertion, ignoring trailing whitespaces', inject( + [DiffService], + (service: DiffService) => { + const htmlBefore = 'Lorem ipsum dolor sit amet, sed diam voluptua. At
', + htmlAfter = 'Lorem ipsum dolor sit amet, sed diam voluptua. At2
'; + const calculatedType = service.detectReplacementType(htmlBefore, htmlAfter); + expect(calculatedType).toBe(ModificationType.TYPE_INSERTION); + } + )); + + it('detects a simple insertion, ignoring spaces between UL and LI', inject( + [DiffService], + (service: DiffService) => { + const htmlBefore = 'dsds dsfsdfsdf sdf sdfs dds sdf dsds dsfsdfsdf
', + htmlAfter = 'dsds dsfsdfsdf sdf sdfs dds sd345 3453 45f dsds dsfsdfsdf
'; + const calculatedType = service.detectReplacementType(htmlBefore, htmlAfter); + expect(calculatedType).toBe(ModificationType.TYPE_INSERTION); + })); + + it('detects a simple deletion', inject([DiffService], (service: DiffService) => { + const htmlBefore = 'Test 1 Test 2
' + '\n' + 'Test 3
', + htmlAfter = 'Test 1
'; + const calculatedType = service.detectReplacementType(htmlBefore, htmlAfter); + expect(calculatedType).toBe(ModificationType.TYPE_DELETION); + })); + + it('detects a simple deletion, ignoring case of tags', inject([DiffService], (service: DiffService) => { + const htmlBefore = 'Test 1 Test 2
' + '\n' + 'Test 3
', + htmlAfter = 'Test 1
'; + const calculatedType = service.detectReplacementType(htmlBefore, htmlAfter); + expect(calculatedType).toBe(ModificationType.TYPE_DELETION); + })); + + it('detects a simple deletion, ignoring trailing whitespaces', inject([DiffService], (service: DiffService) => { + const htmlBefore = 'Lorem ipsum dolor sit amet, sed diam voluptua. At2
', + htmlAfter = 'Lorem ipsum dolor sit amet, sed diam voluptua. At
'; + const calculatedType = service.detectReplacementType(htmlBefore, htmlAfter); + expect(calculatedType).toBe(ModificationType.TYPE_DELETION); + })); + + it('detects a simple replacement', inject([DiffService], (service: DiffService) => { + const htmlBefore = 'Test 1 Test 2
' + '\n' + 'Test 3
', + htmlAfter = 'Test 1
' + '\n' + 'Test 2
' + '\n' + 'Test 3
'; + const calculatedType = service.detectReplacementType(htmlBefore, htmlAfter); + expect(calculatedType).toBe(ModificationType.TYPE_REPLACEMENT); + })); + }); + + describe('diff normalization', () => { + it('uppercases normal HTML tags', inject([DiffService], (service: DiffService) => { + const unnormalized = 'The brown fox', + normalized = service.normalizeHtmlForDiff(unnormalized); + expect(normalized).toBe('The brown fox'); + })); + + it('uppercases the names of html attributes, but not the values, and sort the attributes', inject( + [DiffService], + (service: DiffService) => { + const unnormalized = + 'This is our cool home page - have a look! ' + + '', + normalized = service.normalizeHtmlForDiff(unnormalized); + expect(normalized).toBe( + 'This is our cool home page - have a look! ' + + '' + ); + } + )); + + it('strips unnecessary spaces', inject([DiffService], (service: DiffService) => { + const unnormalized = 'Test
", + normalized = service.normalizeHtmlForDiff(unnormalized); + expect(normalized).toBe("Test
"); + })); + + it('treats newlines like spaces', inject([DiffService], (service: DiffService) => { + const unnormalized = 'Test line\n\t 2
', + normalized = service.normalizeHtmlForDiff(unnormalized); + expect(normalized).toBe('Test line 2
'); + })); + }); + + describe('the core diff algorithm', () => { + it('acts as documented by the official documentation', inject([DiffService], (service: DiffService) => { + const before = 'The red brown fox jumped over the rolling log.', + after = 'The brown spotted fox leaped over the rolling log.'; + const diff = service.diff(before, after); + expect(diff).toBe( + 'The' + noMarkup(1) + 'Hendl Kirwa hod Maßkruag
' + noMarkup(2) + 'gmahde Wiesn
Hendl Kirwa hod Maßkruag
\ngmahde Wiesn
Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
', + after = + 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
\n' + + '\n' + + 'Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu.
'; + const diff = service.diff(before, after); + expect(diff).toBe( + 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
\n' + + 'Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu.
' + ); + } + )); + + it('does not result in separate paragraphs when only the first word has changed', inject( + [DiffService], + (service: DiffService) => { + const before = + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor
', + after = + 'Bla ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor
'; + const diff = service.diff(before, after); + + expect(diff).toBe( + ' LoremBla ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor
Test1 Test2
", + after = "Test1 Test2
"; + const diff = service.diff(before, after); + + expect(diff).toBe('Test1 Test2
Test1 Test2
'); + })); + + it('handles inserted paragraphs', inject([DiffService], (service: DiffService) => { + const before = "liebliche Stimme, aber deine Stimme ist rauh; du bist der Wolf.' Da gieng der
", + after = + "liebliche Stimme, aber deine Stimme ist rauh; du bist der Wolf.'
\ +\ +Der Wolf hatte danach richtig schlechte laune, trank eine Flasche Rum,
\ +\ +machte eine Weltreise und kam danach wieder um die Ziegen zu fressen. Da ging der
", + expected = + 'liebliche Stimme, aber deine Stimme ist rauh; du bist der Wolf.\' Da gieng der
' + + 'liebliche Stimme, aber deine Stimme ist rauh; du bist der Wolf.\'
' + + 'Der Wolf hatte danach richtig schlechte laune, trank eine Flasche Rum,
' + + 'machte eine Weltreise und kam danach wieder um die Ziegen zu fressen. Da ging der
'; + + const diff = service.diff(before, after); + expect(diff).toBe(expected); + })); + + it('handles inserted paragraphs (2)', inject([DiffService], (service: DiffService) => { + // Specifically, Noch should not be enclosed by ..., as Noch would be seriously broken + const before = + "rief sie alle sieben herbei und sprach 'liebe Kinder, ich will hinaus in den Wald, seid
", + after = + "rief sie alle sieben herbei und sprach 'liebe Kinder, ich will hinaus in den Wald, seid Noch
" + + 'Test 123
', + expected = + "rief sie alle sieben herbei und sprach 'liebe Kinder, ich will hinaus in den Wald, seid Noch
" + + 'Test 123
'; + + const diff = service.diff(before, after); + expect(diff).toBe(expected); + })); + + it('handles insterted paragraphs (3)', inject([DiffService], (service: DiffService) => { + // Hint: os-split-after should be moved from the first paragraph to the second one + const before = + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr,
', + after = + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.
\n' + + '\n' + + 'Stet clita kasd gubergren, no sea takimata sanctus est.
', + expected = + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.
\n' + + 'Stet clita kasd gubergren, no sea takimata sanctus est.
'; + + const diff = service.diff(before, after); + expect(diff).toBe(expected); + })); + + it('handles inserted paragraphs (4)', inject([DiffService], (service: DiffService) => { + const before = 'This is a random first line that remains unchanged.
', + after = + 'This is a random first line that remains unchanged.
' + + 'Inserting this line should not make any troubles, especially not affect the first line
' + + 'Neither should this line
', + expected = + 'This is a random first line that remains unchanged.
' + + 'Inserting this line should not make any troubles, especially not affect the first line
' + + 'Neither should this line
'; + + const diff = service.diff(before, after); + expect(diff).toBe(expected); + })); + + it('handles completely deleted paragraphs', inject([DiffService], (service: DiffService) => { + const before = + "Ihr könnt ohne Sorge fortgehen.'Da meckerte die Alte und machte sich getrost auf den Weg.
", + after = ''; + const diff = service.diff(before, after); + expect(diff).toBe( + 'Ihr könnt ohne Sorge fortgehen.\'Da meckerte die Alte und machte sich getrost auf den Weg.
' + ); + })); + + it('does not repeat the last word (1)', inject([DiffService], (service: DiffService) => { + const before = 'sem. Nulla consequat massa quis enim.
', + after = 'sem. Nulla consequat massa quis enim. TEST
\nTEST
sem. Nulla consequat massa quis enim. TEST
TEST
...so frißt er Euch alle mit Haut und Haar.
', + after = '...so frißt er Euch alle mit Haut und Haar und Augen und Därme und alles.
'; + const diff = service.diff(before, after); + + expect(diff).toBe( + '...so frißt er Euch alle mit Haut und Haar und Augen und Därme und alles.
' + ); + })); + + it('does not break when an insertion followes a beginning tag occuring twice', inject( + [DiffService], + (service: DiffService) => { + const before = '...so frißt er Euch alle mit Haut und Haar.
\nTest
', + after = + 'Einfügung 1 ...so frißt er Euch alle mit Haut und Haar und Augen und Därme und alles.
\nTest
'; + const diff = service.diff(before, after); + + expect(diff).toBe( + 'Einfügung 1 ...so frißt er Euch alle mit Haut und Haar und Augen und Därme und alles.
\nTest
' + ); + } + )); + + it('does not lose formattings when multiple lines are deleted', inject( + [DiffService], + (service: DiffService) => { + const before = + '' + + noMarkup(13) + + 'diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd ' + + brMarkup(14) + + 'gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.
', + after = 'Test
'; + const diff = service.diff(before, after).toLowerCase(), + expected = + '' +
+ noMarkup(13) +
+ 'diam voluptua. at vero eos et accusam et justo duo dolores et ea rebum. stet clita kasd ' +
+ brMarkup(14) +
+ 'gubergren, no sea takimata sanctus est lorem ipsum dolor sit amet.' +
+ 'test
...so frißt er Euch alle mit Haut und Haar.
', + after = "...so frißt er Euch alle mit Haut und Haar.
"; + const diff = service.diff(before, after); + + expect(diff).toBe( + '...so frißt er Euch alle mit Haut und Haar.
...so frißt er Euch alle mit Haut und Haar.
' + ); + })); + + it('removed inline colors in inserted/deleted parts (2)', inject([DiffService], (service: DiffService) => { + const before = '...so frißt er Euch alle mit Haut und Haar.
', + after = + "...so frißt er Euch alle mit Haut und Haar.
"; + const diff = service.diff(before, after); + + expect(diff).toBe( + '...so frißt er Euch alle mit Haut und Haar.
...so frißt er Euch alle mit Haut und Haar.
' + ); + })); + + it('marks a single moved word as deleted and inserted again', inject([DiffService], (service: DiffService) => { + const before = + 'tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren bla, no sea takimata sanctus est Lorem ipsum dolor sit amet.
', + after = + 'tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd bla, no sea takimata sanctus est Lorem ipsum dolor gubergren sit amet.
'; + const diff = service.diff(before, after); + + expect(diff).toBe( + 'tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren bla, no sea takimata sanctus est Lorem ipsum dolor gubergren sit amet.
sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing
', + after = + 'sanctus est Lorem ipsum dolor sit amet. Test Lorem ipsum dolor sit amet, consetetur sadipscing
'; + const diff = service.diff(before, after); + + expect(diff).toBe( + 'sanctus est Lorem ipsum dolor sit amet. Test Lorem ipsum dolor sit amet, consetetur sadipscing
' + ); + })); + + it('does not lose words when changes are moved X-wise', inject([DiffService], (service: DiffService) => { + const before = 'elitr. einsetzt. VERSCHLUCKT noch die sog. Gleichbleibend (Wird gelöscht).', + after = 'elitr, Einfügung durch Änderung der Gleichbleibend, einsetzt.'; + + const diff = service.diff(before, after); + expect(diff).toBe( + 'elitrLorem ipsum dolor sit amet, consetetur
sadipscing elitr.
Bavaria ipsum dolor sit amet o’ha wea nia ausgähd
kummt nia hoam i hob di narrisch gean
Lorem ipsum dolor sit amet, consetetur sadipscing elitr. Sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua..
\n' +
+ 'Bavaria ipsum dolor sit amet o’ha wea nia ausgähd
\n' +
+ 'Autonomie erfährt ihre Grenzen
Lorem ipsum dolor sit amet, consetetur sadipscing elitr. Sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua..
Bavaria ipsum dolor sit amet o’ha wea nia ausgähdkummt nia hoam i hob di narrisch geanAutonomie erfährt ihre Grenzen
Test 123
wir strikt ab. lehnen wir ' +
+ brMarkup(1486) +
+ 'ab.
' +
+ noMarkup(1487) +
+ 'Gegenüber
Test 123
\n' + 'wir strikt ab. lehnen wir ab.
\n' + 'Gegenüber
Test 123
wir strikt ab. lehnen wir ' +
+ brMarkup(1486) +
+ 'ab.
' +
+ noMarkup(1487) +
+ 'Gegenüber
...so frißt er Euch alle mit Haut und Haar.
'; + const after = '...so frißt er Euch alle mit Haut und Haar und Augen und Därme und alles.
'; + before = lineNumbering.insertLineNumbers(before, 15, null, null, 2); + const diff = service.diff(before, after); + + expect(diff).toBe( + '' + + noMarkup(2) + + '...so frißt er ' + + brMarkup(3) + + 'Euch alle mit ' + + brMarkup(4) + + 'Haut und Haar und Augen und Därme und alles.
' + ); + } + )); + + it('works with an inserted paragraph', inject( + [DiffService, LinenumberingService], + (service: DiffService, lineNumbering: LinenumberingService) => { + let before = + 'their grammar, their pronunciation and their most common words. Everyone realizes why a
'; + const after = + 'their grammar, their pronunciation and their most common words. Everyone realizes why a
\n' + + 'NEW PARAGRAPH 2.
'; + + before = lineNumbering.insertLineNumbers(before, 80, null, null, 2); + const diff = service.diff(before, after); + expect(diff).toBe( + '' + + noMarkup(2) + + 'their grammar, their pronunciation and their most common words. Everyone ' + + brMarkup(3) + + 'realizes why a
\n' + + 'NEW PARAGRAPH 2.
' + ); + } + )); + + it('works with two inserted paragraphs', inject( + [DiffService, LinenumberingService], + (service: DiffService, lineNumbering: LinenumberingService) => { + // Hint: If the last paragraph is a P again, the Diff still fails and falls back to paragraph-based diff + // This leaves room for future improvements + let before = + 'their grammar, their pronunciation and their most common words. Everyone realizes why a
\ntheir grammar, their pronunciation and their most common words. Everyone realizes why a
\n' + + 'NEW PARAGRAPH 1.
\n' + + 'NEW PARAGRAPH 2.
\n' + + '' + + noMarkup(2) + + 'their grammar, their pronunciation and their most common words. Everyone ' + + brMarkup(3) + + 'realizes why a
\n' + + 'NEW PARAGRAPH 1.
\n' + + 'NEW PARAGRAPH 2.
\n' + + 'holen, da rief sie alle sieben herbei und sprach:
\n\n "Liebe Kinder, ich will hinaus in den Wald, seid auf der Hut vor dem Wolf! Wenn er
hereinkommt, frisst er euch alle mit Haut und Haar. Der Bösewicht verstellt sich oft, aber
an der rauen Stimme und an seinen schwarzen Füßen werdet ihr ihn schon erkennen."
Die Geißlein sagten: " Liebe Mutter, wir wollen uns schon in acht nehmen, du kannst ohne
', + after = + 'holen, da rief sie alle sieben herbei und sprach:
\n\nHello
\n\nWorld
\n\nYa
\n\nDie Geißlein sagten: " Liebe Mutter, wir wollen uns schon in acht nehmen, du kannst ohne
'; + const diff = service.diff(before, after); + expect(diff).toBe( + 'holen, da rief sie alle sieben herbei und sprach:
"Liebe Kinder, ich will hinaus in den Wald, seid auf der Hut vor dem Wolf! Wenn er
hereinkommt, frisst er euch alle mit Haut und Haar. Der Bösewicht verstellt sich oft, aber
an der rauen Stimme und an seinen schwarzen Füßen werdet ihr ihn schon erkennen."
Die Geißlein sagten: " Liebe Mutter, wir wollen uns schon in acht nehmen, du kannst ohne
' + + 'holen, da rief sie alle sieben herbei und sprach:
\n\n' + + 'Hello
\n\n' + + 'World
\n\n' + + 'Ya
\n\n' + + 'Die Geißlein sagten: " Liebe Mutter, wir wollen uns schon in acht nehmen, du kannst ohne
' + ); + })); + + it('line breaks at dashes does not delete/insert the last/first word of the split lines', inject( + [DiffService, LinenumberingService], + (service: DiffService, lineNumbering: LinenumberingService) => { + let before = + '' + + noMarkup(1) + + 'foo & bar' + + brMarkup(2) + + 'Another line' + + brMarkup(3) + + 'This will be changed' + + brMarkup(4) + + 'This, too' + + brMarkup(5) + + 'End
', + after = + '' + + noMarkup(1) + + 'foo & bar' + + brMarkup(2) + + 'Another line' + + brMarkup(3) + + 'This has been changed' + + brMarkup(4) + + 'End
'; + + const diff = service.diff(before, after); + const affected = service.detectAffectedLineRange(diff); + expect(affected).toEqual({ from: 3, to: 5 }); + })); + it('detects changed line numbers at the beginning', inject( + [DiffService, LinenumberingService], + (service: DiffService, lineNumbering: LinenumberingService) => { + let before = + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat
'; + const after = + 'sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat
'; + + before = lineNumbering.insertLineNumbers(before, 20); + const diff = service.diff(before, after); + + const affected = service.detectAffectedLineRange(diff); + expect(affected).toEqual({ from: 1, to: 2 }); + } + )); + }); + + describe('stripping ins/del-styles/tags', () => { + it('deletes to be deleted nodes', inject([DiffService], (service: DiffService) => { + const inHtml = + 'Test Test 2 Another test Test 3
Test 4
'; + const stripped = service.diffHtmlToFinalText(inHtml); + expect(stripped).toBe('Test Another test
'); + })); + + it('produces empty paragraphs, if necessary', inject([DiffService], (service: DiffService) => { + const inHtml = + 'Test Test 2 Another test Test 3
Test 4
'; + const stripped = service.diffHtmlToFinalText(inHtml); + expect(stripped).toBe(''); + })); + + it('Removes INS-tags', inject([DiffService], (service: DiffService) => { + const inHtml = 'Test Test 2 Another test
'; + const stripped = service.diffHtmlToFinalText(inHtml); + expect(stripped).toBe('Test Test 2 Another test
'); + })); + + it('Removes .insert-classes', inject([DiffService], (service: DiffService) => { + const inHtml = + 'Test 1
Test 2
'; + const stripped = service.diffHtmlToFinalText(inHtml); + expect(stripped).toBe('Test 1
Test 2
'); + })); + }); +}); diff --git a/client/src/app/site/motions/services/diff.service.ts b/client/src/app/site/motions/services/diff.service.ts new file mode 100644 index 000000000..0a034beaf --- /dev/null +++ b/client/src/app/site/motions/services/diff.service.ts @@ -0,0 +1,1986 @@ +import { Injectable } from '@angular/core'; +import { LinenumberingService } from './linenumbering.service'; + +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 2A line
Another line
Lorem ipsum dolor sit amet, sed diam voluptua. At
'; + * const beforeLineNumbered = this.lineNumbering.insertLineNumbers(before, 80) + * const after = 'Lorem ipsum dolor sit amet, sed diam voluptua. At2
'; + * 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 = 'Test Test 2 Another test Test 3
Test 4
'; + * 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('A line
Another line
Replaced paragraph
', 1, 2); + * ``` + */ +@Injectable({ + providedIn: 'root' +}) +export class DiffService { + private diffCache = { + get: (key: string) => undefined, + put: (key: string, val: any) => undefined + }; // @TODO + + /** + * 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 =Line 1
Line 2
Line 3
Line 1
+ * - extracting line 2 to 3 results inLine 2
+ * - extracting line 3 to null/4 results inLine 3
+ * + * @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 =+ // - A change happens in the next tag, e.g. inserted text + // - The first tag occures a second time in the text, e.g. another
+ // 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 = ' ]+class\s*=\s*["'][^"']*)os-split-after/gi,
+ (match: string, beginning: string): string => {
+ oldIsSplitAfter = true;
+ return beginning;
+ }
+ );
+ htmlNew = htmlNew.replace(
+ /(\s* ]+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 ]*)?>[\s\S]*?<\/p>)(\s*)<\/ins>/gim,
+ (match: string, whiteBefore: string, inner: string, tagInner: string, whiteAfter: string): string => {
+ return (
+ whiteBefore +
+ inner
+ .replace(
+ / ]*)?>/gi,
+ (match2: string): string => {
+ return match2 + '';
+ }
+ )
+ .replace(/<\/p>/gi, ' tags that only delete line numbers
+ // We need to do this before removing as done in one of the next statements
+ diffUnnormalized = diffUnnormalized.replace(
+ /((
<\/del>)?(]+os-line-number[^>]+?>)(\s|<\/?del>)*<\/span>)<\/del>/gi,
+ (found: string, tag: string, br: string, span: string): string => {
+ return (br !== undefined ? br : '') + span + ' ';
+ }
+ );
+
+ diffUnnormalized = diffUnnormalized.replace(/<\/ins>/gi, '').replace(/<\/del>/gi, '');
+
+ // Move whitespaces around inserted P's out of the INS-tag
+ diffUnnormalized = diffUnnormalized.replace(
+ /(\s*)(
More inserted text
+ // into: Inserted Text\nMore inserted text
+ diffUnnormalized = diffUnnormalized.replace( + /)/gi, '
$1'); + } + ); + + // 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( + /(.*)<\/p><\/del>$/gi, + (match: string, inner: string): string => { + return '
' + inner + '
'; + } + ); + + 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; + } +} diff --git a/client/src/app/site/motions/services/linenumbering.service.spec.ts b/client/src/app/site/motions/services/linenumbering.service.spec.ts new file mode 100644 index 000000000..44471be19 --- /dev/null +++ b/client/src/app/site/motions/services/linenumbering.service.spec.ts @@ -0,0 +1,775 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { LinenumberingService } from './linenumbering.service'; + +describe('LinenumberingService', () => { + const brMarkup = (no: number): string => { + return ( + 'Test 2 3
'; + const out = service.splitToParagraphs(htmlIn); + expect(out.length).toBe(2); + expect(out[0]).toBe('Test 2 3
'); + })); + it('ignores root-level text-nodes', inject([LinenumberingService], (service: LinenumberingService) => { + const htmlIn = 'Node 3
Node 3
Node 3
Node 3
' +
+ noMarkup(2) +
+ 'et accusam et justo duo dolores et ea rebum Inserted Text. Stet clita kasd ' +
+ brMarkup(3) +
+ 'gubergren,
leo Testelefantgeweih Buchstabenwut als Achzehnzahlunginer. Hierbei darfsetzen bist der Deifi das Dor Reh Wachtel da Subjunktivier als Derftige Aalsan Orthopädische, der Arbeitsnachweisdiskus Bass der Tastatur Weiter schreiben wie Tasse Wasser als dienen.
' +
+ noMarkup(1) +
+ 'leo Testelefantgeweih Buchstabenwut als Achzehnzahlunginer. Hierbei darfsetzen bist der Deifi das ' +
+ brMarkup(2) +
+ 'Dor Reh Wachtel da Subjunktivier als Derftige Aalsan Orthopädische, der Arbeitsnachweisdiskus Bass der Tastatur ' +
+ brMarkup(3) +
+ 'Weiter schreiben wie Tasse Wasser als dienen.
et accusam et justo duo dolores et ea rebum Inserted Text. Stet clita kasd gubergren,
' +
+ noMarkup(1) +
+ 'et accusam et justo duo dolores et ea rebum Inserted Text. Stet clita kasd ' +
+ brMarkup(2) +
+ 'gubergren,
Test 123\nTest1
'; + const outHtml = service.insertLineNumbers(inHtml, 5); + expect(outHtml).toBe('' + noMarkup(1) + 'Test ' + brMarkup(2) + '123\n' + brMarkup(3) + 'Test1
'); + } + )); + }); + + describe('line numbering: block nodes', () => { + it('leaves a simple DIV untouched', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = '' + longstr(100) + '' + longstr(100) + '
' + + noMarkup(3) + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGH' + + brMarkup(4) + + 'IJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUV' + + '' + + noMarkup(5) + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZAB' + + brMarkup(6) + + 'CDEFGHIJKLMNOPQRSTUV
Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio.
'; + const outHtml = service.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe( + '' + + noMarkup(1) + + 'Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie ' + + brMarkup(2) + + 'consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan ' + + brMarkup(3) + + 'et iusto odio.
' + ); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + } + )); + + it('breaks before an inline element, if the first word of the new inline element is longer than the remaining line (2)', inject( + [LinenumberingService], + (service: LinenumberingService) => { + const inHtml = + 'Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio.
'; + const outHtml = service.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe( + '' + + noMarkup(1) + + 'Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie ' + + brMarkup(2) + + 'consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan ' + + brMarkup(3) + + 'et iusto odio.
' + ); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + } + )); + + it('does not fail in a weird case', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = 'seid NochTest 123
'; + const outHtml = service.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe( + noMarkup(1) + 'seid Noch' + noMarkup(2) + 'Test 123
' + ); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + })); + }); + + describe('line numbering in regard to the inline diff', () => { + it('does not count within INS nodes', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = '1234 1234 1234 1234'; + const outHtml = service.insertLineNumbers(inHtml, 10); + expect(outHtml).toBe(noMarkup(1) + '1234 1234 1234 ' + brMarkup(2) + '1234'); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + })); + + it('does not create a new line for a trailing INS', inject( + [LinenumberingService], + (service: LinenumberingService) => { + const inHtml = + 'et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, conseteturdsfsdf23
'; + const outHtml = service.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe( + '' + + noMarkup(1) + + 'et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata ' + + brMarkup(2) + + 'sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, conseteturdsfsdf23
' + ); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + } + )); + + it('inserts the line number before the INS, if INS is the first element of the paragraph', inject( + [LinenumberingService], + (service: LinenumberingService) => { + const inHtml = + "lauthals 'liebe Kinder, ich will hinaus in den Wald, seid auf der Hut vor dem Wolf!' Und noch etwas mehr Text bis zur nächsten Zeile
"; + const outHtml = service.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe( + '' + + noMarkup(1) + + "lauthals 'liebe Kinder, ich will hinaus in den Wald, seid auf der Hut vor dem Wolf!' Und " + + brMarkup(2) + + 'noch etwas mehr Text bis zur nächsten Zeile
' + ); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + } + )); + + it('cancels newlines after br-elements', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = 'Test 123
\nTest 456
' + noMarkup(1) + 'Test 123
' + noMarkup(2) + 'Test 456
' + noMarkup(1) + '012345 78 01 345678901234567890123456789
'; + const outHtml = service.insertLineBreaksWithoutNumbers(inHtml, 20, true); + expect(outHtml).toBe( + '' +
+ noMarkup(1) +
+ '012345 78 01
34567890123456789012
3456789
' + longstr(100) + '' + longstr(100) + '
' + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGH' + + plainBr + + 'IJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUV' + + '' + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZAB' + + plainBr + + 'CDEFGHIJKLMNOPQRSTUV
et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, conseteturdsfsdf23
'; + const outHtml = service.insertLineBreaksWithoutNumbers(inHtml, 80, true); + expect(outHtml).toBe( + 'et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata ' + + plainBr + + 'sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur' + + plainBr + + 'dsfsdf23
' + ); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + } + )); + + it('ignores witespaces by previously added line numbers', inject( + [LinenumberingService], + (service: LinenumberingService) => { + const inHtml = '' + noMarkup(1) + longstr(10) + '
'; + const outHtml = service.insertLineBreaksWithoutNumbers(inHtml, 10, true); + expect(outHtml).toBe('' + noMarkup(1) + longstr(10) + '
'); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + } + )); + }); + + describe('behavior regarding ckeditor', () => { + it('does not count empty lines, case 1', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = 'Line 1
\n\nLine 2
'; + const outHtml = service.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe('' + noMarkup(1) + 'Line 1
' + '\n\n' + '' + noMarkup(2) + 'Line 2
'); + })); + + it('does not count empty lines, case 2', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = 'Line 1
' + + 'Line 2
Line 3
Line 4
' + + 'Line 5
'; + inHtml = service.insertLineNumbers(inHtml, 80); + const structure = service.getHeadingsWithLineNumbers(inHtml); + expect(structure).toEqual([ + { lineNumber: 2, level: 1, text: 'Heading 1' }, + { lineNumber: 4, level: 2, text: 'Heading 1.1' }, + { lineNumber: 6, level: 2, text: 'Heading 1.2' }, + { lineNumber: 8, level: 1, text: 'Heading 2' }, + { lineNumber: 9, level: 2, text: 'Heading 2.1' } + ]); + })); + }); + + describe('caching', () => { + it('caches based on line length', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = '' + longstr(100) + '
'; + const outHtml80 = service.insertLineNumbers(inHtml, 80); + const outHtml70 = service.insertLineNumbers(inHtml, 70); + expect(outHtml70).not.toBe(outHtml80); + })); + }); +}); diff --git a/client/src/app/site/motions/services/linenumbering.service.ts b/client/src/app/site/motions/services/linenumbering.service.ts new file mode 100644 index 000000000..9e79dc613 --- /dev/null +++ b/client/src/app/site/motions/services/linenumbering.service.ts @@ -0,0 +1,1023 @@ +import { Injectable } from '@angular/core'; + +const ELEMENT_NODE = 1; +const TEXT_NODE = 3; + +/** + * Specifies a point within a HTML Text Node where a line break might be possible, if the following word + * exceeds the maximum line length. + */ +interface BreakablePoint { + /** + * The Text node which is a candidate to be split into two. + */ + node: Node; + /** + * The exact offset of the found breakable point. + */ + offset: number; +} + +/** + * An object specifying a range of line numbers. + */ +interface LineNumberRange { + /** + * 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; +} + +/** + * Specifies a heading element (H1, H2, H3, H4, H5, H6) within a HTML document. + */ +interface SectionHeading { + /** + * The first line number of this element. + */ + lineNumber: number; + /** + * The nesting level. H1 = 1, H2 = 2, etc. + */ + level: number; + /** + * The text content of this heading. + */ + text: string; +} + +/** + * Functionality regarding adding and removing line numbers and highlighting single lines. + * + * ## Examples: + * + * Adding line numbers to an HTML string: + * + * ```ts + * const lineLength = 80; + * const originalHtml = 'Lorem ipsum dolorsit amet
'; + * const lineNumberedHtml = this.lineNumbering.insertLineNumbers(inHtml, lineLength); + * ``` + * + * Removing line numbers from a line-numbered string: + * ```ts + * const lineNumberedHtml = 'Lorem ipsum dolorsit amet
'; + * const originalHtml = this.lineNumbering.stripLineNumbers(inHtml); + * ``` + * + * Splitting a HTML string into an array of paragraphs: + * + * ```ts + * const htmlIn = 'Paragraph 1
Some introductional paragraph
Another paragraph
+ * const headings = this.lineNumbering.getHeadingsWithLineNumbers(html); + * ``` + */ +@Injectable({ + providedIn: 'root' +}) +export class LinenumberingService { + /** + * @TODO + * This is just a stub for a caching system. The original code from Angular1 was: + * var lineNumberCache = $cacheFactory('linenumbering.service'); + * This should be replaced by a real cache once we have decided on a caching service for OpenSlides 3 + */ + private lineNumberCache = { + get: (key: string) => undefined, + put: (key: string, val: any) => undefined + }; + + // Counts the number of characters in the current line, beyond singe nodes. + // Needs to be resetted after each line break and after entering a new block node. + private currentInlineOffset: number = null; + + // The last position of a point suitable for breaking the line. null or an object with the following values: + // - node: the node that contains the position. Guaranteed to be a TextNode + // - offset: the offset of the breaking characters (like the space) + // Needs to be resetted after each line break and after entering a new block node. + private lastInlineBreakablePoint: BreakablePoint = null; + + // The line number counter + private currentLineNumber: number = null; + + // Indicates that we just entered a block element and we want to add a line number without line break at the beginning. + private prependLineNumberToFirstText = false; + + // A workaround to prevent double line numbers + private ignoreNextRegularLineNumber = false; + + // Decides if the content of inserted nodes should count as well. This is used so we can use the algorithm on a + // text with inline diff annotations and get the same line numbering as with the original text (when set to false) + private ignoreInsertedText = false; + + /** + * Creates a hash of a given string. This is not meant to be specifically secure, but rather as quick as possible. + * + * @param {string} str + * @returns {string} + */ + public djb2hash(str: string): string { + let hash = 5381; + let char; + for (let i = 0; i < str.length; i++) { + char = str.charCodeAt(i); + // tslint:disable-next-line:no-bitwise + hash = (hash << 5) + hash + char; + } + return hash.toString(); + } + + /** + * Returns true, if the provided element is an inline element (hard-coded list of known elements). + * + * @param {Element} element + * @returns {boolean} + */ + private isInlineElement(element: Element): boolean { + const inlineElements = [ + 'SPAN', + 'A', + 'EM', + 'S', + 'B', + 'I', + 'STRONG', + 'U', + 'BIG', + 'SMALL', + 'SUB', + 'SUP', + 'TT', + 'INS', + 'DEL', + 'STRIKE' + ]; + return inlineElements.indexOf(element.nodeName) > -1; + } + + /** + * Returns true, if the given node is a OpenSlides-specific line breaking node. + * + * @param {Node} node + * @returns {boolean} + */ + public isOsLineBreakNode(node: Node): boolean { + let isLineBreak = false; + if (node && node.nodeType === ELEMENT_NODE) { + const element =or