From db25ac6bf470614700cdacadab6a412b6b97ff67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Ho=CC=88=C3=9Fl?= Date: Sun, 9 Sep 2018 18:52:47 +0200 Subject: [PATCH 1/2] Diff & Line numbering services --- .../motion-detail.component.html | 7 +- .../motion-detail.component.scss | 87 + .../motion-detail/motion-detail.component.ts | 37 +- .../app/site/motions/models/view-motion.ts | 17 +- .../motions/services/diff.service.spec.ts | 1168 ++++++++++ .../app/site/motions/services/diff.service.ts | 1986 +++++++++++++++++ .../services/linenumbering.service.spec.ts | 775 +++++++ .../motions/services/linenumbering.service.ts | 1023 +++++++++ .../services/motion-repository.service.ts | 35 +- 9 files changed, 5109 insertions(+), 26 deletions(-) create mode 100644 client/src/app/site/motions/services/diff.service.spec.ts create mode 100644 client/src/app/site/motions/services/diff.service.ts create mode 100644 client/src/app/site/motions/services/linenumbering.service.spec.ts create mode 100644 client/src/app/site/motions/services/linenumbering.service.ts 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 @@

The assembly may decide:

-
-
+
+
diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss b/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss index efe9ac80c..5bd03e27b 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss @@ -160,3 +160,90 @@ mat-expansion-panel { } } } + +/* Line numbers */ +// :host ::ng-deep is needed as this styling applies to the motion html that is injected using innerHTML, +// which doesn't have the [ngcontent]-attributes necessary for regular styles. +// An alternative approach (in case ::ng-deep gets removed) might be to change the view encapsulation. +:host ::ng-deep .motion-text { + ins { + color: green; + text-decoration: underline; + } + + del { + color: red; + text-decoration: line-through; + } + + li { + padding-bottom: 10px; + } + + ol, + ul { + margin-left: 15px; + margin-bottom: 0; + } + + .highlight { + background-color: #ff0; + } + &.line-numbers-outside { + padding-left: 40px; + position: relative; + + .os-line-number { + display: inline-block; + font-size: 0; + line-height: 0; + width: 22px; + height: 22px; + position: absolute; + left: 0; + padding-right: 55px; + + &:after { + content: attr(data-line-number); + position: absolute; + top: 10px; + vertical-align: top; + color: gray; + font-size: 12px; + font-weight: normal; + } + } + } + + &.line-numbers-inline { + .os-line-break { + display: none; + } + + .os-line-number { + display: inline-block; + + &:after { + display: inline-block; + content: attr(data-line-number); + vertical-align: top; + font-size: 10px; + font-weight: normal; + color: gray; + margin-top: -3px; + margin-left: 0; + margin-right: 0; + } + } + } + + &.line-numbers-none { + .os-line-break { + display: none; + } + + .os-line-number { + display: none; + } + } +} diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts b/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts index 613d978ec..3996309fe 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts @@ -1,18 +1,19 @@ import { Component, OnInit, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { FormGroup, FormBuilder, Validators } from '@angular/forms'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { MatExpansionPanel } from '@angular/material'; import { BaseComponent } from '../../../../base.component'; import { Category } from '../../../../shared/models/motions/category'; import { ViewportService } from '../../../../core/services/viewport.service'; import { MotionRepositoryService } from '../../services/motion-repository.service'; -import { ViewMotion } from '../../models/view-motion'; +import { LineNumbering, ViewMotion } from '../../models/view-motion'; import { User } from '../../../../shared/models/users/user'; import { DataStoreService } from '../../../../core/services/data-store.service'; import { TranslateService } from '@ngx-translate/core'; import { Motion } from '../../../../shared/models/motions/motion'; import { BehaviorSubject } from 'rxjs'; +import { SafeHtml } from '@angular/platform-browser'; /** * Component for the motion detail view @@ -215,8 +216,13 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { /** * get the formated motion text from the repository. */ - public getFormatedText(): string { - return this.repo.formatMotion(this.motion.id, this.motion.lnMode, this.motion.crMode); + public getFormattedText(): SafeHtml { + return this.repo.formatMotion( + this.motion.id, + this.motion.crMode, + this.motion.lineLength, + this.motion.highlightedLine + ); } /** @@ -264,10 +270,31 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { * Sets the motions line numbering mode * @param mode Needs to fot to the enum defined in ViewMotion */ - public setLineNumberingMode(mode: number): void { + public setLineNumberingMode(mode: LineNumbering): void { this.motion.lnMode = mode; } + /** + * Returns true if no line numbers are to be shown. + */ + public isLineNumberingNone(): boolean { + return this.motion.lnMode === LineNumbering.None; + } + + /** + * Returns true if the line numbers are to be shown within the text with no line breaks. + */ + public isLineNumberingInline(): boolean { + return this.motion.lnMode === LineNumbering.Inside; + } + + /** + * Returns true if the line numbers are to be shown to the left of the text. + */ + public isLineNumberingOutside(): boolean { + return this.motion.lnMode === LineNumbering.Outside; + } + /** * Sets the motions change reco mode * @param mode Needs to fot to the enum defined in ViewMotion diff --git a/client/src/app/site/motions/models/view-motion.ts b/client/src/app/site/motions/models/view-motion.ts index 79bdfff97..a765af708 100644 --- a/client/src/app/site/motions/models/view-motion.ts +++ b/client/src/app/site/motions/models/view-motion.ts @@ -6,7 +6,7 @@ import { WorkflowState } from '../../../shared/models/motions/workflow-state'; import { BaseModel } from '../../../shared/models/base/base-model'; import { BaseViewModel } from '../../base/base-view-model'; -enum LineNumbering { +export enum LineNumbering { None, Inside, Outside @@ -46,6 +46,18 @@ export class ViewMotion extends BaseViewModel { */ public crMode: ChangeReco; + /** + * Indicates the maximum line length as defined in the configuration. + * Needs to be accessed from outside + */ + public lineLength: number; + + /** + * Indicates the currently highlighted line, if any. + * Needs to be accessed from outside + */ + public highlightedLine: number; + public get motion(): Motion { return this._motion; } @@ -179,6 +191,9 @@ export class ViewMotion extends BaseViewModel { // TODO: Should be set using a a config variable this.lnMode = LineNumbering.None; this.crMode = ChangeReco.Original; + this.lineLength = 80; + + this.highlightedLine = null; } public getTitle(): string { diff --git a/client/src/app/site/motions/services/diff.service.spec.ts b/client/src/app/site/motions/services/diff.service.spec.ts new file mode 100644 index 000000000..7b5ea48be --- /dev/null +++ b/client/src/app/site/motions/services/diff.service.spec.ts @@ -0,0 +1,1168 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { DiffService, ModificationType } from './diff.service'; +import { LinenumberingService } from './linenumbering.service'; + +describe('DiffService', () => { + const brMarkup = (no: number): string => { + return ( + '
' + + ' ' + ); + }; + const noMarkup = (no: number): string => { + return ( + ' ' + ); + }; + + const baseHtml1 = + '

' + + noMarkup(1) + + 'Line 1 ' + + brMarkup(2) + + 'Line 2 ' + + brMarkup(3) + + 'Line 3
' + + noMarkup(4) + + 'Line 4 ' + + brMarkup(5) + + 'Line
5

' + + '
    ' + + '
  • ' + + noMarkup(6) + + 'Line 6 ' + + brMarkup(7) + + 'Line 7' + + '
  • ' + + '
    • ' + + '
    • ' + + noMarkup(8) + + 'Level 2 LI 8
    • ' + + '
    • ' + + noMarkup(9) + + 'Level 2 LI 9
    • ' + + '
  • ' + + '
' + + '

' + + 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(8) + + 'Auffi Gamsbart nimma de Sepp Ledahosn Ohrwaschl um Godds wujn Wiesn Deandlgwand Mongdratzal! Jo ' + + brMarkup(9) + + 'leck mi Mamalad i daad mechad?
  • \ +
  • ' + + noMarkup(10) + + 'Do nackata Wurscht i hob di narrisch gean, Diandldrahn Deandlgwand vui huift vui woaß?
  • \ +
  • ' + + noMarkup(11) + + 'Ned Mamalad auffi i bin a woschechta Bayer greaßt eich nachad, umananda gwiss nia need ' + + brMarkup(12) + + 'Weiznglasl.
  • \ +
  • ' + + noMarkup(13) + + 'Woibbadinga noch da Giasinga Heiwog Biazelt mechad mim Spuiratz, soi zwoa.
  • \ +
\ +

' + + 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 = + '
    ' + + '
  1. ' + + noMarkup(1) + + 'Line 1
  2. ' + + '
  3. ' + + noMarkup(2) + + 'Line 2
  4. ' + + '
    1. ' + + '
    2. ' + + noMarkup(3) + + 'Line 3.1
    3. ' + + '
    4. ' + + noMarkup(4) + + 'Line 3.2
    5. ' + + '
    6. ' + + noMarkup(5) + + 'Line 3.3
    7. ' + + '
  5. ' + + '
  6. ' + + noMarkup(6) + + ' Line 4
'; + + beforeAll(inject([DiffService], (service: DiffService) => { + baseHtmlDom1 = service.htmlToFragment(baseHtml1); + baseHtmlDom2 = service.htmlToFragment(baseHtml2); + service.insertInternalLineMarkers(baseHtmlDom1); + service.insertInternalLineMarkers(baseHtmlDom2); + })); + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [DiffService] + }); + }); + + describe('extraction of lines', () => { + it('locates line number nodes', inject([DiffService], (service: DiffService) => { + let lineNumberNode = service.getLineNumberNode(baseHtmlDom1, 4); + expect(lineNumberNode.parentNode.nodeName).toBe('STRONG'); + + lineNumberNode = service.getLineNumberNode(baseHtmlDom1, 9); + expect(lineNumberNode.parentNode.nodeName).toBe('UL'); + + lineNumberNode = service.getLineNumberNode(baseHtmlDom1, 15); + expect(lineNumberNode).toBe(null); + })); + + it('finds the common ancestor', inject([DiffService], (service: DiffService) => { + let fromLineNode, toLineNode, commonAncestor; + + fromLineNode = service.getLineNumberNode(baseHtmlDom1, 6); + toLineNode = service.getLineNumberNode(baseHtmlDom1, 7); + commonAncestor = service.getCommonAncestor(fromLineNode, toLineNode); + expect(commonAncestor.commonAncestor.nodeName).toBe('#document-fragment'); + + fromLineNode = service.getLineNumberNode(baseHtmlDom1, 6); + toLineNode = service.getLineNumberNode(baseHtmlDom1, 8); + commonAncestor = service.getCommonAncestor(fromLineNode, toLineNode); + expect(commonAncestor.commonAncestor.nodeName).toBe('#document-fragment'); + + fromLineNode = service.getLineNumberNode(baseHtmlDom1, 6); + toLineNode = service.getLineNumberNode(baseHtmlDom1, 10); + commonAncestor = service.getCommonAncestor(fromLineNode, toLineNode); + expect(commonAncestor.commonAncestor.nodeName).toBe('#document-fragment'); + })); + + it('renders DOMs correctly (1)', inject([DiffService], (service: DiffService) => { + const lineNo = service.getLineNumberNode(baseHtmlDom1, 7), + greatParent = lineNo.parentNode.parentNode; + let lineTrace = [lineNo.parentNode, lineNo]; + + const pre = service.serializePartialDomToChild(greatParent, lineTrace, true); + expect(pre).toBe('
  • Line 6 '); + + lineTrace = [lineNo.parentNode, lineNo]; + const post = service.serializePartialDomFromChild(greatParent, lineTrace, true); + expect(post).toBe( + 'Line 7' + + '
  • ' + + '
    • ' + + '
    • Level 2 LI 8
    • ' + + '
    • Level 2 LI 9
    • ' + + '
  • ' + + '
' + ); + })); + + it('renders DOMs correctly (2)', inject([DiffService], (service: DiffService) => { + const lineNo = service.getLineNumberNode(baseHtmlDom1, 9), + greatParent = lineNo.parentNode.parentNode, + lineTrace = [lineNo.parentNode, lineNo]; + + const pre = service.serializePartialDomToChild(greatParent, lineTrace, true); + expect(pre).toBe('
    • Level 2 LI 8
    • '); + })); + + it('extracts a single line', inject([DiffService], (service: DiffService) => { + const diff = service.extractRangeByLineNumbers(baseHtml1, 1, 2); + expect(diff.html).toBe('

      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

      • Level 2 LI 8
      • ' + ); + expect(diff.ancestor.nodeName).toBe('UL'); + expect(diff.outerContextStart).toBe('
          '); + expect(diff.outerContextEnd).toBe('
        '); + expect(diff.innerContextStart).toBe('
      • '); + expect(diff.innerContextEnd).toBe('
    • '); + expect(diff.previousHtmlEndSnippet).toBe('
    '); + expect(diff.followingHtmlStartSnippet).toBe( + '
      • ' + ); + })); + + it('extracts lines from double-nested UL/LI-structures (1)', inject([DiffService], (service: DiffService) => { + const html = + '

        ' + + noMarkup(1) + + 'Line 1

        ' + + '
        • ' + + noMarkup(2) + + 'Line 2' + + brMarkup(3) + + 'Line 3' + + brMarkup(4) + + 'Line 5

        '; + const diff = service.extractRangeByLineNumbers(html, 3, 4); + expect(diff.html).toBe('Line 3'); + expect(diff.ancestor.nodeName).toBe('P'); + expect(diff.outerContextStart).toBe( + '
        • ' + ); + expect(diff.outerContextEnd).toBe('

        '); + expect(diff.innerContextStart).toBe(''); + expect(diff.innerContextEnd).toBe(''); + expect(diff.previousHtmlEndSnippet).toBe('

      '); + expect(diff.followingHtmlStartSnippet).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) + + '

        '; + const diff = service.extractRangeByLineNumbers(html, 2, 3); + expect(diff.html).toBe( + '
        • Line 2' + ); + expect(diff.outerContextStart).toBe(''); + expect(diff.outerContextEnd).toBe(''); + expect(diff.innerContextStart).toBe(''); + expect(diff.innerContextEnd).toBe('

        '); + expect(diff.previousHtmlEndSnippet).toBe(''); + + // @TODO in followingHtmlStartSnippet, os-split-li is not set yet in this case. + // This is not entirely correct, but as this field is never actually used, it's not bothering (yet) + // This comment remains to document a potential pitfall in the future + // expect(diff.followingHtmlStartSnippet).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

          \n
            \t
          • A list item
          • \t
          • Yet another item
          '; + html = lineNumbering.insertLineNumbers(html, 80); + const diff = service.extractRangeByLineNumbers(html, 2, 3); + expect(diff.html).toBe('

          Another 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.

          • Auffi Gamsbart nimma de Sepp Ledahosn Ohrwaschl um Godds wujn Wiesn Deandlgwand Mongdratzal! Jo leck mi Mamalad i daad mechad?
          • Do nackata Wurscht i hob di narrisch gean, Diandldrahn Deandlgwand vui huift vui woaß?
          • ' + ); + expect(diff.ancestor.nodeName).toBe('#document-fragment'); + expect(diff.outerContextStart).toBe(''); + expect(diff.outerContextEnd).toBe(''); + expect(diff.innerContextStart).toBe('

            '); + expect(diff.innerContextEnd).toBe('

          '); + expect(diff.previousHtmlEndSnippet).toBe('

          '); + expect(diff.followingHtmlStartSnippet).toBe('
            '); + })); + + it('extracts the end of a section', inject([DiffService], (service: DiffService) => { + const diff = service.extractRangeByLineNumbers(baseHtml2, 29, null); + + expect(diff.html).toBe( + 'Diandldrahn nix Gwiass woass ma ned hod boarischer: Samma sammawiedaguad wos, i hoam Brodzeid. Jo mei Sepp Gaudi, is ma Wuascht do Hendl Xaver Prosd eana an a bravs. Sauwedda an Brezn, abfieseln.

            ' + ); + expect(diff.ancestor.nodeName).toBe('#document-fragment'); + expect(diff.outerContextStart).toBe(''); + expect(diff.outerContextEnd).toBe(''); + expect(diff.innerContextStart).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('
          • Line 3.3
          • Line 4
          • '); + expect(diff.ancestor.nodeName).toBe('#document-fragment'); + expect(diff.innerContextStart).toBe( + '
              1. ' + ); + expect(diff.innerContextEnd).toBe(''); + expect(diff.previousHtmlEndSnippet).toBe('
            '); + })); + + it('preserves the numbering of OLs (2)', inject([DiffService], (service: DiffService) => { + const diff = service.extractRangeByLineNumbers(baseHtml3, 3, 5); + + expect(diff.html).toBe( + '
            1. Line 3.1
            2. Line 3.2
            3. ' + ); + expect(diff.ancestor.nodeName).toBe('OL'); + expect(diff.outerContextStart).toBe('
                '); + expect(diff.outerContextEnd).toBe('
              '); + })); + + it('escapes text resembling HTML-Tags', inject([DiffService], (service: DiffService) => { + const inHtml = + '

              ' + + noMarkup(1) + + 'Looks like a <p> tag </p>

              ' + + noMarkup(2) + + 'Another line

              '; + const diff = service.extractRangeByLineNumbers(inHtml, 1, 2); + expect(diff.html).toBe('

              Looks like a <p> tag </p>

              '); + })); + + it('marks split list items', inject([DiffService], (service: DiffService) => { + const html = + '
              1. ' + noMarkup(1) + 'Line 1' + brMarkup(2) + 'Line 2' + brMarkup(3) + 'Line 3
              '; + let diff = service.extractRangeByLineNumbers(html, 2, 3); + expect(diff.outerContextStart.toLowerCase()).toBe( + '
              1. ' + ); + + diff = service.extractRangeByLineNumbers(html, 3, null); + expect(diff.innerContextStart.toLowerCase()).toBe( + '
                1. ' + ); + })); + + it('does not mark the second list item as being split', inject([DiffService], (service: DiffService) => { + const html = + '
                  1. ' + + noMarkup(1) + + 'Line 1
                  2. ' + + noMarkup(2) + + 'Line 2' + + brMarkup(3) + + 'Line 3
                  '; + const diff = service.extractRangeByLineNumbers(html, 2, 3); + expect(diff.outerContextStart.toLowerCase()).toBe('
                    '); + expect(diff.innerContextStart.toLowerCase()).toBe(''); + expect(diff.html.toLowerCase()).toBe('
                  1. line 2'); + })); + + it('sets the start in a more complex list', inject([DiffService], (service: DiffService) => { + const html = + '
                    1. ' + + noMarkup(1) + + 'Line 1
                    2. ' + + noMarkup(2) + + 'Line 2' + + brMarkup(3) + + 'Line 3
                    3. ' + + '
                    4. ' + + noMarkup(4) + + 'Line 4
                    '; + const diff = service.extractRangeByLineNumbers(html, 3, 4); + expect(diff.previousHtml.toLowerCase()).toContain('start="10"'); + expect(diff.outerContextStart.toLowerCase()).toContain('start="11"'); + expect(diff.followingHtmlStartSnippet.toLowerCase()).toContain('start="12"'); + })); + }); + + describe('merging two sections', () => { + it('merges OLs recursively, ignoring whitespaces between OL and LI', inject( + [LinenumberingService, DiffService], + (lineNumbering: LinenumberingService, service: DiffService) => { + const node1 = document.createElement('DIV'); + node1.innerHTML = '
                      1. Punkt 4.1
                    '; + const node2 = document.createElement('DIV'); + node2.innerHTML = '
                    1. \ +
                        \ +
                      1. Punkt 4.2
                      2. \ +
                      3. Punkt 4.3
                      4. \ +
                    '; + const out = service.replaceLinesMergeNodeArrays([node1.childNodes[0]], [node2.childNodes[0]]); + const outHtml = lineNumbering.nodesToHtml([out[0]]); + expect(outHtml).toBe( + '
                      1. Punkt 4.1
                      2. Punkt 4.2
                      3. Punkt 4.3
                    ' + ); + } + )); + }); + + describe('replacing lines in the original motion', () => { + it('replaces LIs by a P', inject([DiffService], (service: DiffService) => { + const merged = service.replaceLines(baseHtml1, '

                    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

                      • Level 2 LI 9

                    Line 10 Line 11

                    ' + ); + })); + + it('replaces LIs by another LI', inject([DiffService], (service: DiffService) => { + const merged = service.replaceLines(baseHtml1, '
                    • A new LI
                    ', 6, 9); + expect(merged).toBe( + '

                    Line 1 Line 2 Line 3
                    Line 4 Line
                    5

                    • A new LI
                      • Level 2 LI 9

                    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

                    • Line 6 Line 7
                      • Level 2 LI 8
                      • Level 2 LI 9

                    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, + '
                      ' + '\n' + '
                    • Line 6Inserted
                    • ' + '\n' + '
                    ', + 6, + 7 + ), + containsError = merged.indexOf('Line 6InsertedLine 7'), + containsCorrectVersion = merged.indexOf('Line 6Inserted Line 7'); + expect(containsError).toBe(-1); + expect(containsCorrectVersion > 0).toBe(true); + } + )); + + it('keeps ampersands escaped', inject([DiffService], (service: DiffService) => { + const pre = '

                    ' + 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 = '
                    • accusam et justo duo dolores et ea rebum.
                    ', + htmlAfter = + '
                      ' + '\n' + '
                    • accusam et justo duo dolores et ea rebum 123.
                    • ' + '\n' + '
                    '; + const calculatedType = service.detectReplacementType(htmlBefore, htmlAfter); + expect(calculatedType).toBe(ModificationType.TYPE_INSERTION); + } + )); + + it('detects a simple insertion, despite   tags', 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
                    • \n
                    ', + normalized = service.normalizeHtmlForDiff(unnormalized); + expect(normalized).toBe('
                    • Test
                    '); + })); + + it('normalizes html entities', inject([DiffService], (service: DiffService) => { + const unnormalized = 'German characters like ß or ö', + normalized = service.normalizeHtmlForDiff(unnormalized); + expect(normalized).toBe('German characters like ß or ö'); + })); + + it('sorts css classes', 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 red brown spotted fox jumleaped over the rolling log.' + ); + })); + + it('ignores changing cases in HTML tags', inject([DiffService], (service: DiffService) => { + const before = 'The brown spotted 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 brown spotted fox jumleaped over the rolling log.' + ); + })); + + it('does not insert spaces after a unchanged BR tag', inject([DiffService], (service: DiffService) => { + const before = '

                    ' + noMarkup(1) + 'Hendl Kirwa hod Maßkruag
                    ' + noMarkup(2) + 'gmahde Wiesn

                    ', + after = '

                    Hendl Kirwa hod Maßkruag
                    \ngmahde Wiesn

                    '; + const diff = service.diff(before, after); + + expect(diff).toBe(before); + })); + + it('does not mark the last line of a paragraph as change if a long new one is appended', inject( + [DiffService], + (service: DiffService) => { + const before = + '

                     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

                    ' + ); + } + )); + + it('merges multiple inserts and deletes', inject([DiffService], (service: DiffService) => { + const before = 'Some additional text to circumvent the threshold Test1 Test2 Test3 Test4 Test5 Test9', + after = 'Some additional text to circumvent the threshold Test1 Test6 Test7 Test8 Test9'; + const diff = service.diff(before, after); + + expect(diff).toBe( + 'Some additional text to circumvent the threshold Test1 Test2 Test3 Test4 Test5Test6 Test7 Test8 Test9' + ); + })); + + it('detects insertions and deletions in a word (1)', inject([DiffService], (service: DiffService) => { + const before = 'Test1 Test2 Test3 Test4 Test5 Test6 Test7', + after = 'Test1 Test Test3 Test4addon Test5 Test6 Test7'; + const diff = service.diff(before, after); + + expect(diff).toBe('Test1 Test2 Test3 Test4addon Test5 Test6 Test7'); + })); + + it('detects insertions and deletions in a word (2)', inject([DiffService], (service: DiffService) => { + const before = 'Test Test', + after = 'Test Testappend'; + const diff = service.diff(before, after); + + expect(diff).toBe('Test Testappend'); + })); + + it('recognizes commas as a word separator', inject([DiffService], (service: DiffService) => { + const before = + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat', + after = + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat'; + const diff = service.diff(before, after); + + expect(diff).toBe( + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr sed, diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat' + ); + })); + + it('cannot handle changing CSS-classes', inject([DiffService], (service: DiffService) => { + const before = "

                    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

                    '; + const diff = service.diff(before, after); + + expect(diff).toBe('

                    sem. Nulla consequat massa quis enim. TEST
                    TEST

                    '); + })); + + it('does not repeat the last word (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 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.

                    \n

                    Test

                    ', + after = + '

                    Einfügung 1 ...so frißt er Euch alle mit Haut und Haar und Augen und Därme und alles.

                    \n

                    Test

                    '; + 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.

                    \n

                    Test

                    ' + ); + } + )); + + 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

                    '; + + expect(diff).toBe(expected.toLowerCase()); + } + )); + + it('removed inline colors in inserted/deleted parts (1)', 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('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.

                    ' + ); + })); + + it('works with style-tags in spans', inject([DiffService], (service: DiffService) => { + const before = + '

                     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( + 'elitr. einsetzt. VERSCHLUCKT noch die sog., Einfügung durch Änderung der Gleichbleibend (Wird gelöscht)., einsetzt.' + ); + })); + + it('does not fall back to block level replacement when BRs are inserted/deleted', inject( + [DiffService], + (service: DiffService) => { + const before = + '

                    Lorem 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

                    ', + 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..
                    \n' + + 'Bavaria ipsum dolor sit amet o’ha wea nia ausgähd
                    \n' + + 'Autonomie erfährt ihre Grenzen

                    '; + const diff = service.diff(before, after); + expect(diff).toBe( + '

                    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ähd
                    kummt nia hoam i hob di narrisch geanAutonomie erfährt ihre Grenzen

                    ' + ); + } + )); + + it('does not a change in a very specific case', inject([DiffService], (service: DiffService) => { + // See diff._fixWrongChangeDetection + const inHtml = + '

                    Test 123
                    wir strikt ab. lehnen wir ' + + brMarkup(1486) + + 'ab.
                    ' + + noMarkup(1487) + + 'Gegenüber

                    ', + outHtml = '

                    Test 123
                    \n' + 'wir strikt ab. lehnen wir ab.
                    \n' + 'Gegenüber

                    '; + const diff = service.diff(inHtml, outHtml); + expect(diff).toBe( + '

                    Test 123
                    wir strikt ab. lehnen wir ' + + brMarkup(1486) + + 'ab.
                    ' + + noMarkup(1487) + + 'Gegenüber

                    ' + ); + })); + }); + + describe('ignoring line numbers', () => { + it('works despite line numbers, part 1', inject( + [DiffService, LinenumberingService], + (service: DiffService, lineNumbering: LinenumberingService) => { + let before = '

                    ...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

                    \n
                    Go on
                    '; + const after = + '

                    their grammar, their pronunciation and their most common words. Everyone realizes why a

                    \n' + + '

                    NEW PARAGRAPH 1.

                    \n' + + '

                    NEW PARAGRAPH 2.

                    \n' + + '
                    Go on
                    '; + + 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 1.

                    \n' + + '

                    NEW PARAGRAPH 2.

                    \n' + + '
                    ' + + noMarkup(4) + + 'Go on
                    ' + ); + } + )); + + it('detects broken HTML and lowercases class names', inject([DiffService], (service: DiffService) => { + const before = + '

                     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."

                    \n\n

                     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\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

                    '; + const diff = service.diff(before, after); + expect(diff).toBe( + '

                     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."

                    \n\n

                     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 = + '
                    • Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy ei rmodtem-Porinv idunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
                    '; + const after = + '
                    • Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy ei rmodtem-Porinv idunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
                    '; + + before = lineNumbering.insertLineNumbers(before, 90); + const diff = service.diff(before, after); + expect(diff).toBe( + '
                    • ' + + noMarkup(1) + + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy ei rmodtem-' + + brMarkup(2) + + 'Porinv idunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
                    ' + ); + } + )); + }); + + describe('addCSSClassToFirstTag function', () => { + it('works with plain tags', inject([DiffService], (service: DiffService) => { + const strIn = "
                    1. ", + inserted = service.addCSSClassToFirstTag(strIn, 'newClass'); + expect(inserted).toBe('
                      1. '); + })); + + it('works with tags already having classes', inject([DiffService], (service: DiffService) => { + const strIn = "
                        1. ", + inserted = service.addCSSClassToFirstTag(strIn, 'newClass'); + expect(inserted).toBe('
                          1. '); + })); + }); + + describe('removeDuplicateClassesInsertedByCkeditor', () => { + it('removes additional classes', inject([DiffService], (service: DiffService) => { + const strIn = + '
                              • ...here it goes on
                              • This has been added
                            ', + cleaned = service.removeDuplicateClassesInsertedByCkeditor(strIn); + expect(cleaned).toBe( + '
                              • ...here it goes on
                              • This has been added
                            ' + ); + })); + }); + + describe('detecting changed line number range', () => { + it('detects changed line numbers in the middle', inject([DiffService], (service: DiffService) => { + const 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 2
                          2. Line3
                          3. Line 4
                            ) + */ + 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.
                              in the case of the multiple LIs) + */ + outerContextStart: string; + /** + * An HTML string that closes all necessary tags from the ancestor element (e.g.
                            + */ + outerContextEnd: string; + /** + * A string that opens all necessary tags between the ancestor and the beginning of the selection (e.g.
                          4. ) + */ + innerContextStart: string; + /** + * A string that closes all tags after the end of the selection to the ancestor (e.g.
                          5. ) + */ + 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. + */ +interface LineRange { + /** + * 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; +} + +/** + * 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('

                            A line

                            Another line

                            • A list item
                            • Yet another item
                            ', 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 = '

                            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

                            • A list item
                            • Yet another item
                            ', lineLength); + * const merged = this.diffService.replaceLines(lineNumberedText, '

                            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 = 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 = 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 + * 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 = 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 = 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 = { + ' ': ' ', + '–': '-', + 'ä': 'ä', + 'ö': 'ö', + 'ü': 'ü', + 'Ä': 'Ä', + 'Ö': 'Ö', + 'Ü': 'Ü', + 'ß': 'ß', + '„': '„', + '“': '“', + '•': '•', + '§': '§', + 'é': 'é', + '€': '€' + }; + + html = html + .replace(/\s+<\/P>/gi, '

                            ') + .replace(/\s+<\/DIV>/gi, '
  • ') + .replace(/\s+<\/LI>/gi, ''); + html = html.replace(/\s+
  • /gi, '
  • ').replace(/<\/LI>\s+/gi, '
  • '); + 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(/(
    )\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 = 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 = 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 ... and ...-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 += '' + out.o[i] + ''; + } + } else { + if (out.n[0].text === undefined) { + for (let k = 0; k < out.o.length && out.o[k].text === undefined; k++) { + str += '' + out.o[k] + ''; + } + } + + let currOldRow = 0; + for (i = 0; i < out.n.length; i++) { + if (out.n[i].text === undefined) { + if (out.n[i] !== '') { + str += '' + out.n[i] + ''; + } + } else if (out.n[i].row < currOldRow) { + str += '' + out.n[i].text + ''; + } 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 += '' + out.o[n] + ''; + } else { + pre += '' + out.o[n].text + ''; + } + } + } else { + for (let j = out.n[i].row + 1; j < out.o.length && out.o[j].text === undefined; j++) { + pre += '' + out.o[j] + ''; + } + } + 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 ... or ...-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(/ 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 / 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. "Test

    "), let's better be cautious + // The "!!(found=...)"-construction is only used to make jshint happy :) + const findDel = /(.*?)<\/del>/gi, + findIns = /(.*?)<\/ins>/gi; + let found, inner; + while (!!(found = findDel.exec(html))) { + inner = found[1].replace(/]*>/gi, ''); + if (inner.match(/<[^>]*>/)) { + return true; + } + } + while (!!(found = findIns.exec(html))) { + inner = found[1].replace(/]*>/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 = 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(node.childNodes[i]); + } + } + } + + /** + * 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('') === -1 || diffStr.indexOf('') === -1) { + return diffStr; + } + + const findDelGroupFinder = /(?:.*?<\/del>)+/gi; + let found, + returnStr = diffStr; + + while (!!(found = findDelGroupFinder.exec(diffStr))) { + const del = found[0], + split = returnStr.split(del); + + const findInsGroupFinder = /^(?:.*?<\/ins>)+/gi, + foundIns = findInsGroupFinder.exec(split[1]); + if (foundIns) { + const ins = foundIns[0]; + + let delShortened = del + .replace( + /((
    <\/del>)?(]+os-line-number[^>]+?>)(\s|<\/?del>)*<\/span>)<\/del>/gi, + '' + ) + .replace(/<\/del>/g, ''); + const insConv = ins + .replace(//g, '') + .replace(/<\/ins>/g, '') + .replace(/<\/del>/g, ''); + if (delShortened.indexOf(insConv) !== -1) { + delShortened = delShortened.replace(insConv, ''); + if (delShortened === '') { + returnStr = returnStr.replace(del + ins, del.replace(//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, '>'); + } + if ( + stripLineNumbers && + (this.lineNumberingService.isOsLineNumberNode(node) || this.lineNumberingService.isOsLineBreakNode(node)) + ) { + return ''; + } + if (node.nodeName === 'OS-LINEBREAK') { + return ''; + } + if (node.nodeName === 'BR') { + const element = node; + let 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, '>'); + } 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 += ''; + } + + return html; + } + + /** + * When a
  • 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 = 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 = 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 = 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 = 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 += ''; + } + 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

    Line 1
    Line 2
    Line 3

    : + * - extracting line 1 to 2 results in

    Line 1

    + * - extracting line 2 to 3 results in

    Line 2

    + * - extracting line 3 to null/4 results in

    Line 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 = 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 = 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 = 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 = 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 = '' + 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 = 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 = 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; + } + + /** + * 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 { + let text = + diff.outerContextStart + diff.innerContextStart + diff.html + diff.innerContextEnd + diff.outerContextEnd; + 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
  • and + lastChild = element.childNodes[element.childNodes.length - 2]; + } + if (lastChild.nodeType === TEXT_NODE) { + if (lastChild.nodeValue === '' || lastChild.nodeValue.substr(-1) !== ' ') { + lastChild.nodeValue += ' '; + } + } else { + this.insertDanglingSpace(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
      . + * + * @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 = 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 -Tags (including content) + * Removes the .insert-classes and the wrapping -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 + '' + data.previousHtmlEndSnippet, + previousFragment = this.htmlToFragment(previousHtml), + followingHtml = data.followingHtmlStartSnippet + '' + 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 + * /-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 + ); + newTextWithBreaks = this.lineNumberingService.insertLineNumbersNode( + newText, + lineLength, + null, + firstLineNumber + ); + } else { + oldTextWithBreaks = document.createElement('div'); + oldTextWithBreaks.innerHTML = oldText; + newTextWithBreaks = document.createElement('div'); + newTextWithBreaks.innerHTML = newText; + } + + 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.

      + // - 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 = ''; + + // 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*]+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 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*)(]*)?>[\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, '

      ') + + whiteAfter + ); + } + ); + + // Fixes HTML produced by the diff like this: + // from:

      Inserted Text

      \n

      More inserted text

      + // into: Inserted Text

      \n

      More inserted text

      + diffUnnormalized = diffUnnormalized.replace( + /<\/p><\/del>([\s\S]*?)<\/p><\/ins>/gim, + '$1

      ' + ); + diffUnnormalized = diffUnnormalized.replace( + /[\s\S]*?<\/ins>/gim, + (match: string): string => { + return match.replace(/(<\/p>\s*

      )/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( + /([a-z0-9,_-]* ?)<\/del>([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 += '' + remainderOld + ''; + } + if (remainderNew !== '') { + out += '' + remainderNew + ''; + } + out += commonEnd; + + return out; + } + ); + + // Replace spaces in line numbers by   + diffUnnormalized = diffUnnormalized.replace( + /]+os-line-number[^>]+?>\s*<\/span>/gi, + (found: string): string => { + return found.toLowerCase().replace(/> <\/span/gi, '> .*?(\n.*?)*<\/ins>/gi, + (found: string): string => { + found = found.replace( + /<(div|p|li)[^>]*>/gi, + (match: string): string => { + return match + ''; + } + ); + found = found.replace( + /<\/(div|p|li)[^>]*>/gi, + (match: string): string => { + return '' + match; + } + ); + return found; + } + ); + diffUnnormalized = diffUnnormalized.replace( + /.*?(\n.*?)*<\/del>/gi, + (found: string): string => { + found = found.replace( + /<(div|p|li)[^>]*>/gi, + (match: string): string => { + return match + ''; + } + ); + found = found.replace( + /<\/(div|p|li)[^>]*>/gi, + (match: string): string => { + return '' + match; + } + ); + return found; + } + ); + 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 ( + '
      ' + + ' ' + ); + }, + noMarkup = (no: number): string => { + return ( + ' ' + ); + }, + longstr = (length: number): string => { + let outstr = ''; + for (let i = 0; i < length; i++) { + outstr += String.fromCharCode(65 + (i % 26)); + } + return outstr; + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [LinenumberingService] + }); + }); + + it('should be created', inject([LinenumberingService], (service: LinenumberingService) => { + expect(service).toBeTruthy(); + })); + + describe('paragraph splitting', () => { + it('breaks simple DIVs', inject([LinenumberingService], (service: LinenumberingService) => { + const htmlIn = '
      Test 1
      ' + '\n' + '

      Test 2 3

      '; + const out = service.splitToParagraphs(htmlIn); + expect(out.length).toBe(2); + expect(out[0]).toBe('
      Test 1
      '); + expect(out[1]).toBe('

      Test 2 3

      '); + })); + it('ignores root-level text-nodes', inject([LinenumberingService], (service: LinenumberingService) => { + const htmlIn = '
      Test 3
      ' + '\n New line'; + const out = service.splitToParagraphs(htmlIn); + expect(out.length).toBe(1); + expect(out[0]).toBe('
      Test 3
      '); + })); + it('splits UL-Lists', inject([LinenumberingService], (service: LinenumberingService) => { + const htmlIn = + "
        \n
      • Node 1
      • \n
      • Node 2
      • Node 3

      "; + const out = service.splitToParagraphs(htmlIn); + expect(out.length).toBe(3); + expect(out[0]).toBe('
      • Node 1
      '); + expect(out[1]).toBe('
      • Node 2
      '); + expect(out[2]).toBe('
      • Node 3

      '); + })); + it('splits OL-Lists', inject([LinenumberingService], (service: LinenumberingService) => { + const htmlIn = + "
        \n
      1. Node 1
      2. \n
      3. Node 2
      4. Node 3

      "; + const out = service.splitToParagraphs(htmlIn); + expect(out.length).toBe(3); + expect(out[0]).toBe('
      1. Node 1
      '); + expect(out[1]).toBe('
      1. Node 2
      '); + expect(out[2]).toBe('
      1. Node 3

      '); + })); + }); + + describe('getting line number range', () => { + it('extracts the line number range, example 1', inject( + [LinenumberingService], + (service: LinenumberingService) => { + const html = + '

      ' + + noMarkup(2) + + 'et accusam et justo duo dolores et ea rebum Inserted Text. Stet clita kasd ' + + brMarkup(3) + + 'gubergren,

      '; + const range = service.getLineNumberRange(html); + expect(range).toEqual({ from: 2, to: 4 }); + } + )); + }); + + describe('line numbering: test nodes', () => { + it('breaks very short lines', inject([LinenumberingService], (service: LinenumberingService) => { + const textNode = document.createTextNode('0123'); + service.setInlineOffsetLineNumberForTests(0, 1); + const out = service.textNodeToLines(textNode, 5); + const outHtml = service.nodesToHtml(out); + expect(outHtml).toBe('0123'); + expect(service.getInlineOffsetForTests()).toBe(4); + })); + + it('breaks simple lines', inject([LinenumberingService], (service: LinenumberingService) => { + const textNode = document.createTextNode('012345678901234567'); + service.setInlineOffsetLineNumberForTests(0, 1); + const out = service.textNodeToLines(textNode, 5); + const outHtml = service.nodesToHtml(out); + expect(outHtml).toBe('01234' + brMarkup(1) + '56789' + brMarkup(2) + '01234' + brMarkup(3) + '567'); + expect(service.getInlineOffsetForTests()).toBe(3); + })); + + it('breaks simple lines with offset', inject([LinenumberingService], (service: LinenumberingService) => { + const textNode = document.createTextNode('012345678901234567'); + service.setInlineOffsetLineNumberForTests(2, 1); + const out = service.textNodeToLines(textNode, 5); + const outHtml = service.nodesToHtml(out); + expect(outHtml).toBe('012' + brMarkup(1) + '34567' + brMarkup(2) + '89012' + brMarkup(3) + '34567'); + expect(service.getInlineOffsetForTests()).toBe(5); + })); + + it('breaks simple lines with offset equaling to length', inject( + [LinenumberingService], + (service: LinenumberingService) => { + const textNode = document.createTextNode('012345678901234567'); + service.setInlineOffsetLineNumberForTests(5, 1); + const out = service.textNodeToLines(textNode, 5); + const outHtml = service.nodesToHtml(out); + expect(outHtml).toBe( + brMarkup(1) + '01234' + brMarkup(2) + '56789' + brMarkup(3) + '01234' + brMarkup(4) + '567' + ); + expect(service.getInlineOffsetForTests()).toBe(3); + } + )); + + it('breaks simple lines with spaces (1)', inject([LinenumberingService], (service: LinenumberingService) => { + const textNode = document.createTextNode('0123 45 67 89012 34 567'); + service.setInlineOffsetLineNumberForTests(0, 1); + const out = service.textNodeToLines(textNode, 5); + const outHtml = service.nodesToHtml(out); + expect(outHtml).toBe( + '0123 ' + brMarkup(1) + '45 67 ' + brMarkup(2) + '89012 ' + brMarkup(3) + '34 ' + brMarkup(4) + '567' + ); + expect(service.getInlineOffsetForTests()).toBe(3); + })); + + it('breaks simple lines with spaces (2)', inject([LinenumberingService], (service: LinenumberingService) => { + const textNode = document.createTextNode('0123 45 67 89012tes 344 '); + service.setInlineOffsetLineNumberForTests(0, 1); + const out = service.textNodeToLines(textNode, 5); + const outHtml = service.nodesToHtml(out); + expect(outHtml).toBe( + '0123 ' + brMarkup(1) + '45 67 ' + brMarkup(2) + '89012' + brMarkup(3) + 'tes ' + brMarkup(4) + '344 ' + ); + expect(service.getInlineOffsetForTests()).toBe(4); + })); + + it('breaks simple lines with spaces (3)', inject([LinenumberingService], (service: LinenumberingService) => { + const textNode = document.createTextNode("I'm a Demo-Text"); + service.setInlineOffsetLineNumberForTests(0, 1); + const out = service.textNodeToLines(textNode, 5); + const outHtml = service.nodesToHtml(out); + expect(outHtml).toBe("I'm a " + brMarkup(1) + 'Demo-' + brMarkup(2) + 'Text'); + expect(service.getInlineOffsetForTests()).toBe(4); + })); + + it('breaks simple lines with spaces (4)', inject([LinenumberingService], (service: LinenumberingService) => { + const textNode = document.createTextNode("I'm a LongDemo-Text"); + service.setInlineOffsetLineNumberForTests(0, 1); + const out = service.textNodeToLines(textNode, 5); + const outHtml = service.nodesToHtml(out); + expect(outHtml).toBe("I'm a " + brMarkup(1) + 'LongD' + brMarkup(2) + 'emo-' + brMarkup(3) + 'Text'); + expect(service.getInlineOffsetForTests()).toBe(4); + })); + }); + + describe('line numbering: inline nodes', () => { + it('leaves a simple SPAN untouched', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = 'Test'; + const outHtml = service.insertLineNumbers(inHtml, 5); + expect(outHtml).toBe(noMarkup(1) + 'Test'); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + })); + + it('breaks lines in a simple SPAN', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = 'Lorem ipsum dolorsit amet'; + const outHtml = service.insertLineNumbers(inHtml, 5); + expect(outHtml).toBe( + noMarkup(1) + + 'Lorem ' + + brMarkup(2) + + 'ipsum ' + + brMarkup(3) + + 'dolor' + + brMarkup(4) + + 'sit ' + + brMarkup(5) + + 'amet' + ); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + })); + + it('breaks lines in nested inline elements', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = 'Lorem ipsum dolorsit amet'; + const outHtml = service.insertLineNumbers(inHtml, 5); + expect(outHtml).toBe( + noMarkup(1) + + 'Lorem ' + + brMarkup(2) + + 'ipsum ' + + brMarkup(3) + + 'dolor' + + brMarkup(4) + + 'sit ' + + brMarkup(5) + + 'amet' + ); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + })); + + it('counts within DEL nodes', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = '1234 1234 1234 1234'; + const outHtml = service.insertLineNumbers(inHtml, 10); + expect(outHtml).toBe(noMarkup(1) + '1234 1234 ' + brMarkup(2) + '1234 1234'); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + })); + + it('counts after DEL/INS-nodes', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = + '

      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.

      '; + const outHtml = service.insertLineNumbers(inHtml, 95); + expect(outHtml).toBe( + '

      ' + + 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.

      ' + ); + })); + + it('handles STRIKE-tags', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = + '

      et accusam et justo duo dolores et ea rebum Inserted Text. Stet clita kasd gubergren,

      '; + const outHtml = service.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe( + '

      ' + + noMarkup(1) + + 'et accusam et justo duo dolores et ea rebum Inserted Text. Stet clita kasd ' + + brMarkup(2) + + 'gubergren,

      ' + ); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + })); + + it('treats ascii newline characters like spaces', inject( + [LinenumberingService], + (service: LinenumberingService) => { + const inHtml = '

      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 = '
      Test
      '; + const outHtml = service.insertLineNumbers(inHtml, 5); + expect(outHtml).toBe('
      ' + noMarkup(1) + 'Test
      '); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + })); + + it('breaks a DIV containing only inline elements', inject( + [LinenumberingService], + (service: LinenumberingService) => { + const inHtml = '
      Test Test12345678 Test
      '; + const outHtml = service.insertLineNumbers(inHtml, 5); + expect(outHtml).toBe( + '
      ' + + noMarkup(1) + + 'Test ' + + brMarkup(2) + + 'Test1' + + brMarkup(3) + + '23456' + + brMarkup(4) + + '78 ' + + brMarkup(5) + + 'Test
      ' + ); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + } + )); + + it('handles a DIV within a DIV correctly', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = '
      Te
      Te Test
      Test
      '; + const outHtml = service.insertLineNumbers(inHtml, 5); + expect(outHtml).toBe( + '
      ' + + noMarkup(1) + + 'Te
      ' + + noMarkup(2) + + 'Te ' + + brMarkup(3) + + 'Test
      ' + + noMarkup(4) + + 'Test
      ' + ); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + })); + + it('ignores white spaces between block element tags', inject( + [LinenumberingService], + (service: LinenumberingService) => { + const inHtml = '
        \n
      • Test
      • \n
      '; + const outHtml = service.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe('
        \n
      • ' + noMarkup(1) + 'Test
      • \n
      '); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + } + )); + }); + + describe('indentation for block elements', () => { + it('indents LI-elements', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = '
      ' + longstr(100) + '
      • ' + longstr(100) + '
      ' + longstr(100) + '
      '; + const expected = + '
      ' + + noMarkup(1) + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZAB' + + brMarkup(2) + + 'CDEFGHIJKLMNOPQRSTUV' + + '
      • ' + + noMarkup(3) + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVW' + + brMarkup(4) + + 'XYZABCDEFGHIJKLMNOPQRSTUV' + + '
      ' + + noMarkup(5) + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZAB' + + brMarkup(6) + + 'CDEFGHIJKLMNOPQRSTUV
      '; + const outHtml = service.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe(expected); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + })); + + it('indents BLOCKQUOTE-elements', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = + '
      ' + longstr(100) + '
      ' + longstr(100) + '
      ' + longstr(100) + '
      '; + const expected = + '
      ' + + noMarkup(1) + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZAB' + + brMarkup(2) + + 'CDEFGHIJKLMNOPQRSTUV' + + '
      ' + + noMarkup(3) + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGH' + + brMarkup(4) + + 'IJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUV' + + '
      ' + + noMarkup(5) + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZAB' + + brMarkup(6) + + 'CDEFGHIJKLMNOPQRSTUV
      '; + const outHtml = service.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe(expected); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + })); + + it('shortens the line for H1-elements by 2/3', inject( + [LinenumberingService], + (service: LinenumberingService) => { + const inHtml = '

      ' + longstr(80) + '

      '; + const expected = + '

      ' + + noMarkup(1) + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZA' + + brMarkup(2) + + 'BCDEFGHIJKLMNOPQRSTUVWXYZAB

      '; + const outHtml = service.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe(expected); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + } + )); + + it('shortens the line for H2-elements by 0.75', inject( + [LinenumberingService], + (service: LinenumberingService) => { + const inHtml = '

      ' + longstr(80) + '

      '; + const expected = + '

      ' + + noMarkup(1) + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGH' + + brMarkup(2) + + 'IJKLMNOPQRSTUVWXYZAB

      '; + const outHtml = service.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe(expected); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + } + )); + + it('indents Ps with 30px-padding by 6 characters', inject( + [LinenumberingService], + (service: LinenumberingService) => { + const inHtml = '
      ' + longstr(80) + '
      '; + const expected = + '
      ' + + noMarkup(1) + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUV' + + brMarkup(2) + + 'WXYZAB
      '; + const outHtml = service.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe(expected); + 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 (1)', 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('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 Noch

      Test 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

      '; + const outHtml = service.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe('

      ' + noMarkup(1) + 'Test 123
      ' + noMarkup(2) + 'Test 456

      '); + })); + + it('does not force-break words right after an INS', inject( + [LinenumberingService], + (service: LinenumberingService) => { + const inHtml = '

      ' + noMarkup(1) + '012345 78 01 345678901234567890123456789

      '; + const outHtml = service.insertLineBreaksWithoutNumbers(inHtml, 20, true); + expect(outHtml).toBe( + '

      ' + + noMarkup(1) + + '012345 78 01
      34567
      890123456789012
      3456789

      ' + ); + } + )); + }); + + describe('line breaking without adding line numbers', () => { + const plainBr = '
      '; + + it('breaks a DIV containing only inline elements', inject( + [LinenumberingService], + (service: LinenumberingService) => { + const inHtml = '
      Test Test12345678 Test
      '; + const outHtml = service.insertLineBreaksWithoutNumbers(inHtml, 5); + expect(outHtml).toBe( + '
      Test ' + + plainBr + + 'Test1' + + plainBr + + '23456' + + plainBr + + '78 ' + + plainBr + + 'Test
      ' + ); + expect(service.stripLineNumbers(outHtml)).toBe(inHtml); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + } + )); + + it('indents BLOCKQUOTE-elements', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = + '
      ' + longstr(100) + '
      ' + longstr(100) + '
      ' + longstr(100) + '
      '; + const expected = + '
      ' + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZAB' + + plainBr + + 'CDEFGHIJKLMNOPQRSTUV' + + '
      ' + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGH' + + plainBr + + 'IJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUV' + + '
      ' + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZAB' + + plainBr + + 'CDEFGHIJKLMNOPQRSTUV
      '; + const outHtml = service.insertLineBreaksWithoutNumbers(inHtml, 80); + expect(outHtml).toBe(expected); + expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml); + })); + + it('DOES count within INS nodes', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = '1234 1234 1234 1234'; + const outHtml = service.insertLineBreaksWithoutNumbers(inHtml, 10, true); + expect(outHtml).toBe('1234 1234 ' + plainBr + '1234 1234'); + 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.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\n

      Line 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 = '
        \n\n
      • Point 1
      • \n\n
      '; + const outHtml = service.insertLineNumbers(inHtml, 80); + expect(outHtml).toBe('
        \n\n
      • ' + noMarkup(1) + 'Point 1
      • \n\n
      '); + })); + }); + + describe('line highlighting', () => { + it('highlights a simple line', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = service.insertLineNumbers('Lorem ipsum dolorsit amet', 5); + const highlighted = service.highlightLine(inHtml, 2); + expect(highlighted).toBe( + noMarkup(1) + + 'Lorem ' + + brMarkup(2) + + 'ipsum ' + + brMarkup(3) + + 'dolor' + + brMarkup(4) + + 'sit ' + + brMarkup(5) + + 'amet' + ); + })); + + it('highlights a simple line with formattings', inject( + [LinenumberingService], + (service: LinenumberingService) => { + const inHtml = service.insertLineNumbers( + 'Lorem ipsum dolorsit amet Lorem ipsum dolorsit amet Lorem ipsum dolorsit amet', + 20 + ); + expect(inHtml).toBe( + noMarkup(1) + + 'Lorem ipsum dolorsit ' + + brMarkup(2) + + 'amet Lorem ipsum ' + + brMarkup(3) + + 'dolorsit amet Lorem ' + + brMarkup(4) + + 'ipsum dolorsit amet' + ); + + const highlighted = service.highlightLine(inHtml, 2); + expect(highlighted).toBe( + noMarkup(1) + + 'Lorem ipsum dolorsit ' + + brMarkup(2) + + 'amet Lorem ipsum ' + + brMarkup(3) + + 'dolorsit amet Lorem ' + + brMarkup(4) + + 'ipsum dolorsit amet' + ); + } + )); + + it('highlights the last line', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = service.insertLineNumbers('Lorem ipsum dolorsit amet', 5); + const highlighted = service.highlightLine(inHtml, 5); + expect(highlighted).toBe( + noMarkup(1) + + 'Lorem ' + + brMarkup(2) + + 'ipsum ' + + brMarkup(3) + + 'dolor' + + brMarkup(4) + + 'sit ' + + brMarkup(5) + + 'amet' + ); + })); + + it('highlights the first line', inject([LinenumberingService], (service: LinenumberingService) => { + const inHtml = service.insertLineNumbers('Lorem ipsum dolorsit amet', 5); + const highlighted = service.highlightLine(inHtml, 1); + expect(highlighted).toBe( + noMarkup(1) + + 'Lorem ' + + brMarkup(2) + + 'ipsum ' + + brMarkup(3) + + 'dolor' + + brMarkup(4) + + 'sit ' + + brMarkup(5) + + 'amet' + ); + })); + + it('does not change the string if the line number is not found', inject( + [LinenumberingService], + (service: LinenumberingService) => { + const inHtml = service.insertLineNumbers('Lorem ipsum dolorsit amet', 5); + const highlighted = service.highlightLine(inHtml, 8); + expect(highlighted).toBe( + noMarkup(1) + + 'Lorem ' + + brMarkup(2) + + 'ipsum ' + + brMarkup(3) + + 'dolor' + + brMarkup(4) + + 'sit ' + + brMarkup(5) + + 'amet' + ); + } + )); + }); + + describe('document structure parsing', () => { + it('detects the line numbers of headings', inject([LinenumberingService], (service: LinenumberingService) => { + let inHtml = + '

      Line 1

      ' + + '

      Heading 1

      Line 2

      Heading 1.1

      Line 3

      Heading 1.2

      Line 4

      ' + + '

      Heading 2

      Heading 2.1

      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

      • Paragraph 2
      • Paragraph 3
      '; + * const paragraphs = this.lineNumbering.splitToParagraphs(htmlIn); + * ``` + * + * Retrieving all section headings from an HTML string: + * + * ```ts + * const html = '

      Heading 1

      Some introductional paragraph

      Subheading 1.1

      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 = node; + if (element.nodeName === 'BR' && element.hasAttribute('class')) { + const classes = element.getAttribute('class').split(' '); + if (classes.indexOf('os-line-break') > -1) { + isLineBreak = true; + } + } + } + return isLineBreak; + } + + /** + * Returns true, if the given node is a OpenSlides-specific line numbering node. + * + * @param {Node} node + * @returns {boolean} + */ + public isOsLineNumberNode(node: Node): boolean { + let isLineNumber = false; + if (node && node.nodeType === ELEMENT_NODE) { + const element = node; + if (node.nodeName === 'SPAN' && element.hasAttribute('class')) { + const classes = element.getAttribute('class').split(' '); + if (classes.indexOf('os-line-number') > -1) { + isLineNumber = true; + } + } + } + return isLineNumber; + } + + /** + * Searches for the line breaking node within the given Document specified by the given lineNumber. + * + * @param {DocumentFragment} fragment + * @param {number} lineNumber + * @returns {Element} + */ + private getLineNumberNode(fragment: DocumentFragment, lineNumber: number): Element { + return fragment.querySelector('.os-line-number.line-number-' + lineNumber); + } + + /** + * This converts the given HTML string into a DOM tree contained by a DocumentFragment, which is reqturned. + * + * @param {string} html + * @return {DocumentFragment} + */ + private htmlToFragment(html: string): DocumentFragment { + const fragment: DocumentFragment = 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; + } + + /** + * Converts a HTML Document Fragment into HTML string, using the browser's internal mechanisms. + * HINT: special characters might get escaped / html-encoded in the process of this. + * + * @param {DocumentFragment} fragment + * @returns string + */ + private fragmentToHtml(fragment: DocumentFragment): string { + const div: Element = document.createElement('DIV'); + while (fragment.firstChild) { + const child = fragment.firstChild; + fragment.removeChild(child); + div.appendChild(child); + } + return div.innerHTML; + } + + /** + * Creates a OpenSlides-specific line break Element + * + * @returns {Element} + */ + private createLineBreak(): Element { + const br = document.createElement('br'); + br.setAttribute('class', 'os-line-break'); + return br; + } + + /** + * Moves line breaking and line numbering markup before inline elements + * + * @param {Element} innerNode + * @param {Element} outerNode + * @private + */ + private moveLeadingLineBreaksToOuterNode(innerNode: Element, outerNode: Element): void { + if (this.isInlineElement(innerNode)) { + const firstChild = innerNode.firstChild; + if (this.isOsLineBreakNode(firstChild)) { + const br = innerNode.firstChild; + innerNode.removeChild(br); + outerNode.appendChild(br); + } + if (this.isOsLineNumberNode(firstChild)) { + const span = innerNode.firstChild; + innerNode.removeChild(span); + outerNode.appendChild(span); + } + } + } + + /** + * As some elements add extra paddings/margins, the maximum line length of the contained text is not as big + * as for text outside of this element. Based on the outside line length, this returns the new (reduced) maximum + * line length for the given block element. + * HINT: this makes quite some assumtions about the styling of the CSS / PDFs. But there is no way around this, + * as line numbers have to be fixed and not depend on styling. + * + * @param {Element} node + * @param {number} oldLength + * @returns {number} + */ + public calcBlockNodeLength(node: Element, oldLength: number): number { + let newLength = oldLength; + switch (node.nodeName) { + case 'LI': + newLength -= 5; + break; + case 'BLOCKQUOTE': + newLength -= 20; + break; + case 'DIV': + case 'P': + const styles = node.getAttribute('style'); + let padding = 0; + if (styles) { + const leftpad = styles.split('padding-left:'); + if (leftpad.length > 1) { + padding += parseInt(leftpad[1], 10); + } + const rightpad = styles.split('padding-right:'); + if (rightpad.length > 1) { + padding += parseInt(rightpad[1], 10); + } + newLength -= padding / 5; + } + break; + case 'H1': + newLength *= 0.66; + break; + case 'H2': + newLength *= 0.75; + break; + case 'H3': + newLength *= 0.85; + break; + } + return Math.ceil(newLength); + } + + /** + * This converts an array of HTML elements into a string + * + * @param {Element[]} nodes + * @returns {string} + */ + public nodesToHtml(nodes: Element[]): string { + const root = document.createElement('div'); + nodes.forEach(node => { + root.appendChild(node); + }); + return root.innerHTML; + } + + /** + * Given a HTML string augmented with line number nodes, this function detects the line number range of this text. + * This method assumes that the line number node indicating the beginning of the next line is not included anymore. + * + * @param {string} html + * @returns {LineNumberRange} + */ + public getLineNumberRange(html: string): LineNumberRange { + const fragment = this.htmlToFragment(html); + const range = { + from: null, + to: null + }; + const lineNumbers = fragment.querySelectorAll('.os-line-number'); + for (let i = 0; i < lineNumbers.length; i++) { + const node = lineNumbers.item(i); + const number = parseInt(node.getAttribute('data-line-number'), 10); + if (range.from === null || number < range.from) { + range.from = number; + } + if (range.to === null || number + 1 > range.to) { + range.to = number + 1; + } + } + return range; + } + + /** + * Seaches for all H1-H6 elements within the given text and returns information about them. + * + * @param {string} html + * @returns {SectionHeading[]} + */ + public getHeadingsWithLineNumbers(html: string): SectionHeading[] { + const fragment = this.htmlToFragment(html); + const headings = []; + const headingNodes = fragment.querySelectorAll('h1, h2, h3, h4, h5, h6'); + for (let i = 0; i < headingNodes.length; i++) { + const heading = headingNodes.item(i); + const linenumbers = heading.querySelectorAll('.os-line-number'); + if (linenumbers.length > 0) { + const number = parseInt(linenumbers.item(0).getAttribute('data-line-number'), 10); + headings.push({ + lineNumber: number, + level: parseInt(heading.nodeName.substr(1), 10), + text: heading.innerText.replace(/^\s/, '').replace(/\s$/, '') + }); + } + } + return headings.sort( + (heading1: SectionHeading, heading2: SectionHeading): number => { + if (heading1.lineNumber < heading2.lineNumber) { + return 0; + } else if (heading1.lineNumber > heading2.lineNumber) { + return 1; + } else { + return 0; + } + } + ); + } + + /** + * Given a big element containing a whole document, this method splits it into editable paragraphs. + * Each first-level LI element gets its own paragraph, as well as all root-level block elements (except for lists). + * + * @param {Element|DocumentFragment} node + * @returns {Element[]} + * @private + */ + public splitNodeToParagraphs(node: Element | DocumentFragment): Element[] { + const elements = []; + for (let i = 0; i < node.childNodes.length; i++) { + const childNode = node.childNodes.item(i); + + if (childNode.nodeType === TEXT_NODE) { + continue; + } + if (childNode.nodeName === 'UL' || childNode.nodeName === 'OL') { + const childElement = childNode; + let start = 1; + if (childElement.getAttribute('start') !== null) { + start = parseInt(childElement.getAttribute('start'), 10); + } + for (let j = 0; j < childElement.childNodes.length; j++) { + if (childElement.childNodes.item(j).nodeType === TEXT_NODE) { + continue; + } + const newParent = childElement.cloneNode(false); + if (childElement.nodeName === 'OL') { + newParent.setAttribute('start', start.toString()); + } + newParent.appendChild(childElement.childNodes.item(j).cloneNode(true)); + elements.push(newParent); + start++; + } + } else { + elements.push(childNode); + } + } + return elements; + } + + /** + * Splitting the text into paragraphs: + * - Each root-level-element is considered as a paragraph. + * Inline-elements at root-level are not expected and treated as block elements. + * Text-nodes at root-level are not expected and ignored. Every text needs to be wrapped e.g. by

      or

      . + * - If a UL or OL is encountered, paragraphs are defined by the child-LI-elements. + * List items of nested lists are not considered as a paragraph of their own. + * + * @param {string} html + * @return {string[]} + */ + public splitToParagraphs(html: string): string[] { + const fragment = this.htmlToFragment(html); + return this.splitNodeToParagraphs(fragment).map( + (node: Element): string => { + return node.outerHTML; + } + ); + } + + /** + * Test function, only called by the tests, never from the actual app + * + * @param {number} offset + * @param {number} lineNumber + */ + public setInlineOffsetLineNumberForTests(offset: number, lineNumber: number): void { + this.currentInlineOffset = offset; + this.currentLineNumber = lineNumber; + } + + /** + * Returns debug information for the test cases + * + * @returns {number} + */ + public getInlineOffsetForTests(): number { + return this.currentInlineOffset; + } + + /** + * When calculating line numbers on a diff-marked-up text, some elements should not be considered: + * inserted text and line numbers. This identifies such elements. + * + * @param {Element} element + * @returns {boolean} + */ + private isIgnoredByLineNumbering(element: Element): boolean { + if (element.nodeName === 'INS') { + return this.ignoreInsertedText; + } else if (this.isOsLineNumberNode(element)) { + return true; + } else { + return false; + } + } + + /** + * This creates a line number node with the next free line number. + * If the internal flag is set, this step is skipped. + * + * @returns {Element} + */ + private createLineNumber(): Element { + if (this.ignoreNextRegularLineNumber) { + this.ignoreNextRegularLineNumber = false; + return; + } + const node = document.createElement('span'); + const lineNumber = this.currentLineNumber; + this.currentLineNumber++; + node.setAttribute('class', 'os-line-number line-number-' + lineNumber); + node.setAttribute('data-line-number', lineNumber + ''); + node.setAttribute('contenteditable', 'false'); + node.innerHTML = ' '; // Prevent ckeditor from stripping out empty span's + return node; + } + + /** + * Splits a TEXT_NODE into an array of TEXT_NODEs and BR-Elements separating them into lines. + * Each line has a maximum length of 'length', with one exception: spaces are accepted to exceed the length. + * Otherwise the string is split by the last space or dash in the line. + * + * @param {Node} node + * @param {number} length + * @param {number} highlight + */ + public textNodeToLines(node: Node, length: number, highlight: number = -1): Element[] { + const out = []; + let currLineStart = 0, + i = 0, + firstTextNode = true; + const addLine = (text: string) => { + let lineNode; + if (firstTextNode) { + if (highlight === this.currentLineNumber - 1) { + lineNode = document.createElement('span'); + lineNode.setAttribute('class', 'highlight'); + lineNode.innerHTML = text; + } else { + lineNode = document.createTextNode(text); + } + firstTextNode = false; + } else { + if (this.currentLineNumber === highlight && highlight !== null) { + lineNode = document.createElement('span'); + lineNode.setAttribute('class', 'highlight'); + lineNode.innerHTML = text; + } else { + lineNode = document.createTextNode(text); + } + out.push(this.createLineBreak()); + if (this.currentLineNumber !== null) { + out.push(this.createLineNumber()); + } + } + out.push(lineNode); + return lineNode; + }; + const addLinebreakToPreviousNode = (lineNode: Element, offset: number) => { + const firstText = lineNode.nodeValue.substr(0, offset + 1), + secondText = lineNode.nodeValue.substr(offset + 1); + const lineBreak = this.createLineBreak(); + const firstNode = document.createTextNode(firstText); + lineNode.parentNode.insertBefore(firstNode, lineNode); + lineNode.parentNode.insertBefore(lineBreak, lineNode); + if (this.currentLineNumber !== null) { + lineNode.parentNode.insertBefore(this.createLineNumber(), lineNode); + } + lineNode.nodeValue = secondText; + }; + + if (node.nodeValue === '\n') { + out.push(node); + } else { + // This happens if a previous inline element exactly stretches to the end of the line + if (this.currentInlineOffset >= length) { + out.push(this.createLineBreak()); + if (this.currentLineNumber !== null) { + out.push(this.createLineNumber()); + } + this.currentInlineOffset = 0; + this.lastInlineBreakablePoint = null; + } else if (this.prependLineNumberToFirstText) { + if (this.ignoreNextRegularLineNumber) { + this.ignoreNextRegularLineNumber = false; + } else if (this.currentLineNumber !== null) { + out.push(this.createLineNumber()); + } + } + this.prependLineNumberToFirstText = false; + + while (i < node.nodeValue.length) { + let lineBreakAt = null; + if (this.currentInlineOffset >= length) { + if (this.lastInlineBreakablePoint !== null) { + lineBreakAt = this.lastInlineBreakablePoint; + } else { + lineBreakAt = { + node: node, + offset: i - 1 + }; + } + } + if (lineBreakAt !== null && (node.nodeValue[i] !== ' ' && node.nodeValue[i] !== '\n')) { + if (lineBreakAt.node === node) { + // The last possible breaking point is in this text node + const currLine = node.nodeValue.substring(currLineStart, lineBreakAt.offset + 1); + addLine(currLine); + + currLineStart = lineBreakAt.offset + 1; + this.currentInlineOffset = i - lineBreakAt.offset - 1; + this.lastInlineBreakablePoint = null; + } else { + // The last possible breaking point was not in this text not, but one we have already passed + const remainderOfPrev = lineBreakAt.node.nodeValue.length - lineBreakAt.offset - 1; + addLinebreakToPreviousNode(lineBreakAt.node, lineBreakAt.offset); + + this.currentInlineOffset = i + remainderOfPrev; + this.lastInlineBreakablePoint = null; + } + } + + if (node.nodeValue[i] === ' ' || node.nodeValue[i] === '-' || node.nodeValue[i] === '\n') { + this.lastInlineBreakablePoint = { + node: node, + offset: i + }; + } + + this.currentInlineOffset++; + i++; + } + const lastLine = addLine(node.nodeValue.substring(currLineStart)); + if (this.lastInlineBreakablePoint !== null) { + this.lastInlineBreakablePoint.node = lastLine; + } + } + return out; + } + + /** + * Searches recursively for the first textual word in a node and returns its length. Handy for detecting if + * the next nested element will break the current line. + * + * @param {Node} node + * @returns {number} + */ + private lengthOfFirstInlineWord(node: Node): number { + if (!node.firstChild) { + return 0; + } + if (node.firstChild.nodeType === TEXT_NODE) { + const parts = node.firstChild.nodeValue.split(' '); + return parts[0].length; + } else { + return this.lengthOfFirstInlineWord(node.firstChild); + } + } + + /** + * Given an inline node, this method adds line numbers to it based on the current state. + * + * @param {Element} element + * @param {number} length + * @param {number} highlight + */ + private insertLineNumbersToInlineNode(element: Element, length: number, highlight?: number): Element { + const oldChildren: Node[] = []; + for (let i = 0; i < element.childNodes.length; i++) { + oldChildren.push(element.childNodes[i]); + } + + while (element.firstChild) { + element.removeChild(element.firstChild); + } + + for (let i = 0; i < oldChildren.length; i++) { + if (oldChildren[i].nodeType === TEXT_NODE) { + const ret = this.textNodeToLines(oldChildren[i], length, highlight); + for (let j = 0; j < ret.length; j++) { + element.appendChild(ret[j]); + } + } else if (oldChildren[i].nodeType === ELEMENT_NODE) { + const childElement = oldChildren[i], + firstword = this.lengthOfFirstInlineWord(childElement), + overlength = this.currentInlineOffset + firstword > length && this.currentInlineOffset > 0; + if (overlength && this.isInlineElement(childElement)) { + this.currentInlineOffset = 0; + this.lastInlineBreakablePoint = null; + element.appendChild(this.createLineBreak()); + if (this.currentLineNumber !== null) { + element.appendChild(this.createLineNumber()); + } + } + const changedNode = this.insertLineNumbersToNode(childElement, length, highlight); + this.moveLeadingLineBreaksToOuterNode(changedNode, element); + element.appendChild(changedNode); + } else { + throw new Error('Unknown nodeType: ' + i + ': ' + oldChildren[i]); + } + } + + return element; + } + + /** + * Given a block node, this method adds line numbers to it based on the current state. + * + * @param {Element} element + * @param {number} length + * @param {number} highlight + */ + public insertLineNumbersToBlockNode(element: Element, length: number, highlight?: number): Element { + this.currentInlineOffset = 0; + this.lastInlineBreakablePoint = null; + this.prependLineNumberToFirstText = true; + + const oldChildren = []; + for (let i = 0; i < element.childNodes.length; i++) { + oldChildren.push(element.childNodes[i]); + } + + while (element.firstChild) { + element.removeChild(element.firstChild); + } + + for (let i = 0; i < oldChildren.length; i++) { + if (oldChildren[i].nodeType === TEXT_NODE) { + if (!oldChildren[i].nodeValue.match(/\S/)) { + // White space nodes between block elements should be ignored + const prevIsBlock = i > 0 && !this.isInlineElement(oldChildren[i - 1]); + const nextIsBlock = i < oldChildren.length - 1 && !this.isInlineElement(oldChildren[i + 1]); + if ( + (prevIsBlock && nextIsBlock) || + (i === 0 && nextIsBlock) || + (i === oldChildren.length - 1 && prevIsBlock) + ) { + element.appendChild(oldChildren[i]); + continue; + } + } + const ret = this.textNodeToLines(oldChildren[i], length, highlight); + for (let j = 0; j < ret.length; j++) { + element.appendChild(ret[j]); + } + } else if (oldChildren[i].nodeType === ELEMENT_NODE) { + const firstword = this.lengthOfFirstInlineWord(oldChildren[i]), + overlength = this.currentInlineOffset + firstword > length && this.currentInlineOffset > 0; + if ( + overlength && + this.isInlineElement(oldChildren[i]) && + !this.isIgnoredByLineNumbering(oldChildren[i]) + ) { + this.currentInlineOffset = 0; + this.lastInlineBreakablePoint = null; + element.appendChild(this.createLineBreak()); + if (this.currentLineNumber !== null) { + element.appendChild(this.createLineNumber()); + } + } + const changedNode = this.insertLineNumbersToNode(oldChildren[i], length, highlight); + this.moveLeadingLineBreaksToOuterNode(changedNode, element); + element.appendChild(changedNode); + } else { + throw new Error('Unknown nodeType: ' + i + ': ' + oldChildren[i]); + } + } + + this.currentInlineOffset = 0; + this.lastInlineBreakablePoint = null; + this.prependLineNumberToFirstText = true; + this.ignoreNextRegularLineNumber = false; + + return element; + } + + /** + * Given any node, this method adds line numbers to it based on the current state. + * + * @param {Element} element + * @param {number} length + * @param {number} highlight + */ + public insertLineNumbersToNode(element: Element, length: number, highlight?: number): Element { + if (element.nodeType !== ELEMENT_NODE) { + throw new Error('This method may only be called for ELEMENT-nodes: ' + element.nodeValue); + } + if (this.isIgnoredByLineNumbering(element)) { + if (this.currentInlineOffset === 0 && this.currentLineNumber !== null) { + const lineNumberNode = this.createLineNumber(); + if (lineNumberNode) { + element.insertBefore(lineNumberNode, element.firstChild); + this.ignoreNextRegularLineNumber = true; + } + } + return element; + } else if (this.isInlineElement(element)) { + return this.insertLineNumbersToInlineNode(element, length, highlight); + } else { + const newLength = this.calcBlockNodeLength(element, length); + return this.insertLineNumbersToBlockNode(element, newLength, highlight); + } + } + + /** + * Removes all line number nodes from the given Node. + * + * @param {Node} node + */ + public stripLineNumbersFromNode(node: Node): void { + for (let i = 0; i < node.childNodes.length; i++) { + if (this.isOsLineBreakNode(node.childNodes[i]) || this.isOsLineNumberNode(node.childNodes[i])) { + // If a newline character follows a line break, it's been very likely inserted by the WYSIWYG-editor + if (node.childNodes.length > i + 1 && node.childNodes[i + 1].nodeType === TEXT_NODE) { + if (node.childNodes[i + 1].nodeValue[0] === '\n') { + node.childNodes[i + 1].nodeValue = ' ' + node.childNodes[i + 1].nodeValue.substring(1); + } + } + node.removeChild(node.childNodes[i]); + i--; + } else { + this.stripLineNumbersFromNode(node.childNodes[i]); + } + } + } + + /** + * Adds line number nodes to the given Node. + * + * @param {string} html + * @param {number|string} lineLength + * @param {number|null} highlight - optional + * @param {number|null} firstLine + */ + public insertLineNumbersNode(html: string, lineLength: number, highlight: number, firstLine: number = 1): Element { + // Removing newlines after BRs, as they lead to problems like #3410 + if (html) { + html = html.replace(/(]*>)[\n\r]+/gi, '$1'); + } + + const root = document.createElement('div'); + root.innerHTML = html; + + this.currentInlineOffset = 0; + this.lastInlineBreakablePoint = null; + this.currentLineNumber = firstLine; + this.prependLineNumberToFirstText = true; + this.ignoreNextRegularLineNumber = false; + this.ignoreInsertedText = true; + + return this.insertLineNumbersToNode(root, lineLength, highlight); + } + + /** + * Adds line number nodes to the given html string. + * @param {string} html + * @param {number} lineLength + * @param {number} highlight + * @param {function} callback + * @param {number} firstLine + * @returns {string} + */ + public insertLineNumbers( + html: string, + lineLength: number, + highlight?: number, + callback?: () => void, + firstLine?: number + ): string { + let newHtml, newRoot; + + if (highlight > 0) { + // Caching versions with highlighted line numbers is probably not worth it + newRoot = this.insertLineNumbersNode(html, lineLength, highlight, firstLine); + newHtml = newRoot.innerHTML; + } else { + const firstLineStr = firstLine === undefined ? '' : firstLine.toString(); + const cacheKey = this.djb2hash(firstLineStr + '-' + lineLength.toString() + html); + newHtml = this.lineNumberCache.get(cacheKey); + + if (!newHtml) { + newRoot = this.insertLineNumbersNode(html, lineLength, null, firstLine); + newHtml = newRoot.innerHTML; + this.lineNumberCache.put(cacheKey, newHtml); + } + } + + if (callback !== undefined && callback !== null) { + callback(); + } + + return newHtml; + } + + /** + * This enforces that no line is longer than the given line Length. However, this method does not care about + * line numbers, diff-markup etc. + * It's mainly used to display diff-formatted text with the original line numbering, that may have longer lines + * than the line length because of inserted text, in a context where really only a given width is available. + * + * @param {string} html + * @param {number} lineLength + * @param {boolean} countInserted + * @returns {string} + */ + public insertLineBreaksWithoutNumbers(html: string, lineLength: number, countInserted: boolean = false): string { + const root = document.createElement('div'); + root.innerHTML = html; + + this.currentInlineOffset = 0; + this.lastInlineBreakablePoint = null; + this.currentLineNumber = null; + this.prependLineNumberToFirstText = true; + this.ignoreNextRegularLineNumber = false; + this.ignoreInsertedText = !countInserted; + + const newRoot = this.insertLineNumbersToNode(root, lineLength, null); + + return newRoot.innerHTML; + } + + /** + * Strips line numbers from a HTML string + * + * @param {string} html + * @returns {string} + */ + public stripLineNumbers(html: string): string { + const root = document.createElement('div'); + root.innerHTML = html; + this.stripLineNumbersFromNode(root); + return root.innerHTML; + } + + /** + * Traverses up the DOM tree until it finds a node with a nextSibling, then returns that sibling + * + * @param {Node} node + * @returns {Node} + */ + public findNextAuntNode(node: Node): Node { + if (node.nextSibling) { + return node.nextSibling; + } else if (node.parentNode) { + return this.findNextAuntNode(node.parentNode); + } else { + return null; + } + } + + /** + * Highlights (span[class=highlight]) all text starting from the given line number Node to the next one found. + * + * @param {Element} lineNumberNode + */ + public highlightUntilNextLine(lineNumberNode: Element): void { + let currentNode: Node = lineNumberNode, + foundNextLineNumber = false; + + do { + let wasHighlighted = false; + if (currentNode.nodeType === TEXT_NODE) { + const node = document.createElement('span'); + node.setAttribute('class', 'highlight'); + node.innerHTML = currentNode.nodeValue; + currentNode.parentNode.insertBefore(node, currentNode); + currentNode.parentNode.removeChild(currentNode); + currentNode = node; + wasHighlighted = true; + } else { + wasHighlighted = false; + } + + if (currentNode.childNodes.length > 0 && !this.isOsLineNumberNode(currentNode) && !wasHighlighted) { + currentNode = currentNode.childNodes[0]; + } else if (currentNode.nextSibling) { + currentNode = currentNode.nextSibling; + } else { + currentNode = this.findNextAuntNode(currentNode); + } + + if (this.isOsLineNumberNode(currentNode)) { + foundNextLineNumber = true; + } + } while (!foundNextLineNumber && currentNode !== null); + } + + /** + * Highlights (span[class=highlight]) a specific line. + * + * @param {string} html + * @param {number} lineNumber + * @return {string} + */ + public highlightLine(html: string, lineNumber: number): string { + const fragment = this.htmlToFragment(html), + lineNumberNode = this.getLineNumberNode(fragment, lineNumber); + + if (lineNumberNode) { + this.highlightUntilNextLine(lineNumberNode); + html = this.fragmentToHtml(fragment); + } + + return html; + } +} diff --git a/client/src/app/site/motions/services/motion-repository.service.ts b/client/src/app/site/motions/services/motion-repository.service.ts index 0e2b2f902..c502c1e9d 100644 --- a/client/src/app/site/motions/services/motion-repository.service.ts +++ b/client/src/app/site/motions/services/motion-repository.service.ts @@ -10,6 +10,8 @@ import { ViewMotion } from '../models/view-motion'; import { Observable } from 'rxjs'; import { BaseRepository } from '../../base/base-repository'; import { DataStoreService } from '../../../core/services/data-store.service'; +import { LinenumberingService } from './linenumbering.service'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; /** * Repository Services for motions (and potentially categories) @@ -30,9 +32,16 @@ export class MotionRepositoryService extends BaseRepository * * Converts existing and incoming motions to ViewMotions * Handles CRUD using an observer to the DataStore - * @param DataSend + * @param DS + * @param dataSend + * @param lineNumbering */ - public constructor(DS: DataStoreService, private dataSend: DataSendService) { + public constructor( + DS: DataStoreService, + private dataSend: DataSendService, + private readonly lineNumbering: LinenumberingService, + private readonly sanitizer: DomSanitizer + ) { super(DS, Motion, [Category, User, Workflow]); } @@ -102,30 +111,20 @@ export class MotionRepositoryService extends BaseRepository * Format the motion text using the line numbering and change * reco algorithm. * - * TODO: Call DiffView and LineNumbering Service here. + * TODO: Call DiffView Service here. * * Can be called from detail view and exporter * @param id Motion ID - will be pulled from the repository - * @param lnMode indicator for the line numbering mode * @param crMode indicator for the change reco mode + * @param lineLength the current line + * @param highlightLine the currently highlighted line (default: none) */ - public formatMotion(id: number, lnMode: number, crMode: number): string { + public formatMotion(id: number, crMode: number, lineLength: number, highlightLine?: number): SafeHtml { const targetMotion = this.getViewModel(id); if (targetMotion && targetMotion.text) { let motionText = targetMotion.text; - - // TODO : Use Line numbering service here - switch (lnMode) { - case 0: // no line numbers - break; - case 1: // line number inside - motionText = 'Get line numbers outside'; - break; - case 2: // line number outside - motionText = 'Get line numbers inside'; - break; - } + motionText = this.lineNumbering.insertLineNumbers(motionText, lineLength, highlightLine); // TODO : Use Diff Service here. // this will(currently) append the previous changes. @@ -144,7 +143,7 @@ export class MotionRepositoryService extends BaseRepository break; } - return motionText; + return this.sanitizer.bypassSecurityTrustHtml(motionText); } else { return null; } From 46ad38a98a43ddb11b9627c16caa3c0702d12454 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Ho=CC=88=C3=9Fl?= Date: Sun, 30 Sep 2018 18:43:20 +0200 Subject: [PATCH 2/2] Change recommendations --- .../models/motions/motion-change-reco.ts | 2 +- client/src/app/shared/shared.module.ts | 8 +- ...otion-change-recommendation.component.html | 19 ++ ...otion-change-recommendation.component.scss | 9 + ...on-change-recommendation.component.spec.ts | 54 ++++ .../motion-change-recommendation.component.ts | 135 +++++++++ .../motion-detail-diff.component.html | 123 ++++++++ .../motion-detail-diff.component.scss | 116 +++++++ .../motion-detail-diff.component.spec.ts | 58 ++++ .../motion-detail-diff.component.ts | 253 ++++++++++++++++ ...inal-change-recommendations.component.html | 12 + ...inal-change-recommendations.component.scss | 38 +++ ...l-change-recommendations.component.spec.ts | 44 +++ ...iginal-change-recommendations.component.ts | 284 ++++++++++++++++++ .../motion-detail.component.html | 29 +- .../motion-detail.component.scss | 20 +- .../motion-detail.component.spec.ts | 5 +- .../motion-detail/motion-detail.component.ts | 122 +++++++- .../site/motions/models/view-change-reco.ts | 100 ++++++ .../app/site/motions/models/view-motion.ts | 16 +- .../motions/models/view-unified-change.ts | 46 +++ client/src/app/site/motions/motions.module.ts | 12 +- ...-recommendation-repository.service.spec.ts | 20 ++ ...hange-recommendation-repository.service.ts | 135 +++++++++ .../app/site/motions/services/diff.service.ts | 53 +++- .../motions/services/linenumbering.service.ts | 11 +- .../services/motion-repository.service.ts | 228 ++++++++++++-- 27 files changed, 1886 insertions(+), 66 deletions(-) create mode 100644 client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.html create mode 100644 client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.scss create mode 100644 client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.spec.ts create mode 100644 client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.ts create mode 100644 client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.html create mode 100644 client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.scss create mode 100644 client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.spec.ts create mode 100644 client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.ts create mode 100644 client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.html create mode 100644 client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.scss create mode 100644 client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.spec.ts create mode 100644 client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.ts create mode 100644 client/src/app/site/motions/models/view-change-reco.ts create mode 100644 client/src/app/site/motions/models/view-unified-change.ts create mode 100644 client/src/app/site/motions/services/change-recommendation-repository.service.spec.ts create mode 100644 client/src/app/site/motions/services/change-recommendation-repository.service.ts diff --git a/client/src/app/shared/models/motions/motion-change-reco.ts b/client/src/app/shared/models/motions/motion-change-reco.ts index 24035b643..05aa22b51 100644 --- a/client/src/app/shared/models/motions/motion-change-reco.ts +++ b/client/src/app/shared/models/motions/motion-change-reco.ts @@ -6,7 +6,7 @@ import { BaseModel } from '../base/base-model'; */ export class MotionChangeReco extends BaseModel { public id: number; - public motion_version_id: number; + public motion_id: number; public rejected: boolean; public type: number; public other_description: string; diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index c7d3e0a2e..753ece21f 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -20,10 +20,12 @@ import { MatDatepickerModule, MatNativeDateModule, DateAdapter, - MatIconModule + MatIconModule, + MatButtonToggleModule } from '@angular/material'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatChipsModule } from '@angular/material'; +import { MatRadioModule } from '@angular/material'; import { NgxMatSelectSearchModule } from 'ngx-mat-select-search'; import { MatDialogModule } from '@angular/material/dialog'; import { MatListModule } from '@angular/material/list'; @@ -88,6 +90,8 @@ import { PromptDialogComponent } from './components/prompt-dialog/prompt-dialog. // we either wait or include a fixed version manually (dirty) // https://github.com/google/material-design-icons/issues/786 MatIconModule, + MatRadioModule, + MatButtonToggleModule, TranslateModule.forChild(), RouterModule, NgxMatSelectSearchModule @@ -117,6 +121,8 @@ import { PromptDialogComponent } from './components/prompt-dialog/prompt-dialog. MatChipsModule, MatTooltipModule, MatIconModule, + MatRadioModule, + MatButtonToggleModule, NgxMatSelectSearchModule, TranslateModule, PermsDirective, diff --git a/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.html b/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.html new file mode 100644 index 000000000..70bc3529c --- /dev/null +++ b/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.html @@ -0,0 +1,19 @@ +

      Create a change recommendation

      + +
      +

      Change from line {{ lineRange.from }} to {{ lineRange.to }}:

      + + + {{ radio.title }} + + + + + +
      +
      + + + + + diff --git a/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.scss b/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.scss new file mode 100644 index 000000000..e2963273e --- /dev/null +++ b/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.scss @@ -0,0 +1,9 @@ +.wide-form { + textarea { + height: 100px; + } + + ::ng-deep { + width: 100%; + } +} diff --git a/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.spec.ts b/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.spec.ts new file mode 100644 index 000000000..e31dd8113 --- /dev/null +++ b/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.spec.ts @@ -0,0 +1,54 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { + MotionChangeRecommendationComponent, + MotionChangeRecommendationComponentData +} from './motion-change-recommendation.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; +import { ModificationType } from '../../services/diff.service'; +import { ViewChangeReco } from '../../models/view-change-reco'; + +describe('MotionChangeRecommendationComponent', () => { + let component: MotionChangeRecommendationComponent; + let fixture: ComponentFixture; + + const changeReco = { + line_from: 1, + line_to: 2, + type: ModificationType.TYPE_REPLACEMENT, + text: '

      ', + rejected: false, + motion_id: 1 + }; + const dialogData: MotionChangeRecommendationComponentData = { + newChangeRecommendation: true, + editChangeRecommendation: false, + changeRecommendation: changeReco, + lineRange: { from: 1, to: 2 } + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [MotionChangeRecommendationComponent], + providers: [ + { provide: MatDialogRef, useValue: {} }, + { + provide: MAT_DIALOG_DATA, + useValue: dialogData + } + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MotionChangeRecommendationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.ts b/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.ts new file mode 100644 index 000000000..179921432 --- /dev/null +++ b/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.ts @@ -0,0 +1,135 @@ +import { Component, Inject } from '@angular/core'; +import { LineRange, ModificationType } from '../../services/diff.service'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { ChangeRecommendationRepositoryService } from '../../services/change-recommendation-repository.service'; +import { ViewChangeReco } from '../../models/view-change-reco'; + +/** + * Data that needs to be provided to the MotionChangeRecommendationComponent dialog + */ +export interface MotionChangeRecommendationComponentData { + editChangeRecommendation: boolean; + newChangeRecommendation: boolean; + lineRange: LineRange; + changeRecommendation: ViewChangeReco; +} + +/** + * The dialog for creating and editing change recommendations from within the os-motion-detail-component. + * + * @example + * ```ts + * const data: MotionChangeRecommendationComponentData = { + * editChangeRecommendation: false, + * newChangeRecommendation: true, + * lineRange: lineRange, + * motion: this.motion, + * }; + * this.dialogService.open(MotionChangeRecommendationComponent, { + * height: '400px', + * width: '600px', + * data: data, + * }); + * ``` + * + */ +@Component({ + selector: 'os-motion-change-recommendation', + templateUrl: './motion-change-recommendation.component.html', + styleUrls: ['./motion-change-recommendation.component.scss'] +}) +export class MotionChangeRecommendationComponent { + /** + * Determine if the change recommendation is edited + */ + public editReco = false; + + /** + * Determine if the change recommendation is new + */ + public newReco = false; + + /** + * The change recommendation + */ + public changeReco: ViewChangeReco; + + /** + * The line range affected by this change recommendation + */ + public lineRange: LineRange; + + /** + * Change recommendation content. + */ + public contentForm: FormGroup; + + /** + * The replacement types for the radio group + * @TODO translate + */ + public replacementTypes = [ + { + value: ModificationType.TYPE_REPLACEMENT, + title: 'Replacement' + }, + { + value: ModificationType.TYPE_INSERTION, + title: 'Insertion' + }, + { + value: ModificationType.TYPE_DELETION, + title: 'Deletion' + } + ]; + + public constructor( + @Inject(MAT_DIALOG_DATA) public data: MotionChangeRecommendationComponentData, + private formBuilder: FormBuilder, + private repo: ChangeRecommendationRepositoryService, + private dialogRef: MatDialogRef + ) { + this.editReco = data.editChangeRecommendation; + this.newReco = data.newChangeRecommendation; + this.changeReco = data.changeRecommendation; + this.lineRange = data.lineRange; + + this.createForm(); + } + + /** + * Creates the forms for the Motion and the MotionVersion + */ + public createForm(): void { + this.contentForm = this.formBuilder.group({ + text: [this.changeReco.text, Validators.required], + diffType: [this.changeReco.type, Validators.required] + }); + } + + public saveChangeRecommendation(): void { + this.changeReco.updateChangeReco( + this.contentForm.controls.diffType.value, + this.contentForm.controls.text.value + ); + + if (this.newReco) { + this.repo.createByViewModel(this.changeReco).subscribe(response => { + if (response.id) { + this.dialogRef.close(response); + } else { + // @TODO Show an error message + } + }); + } else { + this.repo.update(this.changeReco.changeRecommendation, this.changeReco).subscribe(response => { + if (response.id) { + this.dialogRef.close(response); + } else { + // @TODO Show an error message + } + }); + } + } +} diff --git a/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.html b/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.html new file mode 100644 index 000000000..76951821d --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.html @@ -0,0 +1,123 @@ + +

      + + {{ 'Summary of changes' | translate }}: + + + + + + +
      + {{ 'No change recommendations yet' | translate }} +
      +
      + + + +
      +
      +
      + +
      + +
      +
      + warning +
      +
      + + + +
      +
      + {{ 'Rejected' | translate }}: +
      + +
      +
      +
      + +
      + +
      +
      + + + + + + + + + diff --git a/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.scss b/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.scss new file mode 100644 index 000000000..1fb3cfb1d --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.scss @@ -0,0 +1,116 @@ +/* Diffbox */ +.diff-box { + background-color: #f9f9f9; + border: solid 1px #eee; + border-radius: 3px; + margin-bottom: 0; + padding-top: 0; + padding-right: 155px; + + &:hover { + background-color: #f0f0f0; + + .action-row { + opacity: 1; + } + } + + .action-row { + font-size: 0.8em; + padding-top: 5px; + padding-bottom: 5px; + float: right; + width: 150px; + text-align: right; + margin-right: -150px; + opacity: 0.5; + + .btn-delete { + margin-left: 5px; + color: red; + } + + .btn-edit { + margin-left: 5px; + } + + .btn-amend-info { + margin-left: 5px; + min-width: 68px; + } + } + .status-row { + font-style: italic; + color: gray; + + & > *:after { + content: ':'; + } + } +} + +.change-recommendation-overview { + background-color: #eee; + border: solid 1px #ddd; + border-radius: 3px; + margin-bottom: 5px; + margin-top: -15px; + padding: 5px 5px 0 5px; + + a, + a:link, + a:active { + text-decoration: none; + } + + h3 { + margin-top: 10px; + } + + ul { + list-style: none; + display: table; + } + + li { + display: table-row; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + + & > * { + display: table-cell; + padding: 1px; + } + } + + .status { + color: gray; + font-style: italic; + + & > *:before { + content: '('; + } + + & > *:after { + content: ')'; + } + } + + .no-changes { + font-style: italic; + color: grey; + } +} + +::ng-deep .mat-menu-content { + .active-indicator { + font-size: 18px; + color: green !important; + margin-left: 10px; + margin-right: 0; + margin-top: 3px; + } +} diff --git a/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.spec.ts b/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.spec.ts new file mode 100644 index 000000000..58deac193 --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.spec.ts @@ -0,0 +1,58 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from '../../../../../e2e-imports.module'; +import { Component } from '@angular/core'; +import { ViewMotion } from '../../models/view-motion'; +import { ViewChangeReco } from '../../models/view-change-reco'; +import { MotionDetailDiffComponent } from './motion-detail-diff.component'; +import { MotionDetailOriginalChangeRecommendationsComponent } from '../motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component'; + +@Component({ + template: ` + + ` +}) +class TestHostComponent { + public motion: ViewMotion; + public changes: ViewChangeReco[]; + + public constructor() { + this.motion = new ViewMotion(); + this.changes = []; + } + + public scrollToChange($event: Event): void {} + + public createChangeRecommendation($event: Event): void {} +} + +describe('MotionDetailDiffComponent', () => { + let component: TestHostComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [ + TestHostComponent, + MotionDetailDiffComponent, + MotionDetailOriginalChangeRecommendationsComponent + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestHostComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.ts b/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.ts new file mode 100644 index 000000000..9dfeb3802 --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.ts @@ -0,0 +1,253 @@ +import { AfterViewInit, Component, ElementRef, EventEmitter, Input, Output } from '@angular/core'; +import { ViewMotion } from '../../models/view-motion'; +import { ViewUnifiedChange, ViewUnifiedChangeType } from '../../models/view-unified-change'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { MotionRepositoryService } from '../../services/motion-repository.service'; +import { LineRange, ModificationType } from '../../services/diff.service'; +import { ViewChangeReco } from '../../models/view-change-reco'; +import { MatDialog } from '@angular/material'; +import { ChangeRecommendationRepositoryService } from '../../services/change-recommendation-repository.service'; +import { + MotionChangeRecommendationComponent, + MotionChangeRecommendationComponentData +} from '../motion-change-recommendation/motion-change-recommendation.component'; + +/** + * This component displays the original motion text with the change blocks inside. + * If the user is an administrator, each change block can be rejected. + * + * The line numbers are provided within the pre-rendered HTML, so we have to work with raw HTML and native HTML elements. + * + * It takes the styling from the parent component. + * + * ## Examples + * + * ```html + * + * ``` + */ +@Component({ + selector: 'os-motion-detail-diff', + templateUrl: './motion-detail-diff.component.html', + styleUrls: ['./motion-detail-diff.component.scss'] +}) +export class MotionDetailDiffComponent implements AfterViewInit { + @Input() + public motion: ViewMotion; + @Input() + public changes: ViewUnifiedChange[]; + @Input() + public scrollToChange: ViewUnifiedChange; + + @Output() + public createChangeRecommendation: EventEmitter = new EventEmitter(); + + public constructor( + private sanitizer: DomSanitizer, + private motionRepo: MotionRepositoryService, + private recoRepo: ChangeRecommendationRepositoryService, + private dialogService: MatDialog, + private el: ElementRef + ) {} + + /** + * Returns the part of this motion between two change objects + * @param {ViewUnifiedChange} change1 + * @param {ViewUnifiedChange} change2 + */ + public getTextBetweenChanges(change1: ViewUnifiedChange, change2: ViewUnifiedChange): string { + // @TODO Highlighting + const lineRange: LineRange = { + from: change1 ? change1.getLineTo() : 1, + to: change2 ? change2.getLineFrom() : null + }; + + if (lineRange.from > lineRange.to) { + const msg = 'Inconsistent data.'; + return '' + msg + ''; + } + if (lineRange.from === lineRange.to) { + return ''; + } + + return this.motionRepo.extractMotionLineRange(this.motion.id, lineRange, true); + } + + /** + * Returns true if this change is colliding with another change + * @param change + */ + public hasCollissions(change: ViewUnifiedChange): boolean { + // @TODO Implementation + return false; + } + + /** + * Returns the diff string from the motion to the change + * @param {ViewUnifiedChange} change + */ + public getDiff(change: ViewUnifiedChange): SafeHtml { + const html = this.motionRepo.getChangeDiff(this.motion, change); + return this.sanitizer.bypassSecurityTrustHtml(html); + } + + /** + * Returns the remainder text of the motion after the last change + */ + public getTextRemainderAfterLastChange(): string { + return this.motionRepo.getTextRemainderAfterLastChange(this.motion, this.changes); + } + + /** + * Returns true if the change is a Change Recommendation + * + * @param {ViewUnifiedChange} change + */ + public isRecommendation(change: ViewUnifiedChange): boolean { + return change.getChangeType() === ViewUnifiedChangeType.TYPE_CHANGE_RECOMMENDATION; + } + + /** + * Returns accepted, rejected or an empty string depending on the state of this change. + * + * @param change + */ + public getAcceptanceValue(change: ViewUnifiedChange): string { + if (change.isAccepted()) { + return 'accepted'; + } + if (change.isRejected()) { + return 'rejected'; + } + return ''; + } + + /** + * Returns true if the change is an Amendment + * + * @param {ViewUnifiedChange} change + */ + public isAmendment(change: ViewUnifiedChange): boolean { + return change.getChangeType() === ViewUnifiedChangeType.TYPE_AMENDMENT; + } + + /** + * Returns true if the change is a Change Recommendation + * + * @param {ViewUnifiedChange} change + */ + public isChangeRecommendation(change: ViewUnifiedChange): boolean { + return change.getChangeType() === ViewUnifiedChangeType.TYPE_CHANGE_RECOMMENDATION; + } + + /** + * Gets the name of the modification type + * + * @param change + */ + public getRecommendationTypeName(change: ViewChangeReco): string { + switch (change.type) { + case ModificationType.TYPE_REPLACEMENT: + return 'Replacement'; + case ModificationType.TYPE_INSERTION: + return 'Insertion'; + case ModificationType.TYPE_DELETION: + return 'Deletion'; + default: + return '@UNKNOWN@'; + } + } + + /** + * Sets a change recommendation to accepted or rejected. + * The template has to make sure only to pass change recommendations to this method. + * + * @param {ViewChangeReco} change + * @param {string} value + */ + public setAcceptanceValue(change: ViewChangeReco, value: string): void { + if (value === 'accepted') { + this.recoRepo.setAccepted(change).subscribe(() => {}); // Subscribe to trigger HTTP request + } + if (value === 'rejected') { + this.recoRepo.setRejected(change).subscribe(() => {}); // Subscribe to trigger HTTP request + } + } + + /** + * Deletes a change recommendation. + * The template has to make sure only to pass change recommendations to this method. + * + * @param {ViewChangeReco} reco + * @param {MouseEvent} $event + */ + public deleteChangeRecommendation(reco: ViewChangeReco, $event: MouseEvent): void { + this.recoRepo.delete(reco).subscribe(() => {}); // Subscribe to trigger HTTP request + $event.stopPropagation(); + $event.preventDefault(); + } + + /** + * Edits a change recommendation. + * The template has to make sure only to pass change recommendations to this method. + * + * @param {ViewChangeReco} reco + * @param {MouseEvent} $event + */ + public editChangeRecommendation(reco: ViewChangeReco, $event: MouseEvent): void { + $event.stopPropagation(); + $event.preventDefault(); + + const data: MotionChangeRecommendationComponentData = { + editChangeRecommendation: true, + newChangeRecommendation: false, + lineRange: { + from: reco.getLineFrom(), + to: reco.getLineTo() + }, + changeRecommendation: reco + }; + this.dialogService.open(MotionChangeRecommendationComponent, { + height: '400px', + width: '600px', + data: data + }); + } + + /** + * Scrolls to the native element specified by [scrollToChange] + */ + private scrollToChangeElement(change: ViewUnifiedChange): void { + const element = this.el.nativeElement; + const target = element.querySelector('[data-change-id="' + change.getChangeId() + '"]'); + target.scrollIntoView({ behavior: 'smooth' }); + } + + public scrollToChangeClicked(change: ViewUnifiedChange, $event: MouseEvent): void { + $event.preventDefault(); + $event.stopPropagation(); + this.scrollToChangeElement(change); + } + + /** + * Called from motion-detail-original-change-recommendations -> delegate to parent + * + * @param {LineRange} event + */ + public onCreateChangeRecommendation(event: LineRange): void { + this.createChangeRecommendation.emit(event); + } + + public ngAfterViewInit(): void { + if (this.scrollToChange) { + window.setTimeout(() => { + this.scrollToChangeElement(this.scrollToChange); + }, 50); + } + } +} diff --git a/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.html b/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.html new file mode 100644 index 000000000..d3268a073 --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.html @@ -0,0 +1,12 @@ +
      +
        +
      • +
      diff --git a/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.scss b/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.scss new file mode 100644 index 000000000..47106c5a2 --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.scss @@ -0,0 +1,38 @@ +.change-recommendation-list { + position: absolute; + top: 0; + left: -25px; + width: 4px; + list-style-type: none; + padding: 0; + margin: 0; + + & > li { + position: absolute; + width: 4px; + left: 0; + cursor: pointer; + padding: 0; + margin: 0; + + &.insert { + background-color: #00aa00; + } + + &.delete { + background-color: #aa0000; + } + + &.replace { + background-color: #0333ff; + } + + &.other { + background-color: #777777; + } + } + + .tooltip { + display: none; + } +} diff --git a/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.spec.ts b/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.spec.ts new file mode 100644 index 000000000..7dba17e87 --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.spec.ts @@ -0,0 +1,44 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MotionDetailOriginalChangeRecommendationsComponent } from './motion-detail-original-change-recommendations.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; +import { Component } from '@angular/core'; + +@Component({ + template: ` + + ` +}) +class TestHostComponent { + public html = '

      Test123

      '; + public changeRecommendations = []; + public createChangeRecommendation($event: Event): void {} + public gotoChangeRecommendation($event: Event): void {} +} + +describe('MotionDetailOriginalChangeRecommendationsComponent', () => { + let component: TestHostComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [MotionDetailOriginalChangeRecommendationsComponent, TestHostComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestHostComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.ts b/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.ts new file mode 100644 index 000000000..0dbd97f42 --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.ts @@ -0,0 +1,284 @@ +import { + ElementRef, + Renderer2, + OnInit, + Output, + EventEmitter, + Input, + Component, + OnChanges, + SimpleChanges +} from '@angular/core'; +import { LineRange, ModificationType } from '../../services/diff.service'; +import { ViewChangeReco } from '../../models/view-change-reco'; +import { OperatorService } from '../../../../core/services/operator.service'; + +/** + * This component displays the original motion text with annotated change commendations + * and a method to create new change recommendations from the line numbers to the left of the text. + * It's called from motion-details for displaying the whole motion text as well as from the diff view to show the + * unchanged parts of the motion. + * + * The line numbers are provided within the pre-rendered HTML, so we have to work with raw HTML and native HTML elements. + * + * It takes the styling from the parent component. + * + * ## Examples + * + * ```html + * + * ``` + */ +@Component({ + selector: 'os-motion-detail-original-change-recommendations', + templateUrl: './motion-detail-original-change-recommendations.component.html', + styleUrls: ['./motion-detail-original-change-recommendations.component.scss'] +}) +export class MotionDetailOriginalChangeRecommendationsComponent implements OnInit, OnChanges { + private element: Element; + private selectedFrom: number = null; + + @Output() + public createChangeRecommendation: EventEmitter = new EventEmitter(); + + @Output() + public gotoChangeRecommendation: EventEmitter = new EventEmitter(); + + @Input() + public html: string; + + @Input() + public changeRecommendations: ViewChangeReco[]; + + public showChangeRecommendations = false; + + public can_manage = false; + + /** + * @param {Renderer2} renderer + * @param {ElementRef} el + * @param {OperatorService} operator + */ + public constructor(private renderer: Renderer2, private el: ElementRef, private operator: OperatorService) { + this.operator.getObservable().subscribe(this.onPermissionsChanged.bind(this)); + } + + /** + * Re-creates + */ + private update(): void { + if (!this.element) { + // Not yet initialized + return; + } + this.element.innerHTML = this.html; + + this.startCreating(); + } + + /** + * The permissions of the user have changed -> activate / deactivate editing functionality + */ + private onPermissionsChanged(): void { + if (this.operator.hasPerms('motions.can_manage')) { + this.can_manage = true; + if (this.selectedFrom === null) { + this.startCreating(); + } + } else { + this.can_manage = false; + this.selectedFrom = null; + if (this.element) { + Array.from(this.element.querySelectorAll('.os-line-number')).forEach((lineNumber: Element) => { + lineNumber.classList.remove('selectable'); + lineNumber.classList.remove('selected'); + }); + } + } + } + + /** + * Returns an array with all line numbers that are currently affected by a change recommendation + * and therefor not subject to further changes + */ + private getAffectedLineNumbers(): number[] { + const affectedLines = []; + this.changeRecommendations.forEach((change: ViewChangeReco) => { + for (let j = change.line_from; j < change.line_to; j++) { + affectedLines.push(j); + } + }); + return affectedLines; + } + + /** + * Resetting the selection. All selected lines are unselected, and the selectable lines are marked as such + */ + private startCreating(): void { + if (!this.can_manage || !this.element) { + return; + } + + const alreadyAffectedLines = this.getAffectedLineNumbers(); + Array.from(this.element.querySelectorAll('.os-line-number')).forEach((lineNumber: Element) => { + lineNumber.classList.remove('selected'); + if (alreadyAffectedLines.indexOf(parseInt(lineNumber.getAttribute('data-line-number'), 10)) === -1) { + lineNumber.classList.add('selectable'); + } else { + lineNumber.classList.remove('selectable'); + } + }); + } + + /** + * A line number has been clicked - either to start the selection or to finish it. + * + * @param lineNumber + */ + private clickedLineNumber(lineNumber: number): void { + if (this.selectedFrom === null) { + this.selectedFrom = lineNumber; + } else { + if (lineNumber > this.selectedFrom) { + this.createChangeRecommendation.emit({ + from: this.selectedFrom, + to: lineNumber + 1 + }); + } else { + this.createChangeRecommendation.emit({ + from: lineNumber, + to: this.selectedFrom + 1 + }); + } + this.selectedFrom = null; + this.startCreating(); + } + } + + /** + * A line number is hovered. If we are in the process of selecting a line range and the hovered line is selectable, + * the plus sign is shown for this line and all lines between the first selected line. + * + * @param lineNumberHovered + */ + private hoverLineNumber(lineNumberHovered: number): void { + if (this.selectedFrom === null) { + return; + } + Array.from(this.element.querySelectorAll('.os-line-number')).forEach((lineNumber: Element) => { + const line = parseInt(lineNumber.getAttribute('data-line-number'), 10); + if ( + (line >= this.selectedFrom && line <= lineNumberHovered) || + (line >= lineNumberHovered && line <= this.selectedFrom) + ) { + lineNumber.classList.add('selected'); + } else { + lineNumber.classList.remove('selected'); + } + }); + } + + /** + * Style for the change recommendation list + * @param reco + */ + public calcRecoTop(reco: ViewChangeReco): string { + const from = ( + this.element.querySelector('.os-line-number.line-number-' + reco.line_from.toString(10)) + ); + return from.offsetTop.toString() + 'px'; + } + + /** + * Style for the change recommendation list + * @param reco + */ + public calcRecoHeight(reco: ViewChangeReco): string { + const from = ( + this.element.querySelector('.os-line-number.line-number-' + reco.line_from.toString(10)) + ); + const to = this.element.querySelector('.os-line-number.line-number-' + reco.line_to.toString(10)); + if (to) { + return (to.offsetTop - from.offsetTop).toString() + 'px'; + } else { + // Last line - lets assume a realistic value + return '20px'; + } + } + + /** + * CSS-Class for the change recommendation list + * @param reco + */ + public recoIsInsertion(reco: ViewChangeReco): boolean { + return reco.type === ModificationType.TYPE_INSERTION; + } + + /** + * CSS-Class for the change recommendation list + * @param reco + */ + public recoIsDeletion(reco: ViewChangeReco): boolean { + return reco.type === ModificationType.TYPE_DELETION; + } + + /** + * CSS-Class for the change recommendation list + * @param reco + */ + public recoIsReplacement(reco: ViewChangeReco): boolean { + return reco.type === ModificationType.TYPE_REPLACEMENT; + } + + /** + * Trigger the `gotoChangeRecommendation`-event + * @param reco + */ + public gotoReco(reco: ViewChangeReco): void { + this.gotoChangeRecommendation.emit(reco); + } + + /** + * Adding the event listeners: clicking on plus signs next to line numbers + * and the hover-event next to the line numbers + */ + public ngOnInit(): void { + const nativeElement = this.el.nativeElement; + this.element = nativeElement.querySelector('.text'); + + this.renderer.listen(this.el.nativeElement, 'click', (ev: MouseEvent) => { + const element = ev.target; + if (element.classList.contains('os-line-number') && element.classList.contains('selectable')) { + this.clickedLineNumber(parseInt(element.getAttribute('data-line-number'), 10)); + } + }); + + this.renderer.listen(this.el.nativeElement, 'mouseover', (ev: MouseEvent) => { + const element = ev.target; + if (element.classList.contains('os-line-number') && element.classList.contains('selectable')) { + this.hoverLineNumber(parseInt(element.getAttribute('data-line-number'), 10)); + } + }); + + this.update(); + + // The positioning of the change recommendations depends on the rendered HTML + // If we show it right away, there will be nasty Angular warnings about changed values, as the position + // is changing while the DOM updates + window.setTimeout(() => { + this.showChangeRecommendations = true; + }, 1); + } + + /** + * @param changes + */ + public ngOnChanges(changes: SimpleChanges): void { + this.update(); + } +} 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 76d4db5fd..98f17c7e9 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 @@ -50,7 +50,7 @@ - + info @@ -275,12 +275,27 @@

      The assembly may decide:

      -
      -
      -
      + +
      + +
      +
      + +
      diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss b/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss index 5bd03e27b..554eaeb49 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss @@ -166,12 +166,14 @@ mat-expansion-panel { // which doesn't have the [ngcontent]-attributes necessary for regular styles. // An alternative approach (in case ::ng-deep gets removed) might be to change the view encapsulation. :host ::ng-deep .motion-text { - ins { + ins, + .insert { color: green; text-decoration: underline; } - del { + del, + .delete { color: red; text-decoration: line-through; } @@ -212,6 +214,20 @@ mat-expansion-panel { font-size: 12px; font-weight: normal; } + + &.selectable:hover:before, + &.selected:before { + position: absolute; + top: 4px; + left: 20px; + display: inline-block; + cursor: pointer; + content: ''; + width: 16px; + height: 16px; + background: url('data:image/svg+xml;utf8,'); + background-size: 16px 16px; + } } } diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.spec.ts b/client/src/app/site/motions/components/motion-detail/motion-detail.component.spec.ts index 717bfca45..067c3b9c7 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.spec.ts +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.spec.ts @@ -2,6 +2,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { MotionDetailComponent } from './motion-detail.component'; import { E2EImportsModule } from '../../../../../e2e-imports.module'; +import { MotionsModule } from '../../motions.module'; describe('MotionDetailComponent', () => { let component: MotionDetailComponent; @@ -9,8 +10,8 @@ describe('MotionDetailComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [E2EImportsModule], - declarations: [MotionDetailComponent] + imports: [E2EImportsModule, MotionsModule], + declarations: [] }).compileComponents(); })); diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts b/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts index 3996309fe..7a76be72f 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts @@ -1,19 +1,27 @@ import { Component, OnInit, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { MatExpansionPanel } from '@angular/material'; +import { MatDialog, MatExpansionPanel } from '@angular/material'; import { BaseComponent } from '../../../../base.component'; import { Category } from '../../../../shared/models/motions/category'; import { ViewportService } from '../../../../core/services/viewport.service'; import { MotionRepositoryService } from '../../services/motion-repository.service'; -import { LineNumbering, ViewMotion } from '../../models/view-motion'; +import { ChangeRecoMode, LineNumberingMode, ViewMotion } from '../../models/view-motion'; import { User } from '../../../../shared/models/users/user'; import { DataStoreService } from '../../../../core/services/data-store.service'; import { TranslateService } from '@ngx-translate/core'; import { Motion } from '../../../../shared/models/motions/motion'; import { BehaviorSubject } from 'rxjs'; -import { SafeHtml } from '@angular/platform-browser'; +import { LineRange } from '../../services/diff.service'; +import { + MotionChangeRecommendationComponent, + MotionChangeRecommendationComponentData +} from '../motion-change-recommendation/motion-change-recommendation.component'; +import { ChangeRecommendationRepositoryService } from '../../services/change-recommendation-repository.service'; +import { ViewChangeReco } from '../../models/view-change-reco'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { ViewUnifiedChange } from '../../models/view-unified-change'; /** * Component for the motion detail view @@ -68,6 +76,16 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { */ public motionCopy: ViewMotion; + /** + * All change recommendations to this motion + */ + public changeRecommendations: ViewChangeReco[]; + + /** + * All change recommendations AND amendments, sorted by line number. + */ + public allChangingObjects: ViewUnifiedChange[]; + /** * Subject for the Categories */ @@ -83,6 +101,11 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { */ public supporterObserver: BehaviorSubject>; + /** + * Value for os-motion-detail-diff: when this is set, that component scrolls to the given change + */ + public scrollToChange: ViewUnifiedChange = null; + /** * Constuct the detail view. * @@ -90,7 +113,11 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { * @param router to navigate back to the motion list and to an existing motion * @param route determine if this is a new or an existing motion * @param formBuilder For reactive forms. Form Group and Form Control + * @param dialogService For opening dialogs * @param repo: Motion Repository + * @param changeRecoRepo: Change Recommendation Repository + * @param DS: The DataStoreService + * @param sanitizer: For making HTML SafeHTML * @param translate: Translation Service */ public constructor( @@ -98,8 +125,11 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { private router: Router, private route: ActivatedRoute, private formBuilder: FormBuilder, + private dialogService: MatDialog, private repo: MotionRepositoryService, + private changeRecoRepo: ChangeRecommendationRepositoryService, private DS: DataStoreService, + private sanitizer: DomSanitizer, protected translate: TranslateService ) { super(); @@ -119,6 +149,12 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { this.repo.getViewModelObservable(params.id).subscribe(newViewMotion => { this.motion = newViewMotion; }); + this.changeRecoRepo + .getChangeRecosOfMotionObservable(parseInt(params.id, 10)) + .subscribe((recos: ViewChangeReco[]) => { + this.changeRecommendations = recos; + this.recalcUnifiedChanges(); + }); }); } // Initial Filling of the Subjects @@ -138,6 +174,24 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { }); } + /** + * Merges amendments and change recommendations and sorts them by the line numbers. + * Called each time one of these arrays changes. + */ + private recalcUnifiedChanges(): void { + // @TODO implement amendments + this.allChangingObjects = this.changeRecommendations; + this.allChangingObjects.sort((a: ViewUnifiedChange, b: ViewUnifiedChange) => { + if (a.getLineFrom() < b.getLineFrom()) { + return -1; + } else if (a.getLineFrom() > b.getLineFrom()) { + return 1; + } else { + return 0; + } + }); + } + /** * Async load the values of the motion in the Form. */ @@ -216,15 +270,25 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { /** * get the formated motion text from the repository. */ - public getFormattedText(): SafeHtml { + public getFormattedTextPlain(): string { + // Prevent this.allChangingObjects to be reordered from within formatMotion + const changes: ViewUnifiedChange[] = Object.assign([], this.allChangingObjects); return this.repo.formatMotion( this.motion.id, this.motion.crMode, + changes, this.motion.lineLength, this.motion.highlightedLine ); } + /** + * get the formated motion text from the repository, as SafeHTML for [innerHTML] + */ + public getFormattedText(): SafeHtml { + return this.sanitizer.bypassSecurityTrustHtml(this.getFormattedTextPlain()); + } + /** * Click on the edit button (pen-symbol) */ @@ -270,7 +334,7 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { * Sets the motions line numbering mode * @param mode Needs to fot to the enum defined in ViewMotion */ - public setLineNumberingMode(mode: LineNumbering): void { + public setLineNumberingMode(mode: LineNumberingMode): void { this.motion.lnMode = mode; } @@ -278,21 +342,21 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { * Returns true if no line numbers are to be shown. */ public isLineNumberingNone(): boolean { - return this.motion.lnMode === LineNumbering.None; + return this.motion.lnMode === LineNumberingMode.None; } /** * Returns true if the line numbers are to be shown within the text with no line breaks. */ public isLineNumberingInline(): boolean { - return this.motion.lnMode === LineNumbering.Inside; + return this.motion.lnMode === LineNumberingMode.Inside; } /** * Returns true if the line numbers are to be shown to the left of the text. */ public isLineNumberingOutside(): boolean { - return this.motion.lnMode === LineNumbering.Outside; + return this.motion.lnMode === LineNumberingMode.Outside; } /** @@ -303,6 +367,48 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { this.motion.crMode = mode; } + /** + * Returns true if the original version (including change recommendation annotation) is to be shown + */ + public isRecoModeOriginal(): boolean { + return this.motion.crMode === ChangeRecoMode.Original; + } + + /** + * Returns true if the diff version is to be shown + */ + public isRecoModeDiff(): boolean { + return this.motion.crMode === ChangeRecoMode.Diff; + } + + /** + * In the original version, a line number range has been selected in order to create a new change recommendation + * + * @param lineRange + */ + public createChangeRecommendation(lineRange: LineRange): void { + const data: MotionChangeRecommendationComponentData = { + editChangeRecommendation: false, + newChangeRecommendation: true, + lineRange: lineRange, + changeRecommendation: this.repo.createChangeRecommendationTemplate(this.motion.id, lineRange) + }; + this.dialogService.open(MotionChangeRecommendationComponent, { + height: '400px', + width: '600px', + data: data + }); + } + + /** + * In the original version, a change-recommendation-annotation has been clicked + * -> Go to the diff view and scroll to the change recommendation + */ + public gotoChangeRecommendation(changeRecommendation: ViewChangeReco): void { + this.scrollToChange = changeRecommendation; + this.setChangeRecoMode(ChangeRecoMode.Diff); + } + /** * Init. Does nothing here. */ diff --git a/client/src/app/site/motions/models/view-change-reco.ts b/client/src/app/site/motions/models/view-change-reco.ts new file mode 100644 index 000000000..a0f5ab9cc --- /dev/null +++ b/client/src/app/site/motions/models/view-change-reco.ts @@ -0,0 +1,100 @@ +import { BaseViewModel } from '../../base/base-view-model'; +import { MotionChangeReco } from '../../../shared/models/motions/motion-change-reco'; +import { BaseModel } from '../../../shared/models/base/base-model'; +import { ModificationType } from '../services/diff.service'; +import { ViewUnifiedChange, ViewUnifiedChangeType } from './view-unified-change'; + +/** + * Change recommendation class for the View + * + * Stores a motion including all (implicit) references + * Provides "safe" access to variables and functions in {@link MotionChangeReco} + * @ignore + */ +export class ViewChangeReco extends BaseViewModel implements ViewUnifiedChange { + private _changeReco: MotionChangeReco; + + public get id(): number { + return this._changeReco ? this._changeReco.id : null; + } + + public get changeRecommendation(): MotionChangeReco { + return this._changeReco; + } + + public constructor(changeReco?: MotionChangeReco) { + super(); + + this._changeReco = changeReco; + } + + public getTitle(): string { + return this._changeReco.getTitle(); + } + + public updateValues(update: BaseModel): void { + // @TODO Is there any need for this function? + } + + public updateChangeReco(type: number, text: string): void { + // @TODO HTML sanitazion + this._changeReco.type = type; + this._changeReco.text = text; + } + + public get rejected(): boolean { + return this._changeReco ? this._changeReco.rejected : null; + } + + public get type(): number { + return this._changeReco ? this._changeReco.type : ModificationType.TYPE_REPLACEMENT; + } + + public get other_description(): string { + return this._changeReco ? this._changeReco.other_description : null; + } + + public get line_from(): number { + return this._changeReco ? this._changeReco.line_from : null; + } + + public get line_to(): number { + return this._changeReco ? this._changeReco.line_to : null; + } + + public get text(): string { + return this._changeReco ? this._changeReco.text : null; + } + + public get motion_id(): number { + return this._changeReco ? this._changeReco.motion_id : null; + } + + public getChangeId(): string { + return 'recommendation-' + this.id.toString(10); + } + + public getChangeType(): ViewUnifiedChangeType { + return ViewUnifiedChangeType.TYPE_CHANGE_RECOMMENDATION; + } + + public getLineFrom(): number { + return this.line_from; + } + + public getLineTo(): number { + return this.line_to; + } + + public getChangeNewText(): string { + return this.text; + } + + public isAccepted(): boolean { + return !this.rejected; + } + + public isRejected(): boolean { + return this.rejected; + } +} diff --git a/client/src/app/site/motions/models/view-motion.ts b/client/src/app/site/motions/models/view-motion.ts index a765af708..9efe71253 100644 --- a/client/src/app/site/motions/models/view-motion.ts +++ b/client/src/app/site/motions/models/view-motion.ts @@ -6,15 +6,15 @@ import { WorkflowState } from '../../../shared/models/motions/workflow-state'; import { BaseModel } from '../../../shared/models/base/base-model'; import { BaseViewModel } from '../../base/base-view-model'; -export enum LineNumbering { +export enum LineNumberingMode { None, Inside, Outside } -enum ChangeReco { +export enum ChangeRecoMode { Original, - Change, + Changed, Diff, Final } @@ -35,16 +35,16 @@ export class ViewMotion extends BaseViewModel { private _state: WorkflowState; /** - * Indicates the LineNumbering Mode. + * Indicates the LineNumberingMode Mode. * Needs to be accessed from outside */ - public lnMode: LineNumbering; + public lnMode: LineNumberingMode; /** * Indicates the Change reco Mode. * Needs to be accessed from outside */ - public crMode: ChangeReco; + public crMode: ChangeRecoMode; /** * Indicates the maximum line length as defined in the configuration. @@ -189,8 +189,8 @@ export class ViewMotion extends BaseViewModel { this._state = state; // TODO: Should be set using a a config variable - this.lnMode = LineNumbering.None; - this.crMode = ChangeReco.Original; + this.lnMode = LineNumberingMode.Outside; + this.crMode = ChangeRecoMode.Original; this.lineLength = 80; this.highlightedLine = null; diff --git a/client/src/app/site/motions/models/view-unified-change.ts b/client/src/app/site/motions/models/view-unified-change.ts new file mode 100644 index 000000000..2875b3b0f --- /dev/null +++ b/client/src/app/site/motions/models/view-unified-change.ts @@ -0,0 +1,46 @@ +export enum ViewUnifiedChangeType { + TYPE_CHANGE_RECOMMENDATION, + TYPE_AMENDMENT +} + +/** + * A common interface for (paragraph-based) amendments and change recommendations. + * Needed to merge both types of change objects in the motion content at the same time + */ +export interface ViewUnifiedChange { + /** + * Returns the type of change + */ + getChangeType(): ViewUnifiedChangeType; + + /** + * An id that is unique considering both change recommendations and amendments, therefore needs to be + * "namespaced" (e.g. "amendment.23" or "recommendation.42") + */ + getChangeId(): string; + + /** + * First line number of the change + */ + getLineFrom(): number; + + /** + * Last line number of the change (the line number marking the end of the change - not the number of the last line) + */ + getLineTo(): number; + + /** + * Returns the new version of the text, as it would be if this change was to be adopted. + */ + getChangeNewText(): string; + + /** + * True, if accepted. False, if rejected or undecided. + */ + isAccepted(): boolean; + + /** + * True, if rejected. False, if accepted or undecided. + */ + isRejected(): boolean; +} diff --git a/client/src/app/site/motions/motions.module.ts b/client/src/app/site/motions/motions.module.ts index 67319fcf4..fa15aead1 100644 --- a/client/src/app/site/motions/motions.module.ts +++ b/client/src/app/site/motions/motions.module.ts @@ -8,6 +8,9 @@ import { MotionDetailComponent } from './components/motion-detail/motion-detail. import { CategoryListComponent } from './components/category-list/category-list.component'; import { MotionCommentSectionListComponent } from './components/motion-comment-section-list/motion-comment-section-list.component'; import { StatuteParagraphListComponent } from './components/statute-paragraph-list/statute-paragraph-list.component'; +import { MotionChangeRecommendationComponent } from './components/motion-change-recommendation/motion-change-recommendation.component'; +import { MotionDetailOriginalChangeRecommendationsComponent } from './components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component'; +import { MotionDetailDiffComponent } from './components/motion-detail-diff/motion-detail-diff.component'; @NgModule({ imports: [CommonModule, MotionsRoutingModule, SharedModule], @@ -16,7 +19,12 @@ import { StatuteParagraphListComponent } from './components/statute-paragraph-li MotionDetailComponent, CategoryListComponent, MotionCommentSectionListComponent, - StatuteParagraphListComponent - ] + StatuteParagraphListComponent, + MotionChangeRecommendationComponent, + MotionCommentSectionListComponent, + MotionDetailOriginalChangeRecommendationsComponent, + MotionDetailDiffComponent + ], + entryComponents: [MotionChangeRecommendationComponent] }) export class MotionsModule {} diff --git a/client/src/app/site/motions/services/change-recommendation-repository.service.spec.ts b/client/src/app/site/motions/services/change-recommendation-repository.service.spec.ts new file mode 100644 index 000000000..2bb662d6f --- /dev/null +++ b/client/src/app/site/motions/services/change-recommendation-repository.service.spec.ts @@ -0,0 +1,20 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { ChangeRecommendationRepositoryService } from './change-recommendation-repository.service'; +import { E2EImportsModule } from '../../../../e2e-imports.module'; + +describe('ChangeRecommendationRepositoryService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [ChangeRecommendationRepositoryService] + }); + }); + + it('should be created', inject( + [ChangeRecommendationRepositoryService], + (service: ChangeRecommendationRepositoryService) => { + expect(service).toBeTruthy(); + } + )); +}); diff --git a/client/src/app/site/motions/services/change-recommendation-repository.service.ts b/client/src/app/site/motions/services/change-recommendation-repository.service.ts new file mode 100644 index 000000000..e71932a12 --- /dev/null +++ b/client/src/app/site/motions/services/change-recommendation-repository.service.ts @@ -0,0 +1,135 @@ +import { Injectable } from '@angular/core'; + +import { DataSendService } from '../../../core/services/data-send.service'; +import { User } from '../../../shared/models/users/user'; +import { Category } from '../../../shared/models/motions/category'; +import { Workflow } from '../../../shared/models/motions/workflow'; +import { Observable } from 'rxjs'; +import { BaseRepository } from '../../base/base-repository'; +import { DataStoreService } from '../../../core/services/data-store.service'; +import { MotionChangeReco } from '../../../shared/models/motions/motion-change-reco'; +import { ViewChangeReco } from '../models/view-change-reco'; +import { map } from 'rxjs/operators'; + +/** + * Repository Services for change recommendations + * + * The repository is meant to process domain objects (those found under + * shared/models), so components can display them and interact with them. + * + * Rather than manipulating models directly, the repository is meant to + * inform the {@link DataSendService} about changes which will send + * them to the Server. + */ +@Injectable({ + providedIn: 'root' +}) +export class ChangeRecommendationRepositoryService extends BaseRepository { + /** + * Creates a MotionRepository + * + * Converts existing and incoming motions to ViewMotions + * Handles CRUD using an observer to the DataStore + * @param DS + * @param dataSend + */ + public constructor(DS: DataStoreService, private dataSend: DataSendService) { + super(DS, MotionChangeReco, [Category, User, Workflow]); + } + + /** + * Creates a change recommendation + * Creates a (real) change recommendation and delegates it to the {@link DataSendService} + * + * @param {MotionChangeReco} changeReco + */ + public create(changeReco: MotionChangeReco): Observable { + return this.dataSend.createModel(changeReco) as Observable; + } + + /** + * Given a change recommendation view object, a entry in the backend is created and the new + * change recommendation view object is returned (as an observable). + * + * @param {ViewChangeReco} view + */ + public createByViewModel(view: ViewChangeReco): Observable { + return this.create(view.changeRecommendation).pipe( + map((changeReco: MotionChangeReco) => { + return new ViewChangeReco(changeReco); + }) + ); + } + + /** + * Creates this view wrapper based on an actual Change Recommendation model + * + * @param {MotionChangeReco} model + */ + protected createViewModel(model: MotionChangeReco): ViewChangeReco { + return new ViewChangeReco(model); + } + + /** + * Deleting a change recommendation. + * + * Extract the change recommendation out of the viewModel and delegate + * to {@link DataSendService} + * @param {ViewChangeReco} viewModel + */ + public delete(viewModel: ViewChangeReco): Observable { + return this.dataSend.delete(viewModel.changeRecommendation) as Observable; + } + + /** + * updates a change recommendation + * + * Updates a (real) change recommendation with patched data and delegate it + * to the {@link DataSendService} + * + * @param {Partial} update the form data containing the update values + * @param {ViewChangeReco} viewModel The View Change Recommendation. If not present, a new motion will be created + */ + public update(update: Partial, viewModel: ViewChangeReco): Observable { + const changeReco = viewModel.changeRecommendation; + changeReco.patchValues(update); + return this.dataSend.updateModel(changeReco, 'patch') as Observable; + } + + /** + * return the Observable of all change recommendations belonging to the given motion + */ + public getChangeRecosOfMotionObservable(motion_id: number): Observable { + return this.viewModelListSubject.asObservable().pipe( + map((recos: ViewChangeReco[]) => { + return recos.filter(reco => reco.motion_id === motion_id); + }) + ); + } + + /** + * Sets a change recommendation to accepted. + * + * @param {ViewChangeReco} change + */ + public setAccepted(change: ViewChangeReco): Observable { + const changeReco = change.changeRecommendation; + changeReco.patchValues({ + rejected: false + }); + return this.dataSend.updateModel(changeReco, 'patch') as Observable; + } + + /** + * Sets a change recommendation to rejected. + * + * @param {ViewChangeReco} change + */ + public setRejected(change: ViewChangeReco): Observable { + const changeReco = change.changeRecommendation; + changeReco.patchValues({ + rejected: true + }); + return this.dataSend.updateModel(changeReco, 'patch') as Observable; + } +} diff --git a/client/src/app/site/motions/services/diff.service.ts b/client/src/app/site/motions/services/diff.service.ts index 0a034beaf..39a2ae4ea 100644 --- a/client/src/app/site/motions/services/diff.service.ts +++ b/client/src/app/site/motions/services/diff.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@angular/core'; import { LinenumberingService } from './linenumbering.service'; +import { ViewMotion } from '../models/view-motion'; +import { ViewUnifiedChange } from '../models/view-unified-change'; const ELEMENT_NODE = 1; const TEXT_NODE = 3; @@ -99,7 +101,7 @@ interface ExtractedContent { /** * An object specifying a range of line numbers. */ -interface LineRange { +export interface LineRange { /** * The first line number to be included. */ @@ -173,10 +175,16 @@ interface LineRange { providedIn: 'root' }) export class DiffService { + // @TODO Decide on a more sophisticated implementation private diffCache = { - get: (key: string) => undefined, - put: (key: string, val: any) => undefined - }; // @TODO + _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; + } + }; /** * Creates the DiffService. @@ -1983,4 +1991,41 @@ export class DiffService { this.diffCache.put(cacheKey, diff); return diff; } + + /** + * 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; + } } diff --git a/client/src/app/site/motions/services/linenumbering.service.ts b/client/src/app/site/motions/services/linenumbering.service.ts index 9e79dc613..d5af49ade 100644 --- a/client/src/app/site/motions/services/linenumbering.service.ts +++ b/client/src/app/site/motions/services/linenumbering.service.ts @@ -90,14 +90,19 @@ interface SectionHeading { }) export class LinenumberingService { /** - * @TODO + * @TODO Decide on a more sophisticated implementation * 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 + _cache: {}, + get: (key: string): any => { + return this.lineNumberCache._cache[key] === undefined ? null : this.lineNumberCache._cache[key]; + }, + put: (key: string, val: any): void => { + this.lineNumberCache._cache[key] = val; + } }; // Counts the number of characters in the current line, beyond singe nodes. diff --git a/client/src/app/site/motions/services/motion-repository.service.ts b/client/src/app/site/motions/services/motion-repository.service.ts index c502c1e9d..4bc0e07dc 100644 --- a/client/src/app/site/motions/services/motion-repository.service.ts +++ b/client/src/app/site/motions/services/motion-repository.service.ts @@ -6,12 +6,15 @@ import { User } from '../../../shared/models/users/user'; import { Category } from '../../../shared/models/motions/category'; import { Workflow } from '../../../shared/models/motions/workflow'; import { WorkflowState } from '../../../shared/models/motions/workflow-state'; -import { ViewMotion } from '../models/view-motion'; +import { ChangeRecoMode, ViewMotion } from '../models/view-motion'; import { Observable } from 'rxjs'; import { BaseRepository } from '../../base/base-repository'; import { DataStoreService } from '../../../core/services/data-store.service'; import { LinenumberingService } from './linenumbering.service'; -import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { DiffService, LineRange, ModificationType } from './diff.service'; +import { ViewChangeReco } from '../models/view-change-reco'; +import { MotionChangeReco } from '../../../shared/models/motions/motion-change-reco'; +import { ViewUnifiedChange } from '../models/view-unified-change'; /** * Repository Services for motions (and potentially categories) @@ -32,15 +35,16 @@ export class MotionRepositoryService extends BaseRepository * * Converts existing and incoming motions to ViewMotions * Handles CRUD using an observer to the DataStore - * @param DS - * @param dataSend - * @param lineNumbering + * @param {DataStoreService} DS + * @param {DataSendService} dataSend + * @param {LinenumberingService} lineNumbering + * @param {DiffService} diff */ public constructor( DS: DataStoreService, private dataSend: DataSendService, private readonly lineNumbering: LinenumberingService, - private readonly sanitizer: DomSanitizer + private readonly diff: DiffService ) { super(DS, Motion, [Category, User, Workflow]); } @@ -111,41 +115,209 @@ export class MotionRepositoryService extends BaseRepository * Format the motion text using the line numbering and change * reco algorithm. * - * TODO: Call DiffView Service here. - * * Can be called from detail view and exporter * @param id Motion ID - will be pulled from the repository * @param crMode indicator for the change reco mode + * @param changes all change recommendations and amendments, sorted by line number * @param lineLength the current line * @param highlightLine the currently highlighted line (default: none) */ - public formatMotion(id: number, crMode: number, lineLength: number, highlightLine?: number): SafeHtml { + public formatMotion( + id: number, + crMode: ChangeRecoMode, + changes: ViewUnifiedChange[], + lineLength: number, + highlightLine?: number + ): string { const targetMotion = this.getViewModel(id); if (targetMotion && targetMotion.text) { - let motionText = targetMotion.text; - motionText = this.lineNumbering.insertLineNumbers(motionText, lineLength, highlightLine); - - // TODO : Use Diff Service here. - // this will(currently) append the previous changes. - // update switch (crMode) { - case 0: // Original - break; - case 1: // Changed Version - motionText += ' and get changed version'; - break; - case 2: // Diff Version - motionText += ' and get diff version'; - break; - case 3: // Final Version - motionText += ' and final version'; - break; + case ChangeRecoMode.Original: + return this.lineNumbering.insertLineNumbers(targetMotion.text, lineLength, highlightLine); + case ChangeRecoMode.Changed: + return this.diff.getTextWithChanges(targetMotion, changes, lineLength, highlightLine); + case ChangeRecoMode.Diff: + let text = ''; + changes.forEach((change: ViewUnifiedChange, idx: number) => { + if (idx === 0) { + text += this.extractMotionLineRange( + id, + { + from: 1, + to: change.getLineFrom() + }, + true + ); + } else if (changes[idx - 1].getLineTo() < change.getLineFrom()) { + text += this.extractMotionLineRange( + id, + { + from: changes[idx - 1].getLineTo(), + to: change.getLineFrom() + }, + true + ); + } + text += this.getChangeDiff(targetMotion, change, highlightLine); + }); + text += this.getTextRemainderAfterLastChange(targetMotion, changes, highlightLine); + return text; + case ChangeRecoMode.Final: + const appliedChanges: ViewUnifiedChange[] = changes.filter(change => change.isAccepted()); + return this.diff.getTextWithChanges(targetMotion, appliedChanges, lineLength, highlightLine); + default: + console.error('unrecognized ChangeRecoMode option'); + return null; } - - return this.sanitizer.bypassSecurityTrustHtml(motionText); } else { return null; } } + + /** + * Extracts a renderable HTML string representing the given line number range of this motion + * + * @param {number} id + * @param {LineRange} lineRange + * @param {boolean} lineNumbers - weather to add line numbers to the returned HTML string + */ + public extractMotionLineRange(id: number, lineRange: LineRange, lineNumbers: boolean): string { + // @TODO flexible line numbers + const origHtml = this.formatMotion(id, ChangeRecoMode.Original, [], 80); + const extracted = this.diff.extractRangeByLineNumbers(origHtml, lineRange.from, lineRange.to); + let html = + extracted.outerContextStart + + extracted.innerContextStart + + extracted.html + + extracted.innerContextEnd + + extracted.outerContextEnd; + if (lineNumbers) { + html = this.lineNumbering.insertLineNumbers(html, 80, null, null, lineRange.from); + } + return html; + } + + /** + * Returns the remainder text of the motion after the last change + * + * @param {ViewMotion} motion + * @param {ViewUnifiedChange[]} changes + * @param {number} highlight + */ + public getTextRemainderAfterLastChange( + motion: ViewMotion, + changes: ViewUnifiedChange[], + highlight?: number + ): string { + let maxLine = 0; + changes.forEach((change: ViewUnifiedChange) => { + if (change.getLineTo() > maxLine) { + maxLine = change.getLineTo(); + } + }, 0); + + const numberedHtml = this.lineNumbering.insertLineNumbers(motion.text, motion.lineLength); + let data; + + try { + data = this.diff.extractRangeByLineNumbers(numberedHtml, maxLine, null); + } catch (e) { + // This only happens (as far as we know) when the motion text has been altered (shortened) + // without modifying the change recommendations accordingly. + // That's a pretty serious inconsistency that should not happen at all, + // we're just doing some basic damage control here. + const msg = + 'Inconsistent data. A change recommendation is probably referring to a non-existant line number.'; + return '' + msg + ''; + } + + let html; + if (data.html !== '') { + // Add "merge-before"-css-class if the first line begins in the middle of a paragraph. Used for PDF. + html = + this.diff.addCSSClassToFirstTag(data.outerContextStart + data.innerContextStart, 'merge-before') + + data.html + + data.innerContextEnd + + data.outerContextEnd; + html = this.lineNumbering.insertLineNumbers(html, motion.lineLength, highlight, null, maxLine); + } else { + // Prevents empty lines at the end of the motion + html = ''; + } + return html; + } + + /** + * Creates a {@link ViewChangeReco} object based on the motion ID and the given lange range. + * This object is not saved yet and does not yet have any changed HTML. It's meant to populate the UI form. + * + * @param {number} motionId + * @param {LineRange} lineRange + */ + public createChangeRecommendationTemplate(motionId: number, lineRange: LineRange): ViewChangeReco { + const changeReco = new MotionChangeReco(); + changeReco.line_from = lineRange.from; + changeReco.line_to = lineRange.to; + changeReco.type = ModificationType.TYPE_REPLACEMENT; + changeReco.text = this.extractMotionLineRange(motionId, lineRange, false); + changeReco.rejected = false; + changeReco.motion_id = motionId; + + return new ViewChangeReco(changeReco); + } + + /** + * Returns the HTML with the changes, optionally with a highlighted line. + * The original motion needs to be provided. + * + * @param {ViewMotion} motion + * @param {ViewUnifiedChange} change + * @param {number} highlight + */ + public getChangeDiff(motion: ViewMotion, change: ViewUnifiedChange, highlight?: number): string { + const lineLength = motion.lineLength, + html = this.lineNumbering.insertLineNumbers(motion.text, lineLength); + + let data, oldText; + + try { + data = this.diff.extractRangeByLineNumbers(html, change.getLineFrom(), change.getLineTo()); + oldText = + data.outerContextStart + + data.innerContextStart + + data.html + + data.innerContextEnd + + data.outerContextEnd; + } catch (e) { + // This only happens (as far as we know) when the motion text has been altered (shortened) + // without modifying the change recommendations accordingly. + // That's a pretty serious inconsistency that should not happen at all, + // we're just doing some basic damage control here. + const msg = + 'Inconsistent data. A change recommendation is probably referring to a non-existant line number.'; + return '' + msg + ''; + } + + oldText = this.lineNumbering.insertLineNumbers(oldText, lineLength, null, null, change.getLineFrom()); + let diff = this.diff.diff(oldText, change.getChangeNewText()); + + // If an insertion makes the line longer than the line length limit, we need two line breaking runs: + // - First, for the official line numbers, ignoring insertions (that's been done some lines before) + // - Second, another one to prevent the displayed including insertions to exceed the page width + diff = this.lineNumbering.insertLineBreaksWithoutNumbers(diff, lineLength, true); + + if (highlight > 0) { + diff = this.lineNumbering.highlightLine(diff, highlight); + } + + const origBeginning = data.outerContextStart + data.innerContextStart; + if (diff.toLowerCase().indexOf(origBeginning.toLowerCase()) === 0) { + // Add "merge-before"-css-class if the first line begins in the middle of a paragraph. Used for PDF. + diff = + this.diff.addCSSClassToFirstTag(origBeginning, 'merge-before') + diff.substring(origBeginning.length); + } + + return diff; + } }