diff --git a/client/src/app/core/repositories/motions/motion-repository.service.ts b/client/src/app/core/repositories/motions/motion-repository.service.ts index 200dd174c..04c6ec434 100644 --- a/client/src/app/core/repositories/motions/motion-repository.service.ts +++ b/client/src/app/core/repositories/motions/motion-repository.service.ts @@ -27,7 +27,7 @@ import { TreeService } from 'app/core/ui-services/tree.service'; import { User } from 'app/shared/models/users/user'; import { ViewMotionChangeRecommendation } from 'app/site/motions/models/view-change-recommendation'; import { ViewMotionAmendedParagraph } from 'app/site/motions/models/view-motion-amended-paragraph'; -import { ViewUnifiedChange } from 'app/site/motions/models/view-unified-change'; +import { ViewUnifiedChange } from '../../../shared/models/motions/view-unified-change'; import { ViewStatuteParagraph } from 'app/site/motions/models/view-statute-paragraph'; import { Workflow } from 'app/shared/models/motions/workflow'; import { WorkflowState } from 'app/shared/models/motions/workflow-state'; @@ -397,7 +397,7 @@ export class MotionRepositoryService extends BaseRepository case ChangeRecoMode.Original: return this.lineNumbering.insertLineNumbers(targetMotion.text, lineLength, highlightLine); case ChangeRecoMode.Changed: - return this.diff.getTextWithChanges(targetMotion, changes, lineLength, highlightLine); + return this.diff.getTextWithChanges(targetMotion.text, changes, lineLength, highlightLine); case ChangeRecoMode.Diff: let text = ''; changes.forEach((change: ViewUnifiedChange, idx: number) => { @@ -424,13 +424,18 @@ export class MotionRepositoryService extends BaseRepository highlightLine ); } - text += this.getChangeDiff(targetMotion, change, lineLength, highlightLine); + text += this.diff.getChangeDiff(targetMotion.text, change, lineLength, highlightLine); }); - text += this.getTextRemainderAfterLastChange(targetMotion, changes, lineLength, highlightLine); + text += this.diff.getTextRemainderAfterLastChange( + targetMotion.text, + changes, + lineLength, + highlightLine + ); return text; case ChangeRecoMode.Final: const appliedChanges: ViewUnifiedChange[] = changes.filter(change => change.isAccepted()); - return this.diff.getTextWithChanges(targetMotion, appliedChanges, lineLength, highlightLine); + return this.diff.getTextWithChanges(targetMotion.text, appliedChanges, lineLength, highlightLine); case ChangeRecoMode.ModifiedFinal: if (targetMotion.modified_final_version) { return this.lineNumbering.insertLineNumbers( @@ -496,63 +501,6 @@ export class MotionRepositoryService extends BaseRepository return html; } - /** - * Returns the remainder text of the motion after the last change - * - * @param {ViewMotion} motion - * @param {ViewUnifiedChange[]} changes - * @param {number} lineLength - * @param {number} highlight - * @returns {string} - */ - public getTextRemainderAfterLastChange( - motion: ViewMotion, - changes: ViewUnifiedChange[], - lineLength: number, - highlight?: number - ): string { - let maxLine = 1; - changes.forEach((change: ViewUnifiedChange) => { - if (change.getLineTo() > maxLine) { - maxLine = change.getLineTo(); - } - }, 0); - - const numberedHtml = this.lineNumbering.insertLineNumbers(motion.text, lineLength); - if (changes.length === 0) { - return numberedHtml; - } - - 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, lineLength, highlight, null, maxLine); - } else { - // Prevents empty lines at the end of the motion - html = ''; - } - return html; - } - /** * Returns the last line number of a motion * @@ -567,7 +515,7 @@ export class MotionRepositoryService extends BaseRepository } /** - * Creates a {@link ViewChangeReco} object based on the motion ID and the given lange range. + * Creates a {@link ViewMotionChangeRecommendation} 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 @@ -590,66 +538,6 @@ export class MotionRepositoryService extends BaseRepository return new ViewMotionChangeRecommendation(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} lineLength - * @param {number} highlight - * @returns {string} - */ - public getChangeDiff( - motion: ViewMotion, - change: ViewUnifiedChange, - lineLength: number, - highlight?: number - ): string { - const 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; - } - /** * Given an amendment, this returns the motion affected by this amendments * diff --git a/client/src/app/core/ui-services/diff.service.ts b/client/src/app/core/ui-services/diff.service.ts index b42adeab3..c140a7cf3 100644 --- a/client/src/app/core/ui-services/diff.service.ts +++ b/client/src/app/core/ui-services/diff.service.ts @@ -1,8 +1,7 @@ import { Injectable } from '@angular/core'; import { LinenumberingService } from './linenumbering.service'; -import { ViewMotion } from '../../site/motions/models/view-motion'; -import { ViewUnifiedChange } from '../../site/motions/models/view-unified-change'; +import { ViewUnifiedChange } from '../../shared/models/motions/view-unified-change'; const ELEMENT_NODE = 1; const TEXT_NODE = 3; @@ -2036,18 +2035,18 @@ export class DiffService { /** * Applies all given changes to the motion and returns the (line-numbered) text * - * @param {ViewMotion} motion + * @param {string} motionHtml * @param {ViewUnifiedChange[]} changes * @param {number} lineLength * @param {number} highlightLine */ public getTextWithChanges( - motion: ViewMotion, + motionHtml: string, changes: ViewUnifiedChange[], lineLength: number, highlightLine: number ): string { - let html = motion.text; + let html = motionHtml; // Changes need to be applied from the bottom up, to prevent conflicts with changing line numbers. changes.sort((change1: ViewUnifiedChange, change2: ViewUnifiedChange) => { @@ -2127,4 +2126,120 @@ export class DiffService { textPost: textPost } as DiffLinesInParagraph; } + + /** + * Returns the HTML with the changes, optionally with a highlighted line. + * The original motion needs to be provided. + * + * @param {string} motionHtml + * @param {ViewUnifiedChange} change + * @param {number} lineLength + * @param {number} highlight + * @returns {string} + */ + public getChangeDiff( + motionHtml: string, + change: ViewUnifiedChange, + lineLength: number, + highlight?: number + ): string { + const html = this.lineNumberingService.insertLineNumbers(motionHtml, lineLength); + + let data, oldText; + + try { + data = this.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.lineNumberingService.insertLineNumbers(oldText, lineLength, null, null, change.getLineFrom()); + let diff = this.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.lineNumberingService.insertLineBreaksWithoutNumbers(diff, lineLength, true); + + if (highlight > 0) { + diff = this.lineNumberingService.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.addCSSClassToFirstTag(origBeginning, 'merge-before') + diff.substring(origBeginning.length); + } + + return diff; + } + + /** + * Returns the remainder text of the motion after the last change + * + * @param {string} motionHtml + * @param {ViewUnifiedChange[]} changes + * @param {number} lineLength + * @param {number} highlight + * @returns {string} + */ + public getTextRemainderAfterLastChange( + motionHtml: string, + changes: ViewUnifiedChange[], + lineLength: number, + highlight?: number + ): string { + let maxLine = 1; + changes.forEach((change: ViewUnifiedChange) => { + if (change.getLineTo() > maxLine) { + maxLine = change.getLineTo(); + } + }, 0); + + const numberedHtml = this.lineNumberingService.insertLineNumbers(motionHtml, lineLength); + if (changes.length === 0) { + return numberedHtml; + } + + let data; + + try { + data = this.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.addCSSClassToFirstTag(data.outerContextStart + data.innerContextStart, 'merge-before') + + data.html + + data.innerContextEnd + + data.outerContextEnd; + html = this.lineNumberingService.insertLineNumbers(html, lineLength, highlight, null, maxLine); + } else { + // Prevents empty lines at the end of the motion + html = ''; + } + return html; + } } diff --git a/client/src/app/site/motions/models/view-unified-change.ts b/client/src/app/shared/models/motions/view-unified-change.ts similarity index 100% rename from client/src/app/site/motions/models/view-unified-change.ts rename to client/src/app/shared/models/motions/view-unified-change.ts 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 index 0a5bc2eac..2c3a269ed 100644 --- 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 @@ -5,7 +5,7 @@ import { Component } from '@angular/core'; import { LineNumberingMode, ViewMotion } from '../../models/view-motion'; import { MotionDetailDiffComponent } from './motion-detail-diff.component'; import { MotionDetailOriginalChangeRecommendationsComponent } from '../motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component'; -import { ViewUnifiedChange } from '../../models/view-unified-change'; +import { ViewUnifiedChange } from '../../../../shared/models/motions/view-unified-change'; import { Motion } from 'app/shared/models/motions/motion'; import { ViewMotionChangeRecommendation } from '../../models/view-change-recommendation'; 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 index 63a958cb2..3dc014880 100644 --- 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 @@ -5,9 +5,9 @@ import { DomSanitizer, SafeHtml, Title } from '@angular/platform-browser'; import { TranslateService } from '@ngx-translate/core'; import { LineNumberingMode, ViewMotion } from '../../models/view-motion'; -import { ViewUnifiedChange, ViewUnifiedChangeType } from '../../models/view-unified-change'; +import { ViewUnifiedChange, ViewUnifiedChangeType } from '../../../../shared/models/motions/view-unified-change'; import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service'; -import { LineRange, ModificationType } from 'app/core/ui-services/diff.service'; +import { DiffService, LineRange, ModificationType } from 'app/core/ui-services/diff.service'; import { ViewMotionChangeRecommendation } from '../../models/view-change-recommendation'; import { ChangeRecommendationRepositoryService } from 'app/core/repositories/motions/change-recommendation-repository.service'; import { @@ -69,6 +69,7 @@ export class MotionDetailDiffComponent extends BaseViewComponent implements Afte * @param matSnackBar * @param sanitizer * @param motionRepo + * @param diff * @param recoRepo * @param dialogService * @param configService @@ -80,6 +81,7 @@ export class MotionDetailDiffComponent extends BaseViewComponent implements Afte matSnackBar: MatSnackBar, private sanitizer: DomSanitizer, private motionRepo: MotionRepositoryService, + private diff: DiffService, private recoRepo: ChangeRecommendationRepositoryService, private dialogService: MatDialog, private configService: ConfigService, @@ -142,7 +144,7 @@ export class MotionDetailDiffComponent extends BaseViewComponent implements Afte * @param {ViewUnifiedChange} change */ public getDiff(change: ViewUnifiedChange): SafeHtml { - const html = this.motionRepo.getChangeDiff(this.motion, change, this.lineLength, this.highlightedLine); + const html = this.diff.getChangeDiff(this.motion.text, change, this.lineLength, this.highlightedLine); return this.sanitizer.bypassSecurityTrustHtml(html); } @@ -153,8 +155,8 @@ export class MotionDetailDiffComponent extends BaseViewComponent implements Afte if (!this.lineLength) { return ''; // @TODO This happens in the test case when the lineLength-variable is not set } - return this.motionRepo.getTextRemainderAfterLastChange( - this.motion, + return this.diff.getTextRemainderAfterLastChange( + this.motion.text, this.changes, this.lineLength, this.highlightedLine 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 a75067bcc..eb35c9829 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 @@ -1,3 +1,5 @@ +@import '../../../../../assets/styles/motion-styles-common'; + span { margin: 0; } @@ -150,138 +152,6 @@ span { } } -/* 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, - .insert { - color: green; - text-decoration: underline; - } - - del, - .delete { - 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; - } - - &.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; - } - } - } - - &.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; - } - } - - .os-split-before { - margin-top: 0; - padding-top: 0; - } - - .os-split-after { - margin-bottom: 0; - padding-bottom: 0; - } - - li.os-split-before { - list-style: none; - } -} - -:host ::ng-deep .amendment-view { - .os-split-after { - margin-bottom: 0; - } - .os-split-before { - margin-top: 0; - } - .paragraphcontext { - opacity: 0.5; - } - &.amendment-context .paragraphcontext { - opacity: 1; - } -} - .main-nav-color { color: rgba(0, 0, 0, 0.54); } 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 c2b0527f7..82b34eaef 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 @@ -34,7 +34,7 @@ import { StatuteParagraphRepositoryService } from 'app/core/repositories/motions import { ViewMotionChangeRecommendation } from '../../models/view-change-recommendation'; import { ViewCreateMotion } from '../../models/view-create-motion'; import { ViewportService } from 'app/core/ui-services/viewport.service'; -import { ViewUnifiedChange } from '../../models/view-unified-change'; +import { ViewUnifiedChange } from '../../../../shared/models/motions/view-unified-change'; import { ViewStatuteParagraph } from '../../models/view-statute-paragraph'; import { Workflow } from 'app/shared/models/motions/workflow'; import { LinenumberingService } from 'app/core/ui-services/linenumbering.service'; @@ -343,6 +343,7 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { * @param promptService ensure safe deletion * @param pdfExport export the motion to pdf * @param personalNoteService: personal comments and favorite marker + * @param linenumberingService The line numbering service * @param categoryRepo * @param userRepo */ diff --git a/client/src/app/site/motions/models/view-change-recommendation.ts b/client/src/app/site/motions/models/view-change-recommendation.ts index 874881c58..fbb1a0c9a 100644 --- a/client/src/app/site/motions/models/view-change-recommendation.ts +++ b/client/src/app/site/motions/models/view-change-recommendation.ts @@ -1,13 +1,13 @@ import { BaseViewModel } from '../../base/base-view-model'; import { ModificationType } from 'app/core/ui-services/diff.service'; import { MotionChangeRecommendation } from 'app/shared/models/motions/motion-change-reco'; -import { ViewUnifiedChange, ViewUnifiedChangeType } from './view-unified-change'; +import { ViewUnifiedChange, ViewUnifiedChangeType } from '../../../shared/models/motions/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} + * Provides "safe" access to variables and functions in {@link MotionChangeRecommendation} * @ignore */ export class ViewMotionChangeRecommendation extends BaseViewModel implements ViewUnifiedChange { diff --git a/client/src/app/site/motions/models/view-motion-amended-paragraph.ts b/client/src/app/site/motions/models/view-motion-amended-paragraph.ts index 483787693..43199abac 100644 --- a/client/src/app/site/motions/models/view-motion-amended-paragraph.ts +++ b/client/src/app/site/motions/models/view-motion-amended-paragraph.ts @@ -1,4 +1,4 @@ -import { ViewUnifiedChange, ViewUnifiedChangeType } from './view-unified-change'; +import { ViewUnifiedChange, ViewUnifiedChangeType } from '../../../shared/models/motions/view-unified-change'; import { ViewMotion } from './view-motion'; import { LineRange } from 'app/core/ui-services/diff.service'; import { MergeAmendment } from 'app/shared/models/motions/workflow-state'; @@ -41,6 +41,8 @@ export class ViewMotionAmendedParagraph implements ViewUnifiedChange { * The state and recommendation of this amendment is considered. * The state takes precedence. * + * HINT: This implementation should be consistent with get_amendment_merge_into_motion() in projector.py + * * @returns {boolean} */ public isAccepted(): boolean { diff --git a/client/src/app/site/motions/services/motion-pdf.service.ts b/client/src/app/site/motions/services/motion-pdf.service.ts index 7b7a82795..b39d388a3 100644 --- a/client/src/app/site/motions/services/motion-pdf.service.ts +++ b/client/src/app/site/motions/services/motion-pdf.service.ts @@ -4,6 +4,7 @@ import { TranslateService } from '@ngx-translate/core'; import { ChangeRecommendationRepositoryService } from 'app/core/repositories/motions/change-recommendation-repository.service'; import { ConfigService } from 'app/core/ui-services/config.service'; +import { ViewUnifiedChange } from '../../../shared/models/motions/view-unified-change'; import { HtmlToPdfService } from 'app/core/ui-services/html-to-pdf.service'; import { MotionPollService, CalculablePollKey } from './motion-poll.service'; import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service'; diff --git a/client/src/app/slides/motions/motion/motions-motion-slide-data.ts b/client/src/app/slides/motions/motion/motions-motion-slide-data.ts index 75b2f650e..5838b0cad 100644 --- a/client/src/app/slides/motions/motion/motions-motion-slide-data.ts +++ b/client/src/app/slides/motions/motion/motions-motion-slide-data.ts @@ -1,6 +1,62 @@ +import { ChangeRecoMode, LineNumberingMode } from '../../../site/motions/models/view-motion'; +import { MergeAmendment } from '../../../shared/models/motions/workflow-state'; + +/** + * This interface describes the data returned by the server about an amendment. + * This object is used if actually the motion is shown and the amendment is shown in the context of the motion. + */ +export interface MotionsMotionSlideDataAmendment { + id: number; + title: string; + amendment_paragraphs: string[]; + merge_amendment_into_final: MergeAmendment; +} + +/** + * This interface describes the data returned by the server about a motion that is changed by an amendment. + * It only contains the data necessary for rendering the amendment's diff. + */ +export interface MotionsMotionSlideDataBaseMotion { + identifier: string; + title: string; + text: string; +} + +/** + * This interface describes the data returned by the server about a statute paragraph that is changed by an amendment. + * It only contains the data necessary for rendering the amendment's diff. + */ +export interface MotionsMotionSlideDataBaseStatute { + title: string; + text: string; +} + +/** + * This interface describes the data returned by the server about a change recommendation. + */ +export interface MotionsMotionSlideDataChangeReco { + creation_time: string; + id: number; + internal: boolean; + line_from: number; + line_to: number; + motion_id: number; + other_description: string; + rejected: false; + text: string; + type: number; +} + +/** + * Hint: defined on server-side in the file /openslides/motions/projector.py + * + * This interface describes either an motion (with all amendments and change recommendations enbedded) + * or an amendment (with the bas motion embedded). + */ export interface MotionsMotionSlideData { identifier: string; title: string; + preamble: string; text: string; reason?: string; is_child: boolean; @@ -9,7 +65,13 @@ export interface MotionsMotionSlideData { recommender?: string; recommendation?: string; recommendation_extension?: string; - amendment_paragraphs: { paragraph: string }[]; - change_recommendations: object[]; + base_motion?: MotionsMotionSlideDataBaseMotion; + base_statute?: MotionsMotionSlideDataBaseStatute; + amendment_paragraphs: string[]; + change_recommendations: MotionsMotionSlideDataChangeReco[]; + amendments: MotionsMotionSlideDataAmendment[]; modified_final_version?: string; + line_length: number; + line_numbering_mode: LineNumberingMode; + change_recommendation_mode: ChangeRecoMode; } diff --git a/client/src/app/slides/motions/motion/motions-motion-slide-obj-amendment-paragraph.ts b/client/src/app/slides/motions/motion/motions-motion-slide-obj-amendment-paragraph.ts new file mode 100644 index 000000000..eda5acdd7 --- /dev/null +++ b/client/src/app/slides/motions/motion/motions-motion-slide-obj-amendment-paragraph.ts @@ -0,0 +1,52 @@ +import { ViewUnifiedChange, ViewUnifiedChangeType } from '../../../shared/models/motions/view-unified-change'; +import { MotionsMotionSlideDataAmendment } from './motions-motion-slide-data'; +import { MergeAmendment } from '../../../shared/models/motions/workflow-state'; +import { LineRange } from '../../../core/ui-services/diff.service'; + +/** + * This class adds methods to the MotionsMotionSlideDataChangeReco data object + * necessary for use it as a UnifiedChange in the Diff-Functions + */ +export class MotionsMotionSlideObjAmendmentParagraph implements ViewUnifiedChange { + public id: number; + public type: number; + public merge_amendment_into_final: MergeAmendment; + + public constructor( + data: MotionsMotionSlideDataAmendment, + private paragraphNo: number, + private newText: string, + private lineRange: LineRange + ) { + this.id = data.id; + this.merge_amendment_into_final = data.merge_amendment_into_final; + } + + public getChangeId(): string { + return 'amendment-' + this.id.toString(10) + '-' + this.paragraphNo.toString(10); + } + + public getChangeType(): ViewUnifiedChangeType { + return ViewUnifiedChangeType.TYPE_AMENDMENT; + } + + public getChangeNewText(): string { + return this.newText; + } + + public getLineFrom(): number { + return this.lineRange.from; + } + + public getLineTo(): number { + return this.lineRange.to; + } + + public isAccepted(): boolean { + return this.merge_amendment_into_final === MergeAmendment.YES; + } + + public isRejected(): boolean { + return this.merge_amendment_into_final === MergeAmendment.NO; + } +} diff --git a/client/src/app/slides/motions/motion/motions-motion-slide-obj-change-reco.ts b/client/src/app/slides/motions/motion/motions-motion-slide-obj-change-reco.ts new file mode 100644 index 000000000..1247cd12f --- /dev/null +++ b/client/src/app/slides/motions/motion/motions-motion-slide-obj-change-reco.ts @@ -0,0 +1,51 @@ +import { ViewUnifiedChange, ViewUnifiedChangeType } from '../../../shared/models/motions/view-unified-change'; +import { MotionsMotionSlideDataChangeReco } from './motions-motion-slide-data'; + +/** + * This class adds methods to the MotionsMotionSlideDataChangeReco data object + * necessary for use it as a UnifiedChange in the Diff-Functions + */ +export class MotionsMotionSlideObjChangeReco implements MotionsMotionSlideDataChangeReco, ViewUnifiedChange { + public creation_time: string; + public id: number; + public internal: boolean; + public line_from: number; + public line_to: number; + public motion_id: number; + public other_description: string; + public rejected: false; + public text: string; + public type: number; + + public constructor(data: MotionsMotionSlideDataChangeReco) { + Object.assign(this, data); + } + + public getChangeId(): string { + return 'recommendation-' + this.id.toString(10); + } + + public getChangeNewText(): string { + return this.text; + } + + public getChangeType(): ViewUnifiedChangeType { + return ViewUnifiedChangeType.TYPE_CHANGE_RECOMMENDATION; + } + + public getLineFrom(): number { + return this.line_from; + } + + public getLineTo(): number { + return this.line_to; + } + + public isAccepted(): boolean { + return !this.rejected; + } + + public isRejected(): boolean { + return this.rejected; + } +} diff --git a/client/src/app/slides/motions/motion/motions-motion-slide.component.html b/client/src/app/slides/motions/motion/motions-motion-slide.component.html index 1b6ed9317..ec8e6532d 100644 --- a/client/src/app/slides/motions/motion/motions-motion-slide.component.html +++ b/client/src/app/slides/motions/motion/motions-motion-slide.component.html @@ -20,12 +20,52 @@

Motion {{ data.data.identifier }}

- -
+ + {{ preamble | translate }} + + + +
+
+
+
+ + +
-
+
+
+ No changes at the text. +
+
+

+ Line {{ paragraph.diffLineFrom }}: +

+

+ Line {{ paragraph.diffLineFrom }} - {{ paragraph.diffLineTo - 1 }}: +

+ +
+
+
+
+
diff --git a/client/src/app/slides/motions/motion/motions-motion-slide.component.scss b/client/src/app/slides/motions/motion/motions-motion-slide.component.scss index d7ca3f8c2..00e4d5d21 100644 --- a/client/src/app/slides/motions/motion/motions-motion-slide.component.scss +++ b/client/src/app/slides/motions/motion/motions-motion-slide.component.scss @@ -1,3 +1,9 @@ +@import '../../../../assets/styles/motion-styles-common'; + +::ng-deep .paragraph-context { + opacity: 0.5; +} + #sidebox { width: 260px; right: 0; diff --git a/client/src/app/slides/motions/motion/motions-motion-slide.component.ts b/client/src/app/slides/motions/motion/motions-motion-slide.component.ts index 080c4d6d6..d5e163b18 100644 --- a/client/src/app/slides/motions/motion/motions-motion-slide.component.ts +++ b/client/src/app/slides/motions/motion/motions-motion-slide.component.ts @@ -1,6 +1,14 @@ -import { Component } from '@angular/core'; +import { Component, Input } from '@angular/core'; import { BaseSlideComponent } from 'app/slides/base-slide-component'; -import { MotionsMotionSlideData } from './motions-motion-slide-data'; +import { MotionsMotionSlideData, MotionsMotionSlideDataAmendment } from './motions-motion-slide-data'; +import { ChangeRecoMode, LineNumberingMode } from '../../../site/motions/models/view-motion'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { DiffLinesInParagraph, DiffService, LineRange } from '../../../core/ui-services/diff.service'; +import { LinenumberingService } from '../../../core/ui-services/linenumbering.service'; +import { ViewUnifiedChange } from '../../../shared/models/motions/view-unified-change'; +import { MotionsMotionSlideObjChangeReco } from './motions-motion-slide-obj-change-reco'; +import { SlideData } from '../../../site/projector/services/projector-data.service'; +import { MotionsMotionSlideObjAmendmentParagraph } from './motions-motion-slide-obj-amendment-paragraph'; @Component({ selector: 'os-motions-motion-slide', @@ -8,7 +16,335 @@ import { MotionsMotionSlideData } from './motions-motion-slide-data'; styleUrls: ['./motions-motion-slide.component.scss'] }) export class MotionsMotionSlideComponent extends BaseSlideComponent { - public constructor() { + /** + * Indicates the LineNumberingMode Mode. + */ + public lnMode: LineNumberingMode; + + /** + * Indicates the Change reco Mode. + */ + public crMode: ChangeRecoMode; + + /** + * Indicates the maximum line length as defined in the configuration. + */ + public lineLength: number; + + /** + * Indicates the currently highlighted line, if any. + * @TODO Read value from the backend + */ + public highlightedLine: number; + + /** + * Value of the config variable `motions_preamble` + */ + public preamble: string; + + /** + * All change recommendations AND amendments, sorted by line number. + */ + public allChangingObjects: ViewUnifiedChange[]; + + private _data: SlideData; + + @Input() + public set data(value: SlideData) { + this._data = value; + this.lnMode = value.data.line_numbering_mode; + this.lineLength = value.data.line_length; + this.crMode = value.data.change_recommendation_mode; + this.preamble = value.data.preamble; + + this.recalcUnifiedChanges(); + } + + public get data(): SlideData { + return this._data; + } + + public constructor( + private sanitizer: DomSanitizer, + private lineNumbering: LinenumberingService, + private diff: DiffService + ) { super(); } + + /** + * Returns all paragraphs that are affected by the given amendment as unified change objects. + * + * @param {MotionsMotionSlideDataAmendment} amendment + * @returns {MotionsMotionSlideObjAmendmentParagraph[]} + */ + public getAmendmentAmendedParagraphs( + amendment: MotionsMotionSlideDataAmendment + ): MotionsMotionSlideObjAmendmentParagraph[] { + let baseHtml = this.data.data.text; + baseHtml = this.lineNumbering.insertLineNumbers(baseHtml, this.lineLength); + const baseParagraphs = this.lineNumbering.splitToParagraphs(baseHtml); + + return amendment.amendment_paragraphs + .map( + (newText: string, paraNo: number): MotionsMotionSlideObjAmendmentParagraph => { + if (newText === null) { + return null; + } + + const origText = baseParagraphs[paraNo], + paragraphLines = this.lineNumbering.getLineNumberRange(origText), + diff = this.diff.diff(origText, newText), + affectedLines = this.diff.detectAffectedLineRange(diff); + + if (affectedLines === null) { + return null; + } + + let newTextLines = this.lineNumbering.insertLineNumbers( + newText, + this.lineLength, + null, + null, + paragraphLines.from + ); + newTextLines = this.diff.formatDiff( + this.diff.extractRangeByLineNumbers(newTextLines, affectedLines.from, affectedLines.to) + ); + + return new MotionsMotionSlideObjAmendmentParagraph(amendment, paraNo, newTextLines, affectedLines); + } + ) + .filter((para: MotionsMotionSlideObjAmendmentParagraph) => para !== null); + } + + /** + * Merges amendments and change recommendations and sorts them by the line numbers. + * Called each time one of these arrays changes. + */ + private recalcUnifiedChanges(): void { + this.allChangingObjects = []; + + if (this.data.data.change_recommendations) { + this.data.data.change_recommendations.forEach(change => { + this.allChangingObjects.push(new MotionsMotionSlideObjChangeReco(change)); + }); + } + if (this.data.data.amendments) { + this.data.data.amendments.forEach(amendment => { + const paras = this.getAmendmentAmendedParagraphs(amendment); + paras.forEach(para => this.allChangingObjects.push(para)); + }); + } + 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; + } + }); + } + + /** + * Returns true, if this is a statute amendment + * + * @returns {boolean} + */ + public isStatuteAmendment(): boolean { + return !!this.data.data.base_statute; + } + + /** + * Returns true, if this is an paragraph-based amendment + * + * @returns {boolean} + */ + public isParagraphBasedAmendment(): boolean { + return ( + this.data.data.is_child && + this.data.data.amendment_paragraphs && + this.data.data.amendment_paragraphs.length > 0 + ); + } + + /** + * Returns true if no line numbers are to be shown. + * + * @returns whether there are line numbers at all + */ + public isLineNumberingNone(): boolean { + return this.lnMode === LineNumberingMode.None; + } + + /** + * Returns true if the line numbers are to be shown within the text with no line breaks. + * + * @returns whether the line numberings are inside + */ + public isLineNumberingInline(): boolean { + return this.lnMode === LineNumberingMode.Inside; + } + + /** + * Returns true if the line numbers are to be shown to the left of the text. + * + * @returns whether the line numberings are outside + */ + public isLineNumberingOutside(): boolean { + return this.lnMode === LineNumberingMode.Outside; + } + + /** + * Called from the template to make a HTML string compatible with [innerHTML] + * (otherwise line-number-data-attributes would be stripped out) + * + * @param {string} text + * @returns {SafeHtml} + */ + public sanitizedText(text: string): SafeHtml { + return this.sanitizer.bypassSecurityTrustHtml(text); + } + + /** + * Extracts a renderable HTML string representing the given line number range of this motion + * + * @param {string} motionHtml + * @param {LineRange} lineRange + * @param {boolean} lineNumbers - weather to add line numbers to the returned HTML string + * @param {number} lineLength + */ + public extractMotionLineRange( + motionHtml: string, + lineRange: LineRange, + lineNumbers: boolean, + lineLength: number + ): string { + const origHtml = this.lineNumbering.insertLineNumbers(motionHtml, this.lineLength, this.highlightedLine); + 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, lineLength, null, null, lineRange.from); + } + return html; + } + + /** + * get the formated motion text from the repository. + * + * @returns formated motion texts + */ + public getFormattedText(): string { + // Prevent this.allChangingObjects to be reordered from within formatMotion + // const changes: ViewUnifiedChange[] = Object.assign([], this.allChangingObjects); + const motion = this.data.data; + + switch (this.crMode) { + case ChangeRecoMode.Original: + return this.lineNumbering.insertLineNumbers(motion.text, this.lineLength, this.highlightedLine); + case ChangeRecoMode.Changed: + return this.diff.getTextWithChanges( + motion.text, + this.allChangingObjects, + this.lineLength, + this.highlightedLine + ); + case ChangeRecoMode.Diff: + let text = ''; + this.allChangingObjects.forEach((change: ViewUnifiedChange, idx: number) => { + if (idx === 0) { + const lineRange = { from: 1, to: change.getLineFrom() }; + text += this.extractMotionLineRange(motion.text, lineRange, true, this.lineLength); + } else if (this.allChangingObjects[idx - 1].getLineTo() < change.getLineFrom()) { + const lineRange = { + from: this.allChangingObjects[idx - 1].getLineTo(), + to: change.getLineFrom() + }; + text += this.extractMotionLineRange(motion.text, lineRange, true, this.lineLength); + } + text += this.diff.getChangeDiff(motion.text, change, this.lineLength, this.highlightedLine); + }); + text += this.diff.getTextRemainderAfterLastChange( + motion.text, + this.allChangingObjects, + this.lineLength, + this.highlightedLine + ); + return text; + case ChangeRecoMode.Final: + const appliedChanges: ViewUnifiedChange[] = this.allChangingObjects.filter(change => + change.isAccepted() + ); + return this.diff.getTextWithChanges(motion.text, appliedChanges, this.lineLength, this.highlightedLine); + case ChangeRecoMode.ModifiedFinal: + if (motion.modified_final_version) { + return this.lineNumbering.insertLineNumbers( + motion.modified_final_version, + this.lineLength, + this.highlightedLine, + null, + 1 + ); + } else { + // Use the final version as fallback, if the modified does not exist. + const appliedChangeObjects: ViewUnifiedChange[] = this.allChangingObjects.filter(change => + change.isAccepted() + ); + return this.diff.getTextWithChanges( + motion.text, + appliedChangeObjects, + this.lineLength, + this.highlightedLine + ); + } + default: + console.error('unrecognized ChangeRecoMode option (' + this.crMode + ')'); + return null; + } + } + + /** + * If `this.data.data` is an amendment, this returns the list of all changed paragraphs. + * + * @returns {DiffLinesInParagraph[]} + */ + public getAmendedParagraphs(): DiffLinesInParagraph[] { + let baseHtml = this.data.data.base_motion.text; + baseHtml = this.lineNumbering.insertLineNumbers(baseHtml, this.lineLength); + const baseParagraphs = this.lineNumbering.splitToParagraphs(baseHtml); + + return this.data.data.amendment_paragraphs + .map( + (newText: string, paraNo: number): DiffLinesInParagraph => { + if (newText === null) { + return null; + } + // Hint: can be either DiffLinesInParagraph or null, if no changes are made + return this.diff.getAmendmentParagraphsLinesByMode( + paraNo, + baseParagraphs[paraNo], + newText, + this.lineLength + ); + } + ) + .filter((para: DiffLinesInParagraph) => para !== null); + } + + /** + * get the diff html from the statute amendment, as SafeHTML for [innerHTML] + * + * @returns safe html strings + */ + public getFormattedStatuteAmendment(): SafeHtml { + let diffHtml = this.diff.diff(this.data.data.base_statute.text, this.data.data.text); + diffHtml = this.lineNumbering.insertLineBreaksWithoutNumbers(diffHtml, this.lineLength, true); + return this.sanitizer.bypassSecurityTrustHtml(diffHtml); + } } diff --git a/client/src/assets/styles/motion-styles-common.scss b/client/src/assets/styles/motion-styles-common.scss new file mode 100644 index 000000000..6b136ede3 --- /dev/null +++ b/client/src/assets/styles/motion-styles-common.scss @@ -0,0 +1,131 @@ +/* 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, + .insert { + color: green; + text-decoration: underline; + } + + del, + .delete { + 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; + } + + &.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; + } + } + } + + &.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; + } + } + + .os-split-before { + margin-top: 0; + padding-top: 0; + } + + .os-split-after { + margin-bottom: 0; + padding-bottom: 0; + } + + li.os-split-before { + list-style: none; + } +} + +:host ::ng-deep .amendment-view { + .os-split-after { + margin-bottom: 0; + } + .os-split-before { + margin-top: 0; + } + .paragraphcontext { + opacity: 0.5; + } + &.amendment-context .paragraphcontext { + opacity: 1; + } +} diff --git a/openslides/motions/projector.py b/openslides/motions/projector.py index f33b607dc..8184fe2fe 100644 --- a/openslides/motions/projector.py +++ b/openslides/motions/projector.py @@ -31,6 +31,64 @@ def get_state( f"motion {motion['id']} can not be on the state with id {state_id}" ) +def get_amendment_merge_into_motion(all_data, motion, amendment): + """ + HINT: This implementation should be consistent to isAccepted() in ViewMotionAmendedParagraph.ts + """ + if amendment["state_id"] is None: + return 0 + + state = get_state( + all_data, motion, amendment["state_id"] + ) + if state["merge_amendment_into_final"] == -1 or state["merge_amendment_into_final"] == 1: + return state["merge_amendment_into_final"] + + if amendment["recommendation_id"] is None: + return 0 + recommendation = get_state( + all_data, motion, amendment["recommendation_id"] + ) + return recommendation["merge_amendment_into_final"] + +def get_amendments_for_motion(motion, all_data): + amendment_data = [] + for amendment_id, amendment in all_data["motions/motion"].items(): + if amendment["parent_id"] == motion["id"]: + merge_amendment_into_final = get_amendment_merge_into_motion(all_data, motion, amendment) + amendment_data.append({ + "id": amendment["id"], + "identifier": amendment["identifier"], + "title": amendment["title"], + "amendment_paragraphs": amendment["amendment_paragraphs"], + "merge_amendment_into_final": merge_amendment_into_final, + }) + return amendment_data + +def get_amendment_base_motion(amendment, all_data): + try: + motion = all_data["motions/motion"][amendment["parent_id"]] + except KeyError: + motion_id = amendment["parent_id"] + raise ProjectorElementException(f"motion with id {motion_id} does not exist") + + return { + "identifier": motion["identifier"], + "title": motion["title"], + "text": motion["text"], + } + +def get_amendment_base_statute(amendment, all_data): + try: + statute = all_data["motions/statute-paragraph"][amendment["statute_paragraph_id"]] + except KeyError: + statute_id = amendment["statute_paragraph_id"] + raise ProjectorElementException(f"statute with id {statute_id} does not exist") + + return { + "title": statute["title"], + "text": statute["text"], + } def motion_slide(all_data: AllData, element: Dict[str, Any]) -> Dict[str, Any]: """ @@ -63,14 +121,43 @@ def motion_slide(all_data: AllData, element: Dict[str, Any]) -> Dict[str, Any]: raise ProjectorElementException(f"motion with id {motion_id} does not exist") show_meta_box = not get_config(all_data, "motions_disable_sidebox_on_projector") + line_length = get_config(all_data, "motions_line_length") + line_numbering_mode = get_config(all_data, "motions_default_line_numbering") + change_recommendation_mode = get_config(all_data, "motions_recommendation_text_mode") + motions_preamble = get_config(all_data, "motions_preamble") + + if motion["statute_paragraph_id"]: + print("statute") + change_recommendations = [] + amendments = [] + base_motion = None + base_statute = get_amendment_base_statute(motion, all_data) + elif bool(motion["parent_id"]) and motion["amendment_paragraphs"]: + change_recommendations = [] + amendments = [] + base_motion = get_amendment_base_motion(motion, all_data) + base_statute = None + else: + change_recommendations = list(filter(lambda reco: reco["internal"] == False, motion["change_recommendations"])) + amendments = get_amendments_for_motion(motion, all_data) + base_motion = None + base_statute = None return_value = { "identifier": motion["identifier"], "title": motion["title"], + "preamble": motions_preamble, "text": motion["text"], "amendment_paragraphs": motion["amendment_paragraphs"], + "base_motion": base_motion, + "base_statute": base_statute, "is_child": bool(motion["parent_id"]), "show_meta_box": show_meta_box, + "change_recommendations": change_recommendations, + "amendments": amendments, + "change_recommendation_mode": change_recommendation_mode, + "line_length": line_length, + "line_numbering_mode": line_numbering_mode, } if not get_config(all_data, "motions_disable_reason_on_projector"): @@ -98,7 +185,6 @@ def motion_slide(all_data: AllData, element: Dict[str, Any]) -> Dict[str, Any]: return_value["recommender"] = get_config( all_data, "motions_recommendations_by" ) - return_value["change_recommendations"] = motion["change_recommendations"] return_value["submitter"] = [ get_user_name(all_data, submitter["user_id"])