Initial support for line numbering and change recommendations in Projector

Read projector settings from the config
Preamble, styling fixes
Styling fixes
Show amendments inside of the motion view
Amendment view
Projector and statute paragraphs
Bugfix: Imports
This commit is contained in:
Tobias Hößl 2019-02-03 19:30:07 +01:00 committed by Emanuel Schütze
parent a3f9d46a6d
commit 453fedbc3e
18 changed files with 923 additions and 280 deletions

View File

@ -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<ViewMotion, Motion>
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<ViewMotion, Motion>
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<ViewMotion, Motion>
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 '<em style="color: red; font-weight: bold;">' + msg + '</em>';
}
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<ViewMotion, Motion>
}
/**
* 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<ViewMotion, Motion>
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 '<em style="color: red; font-weight: bold;">' + msg + '</em>';
}
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
*

View File

@ -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 '<em style="color: red; font-weight: bold;">' + msg + '</em>';
}
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 '<em style="color: red; font-weight: bold;">' + msg + '</em>';
}
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;
}
}

View File

@ -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';

View File

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

View File

@ -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,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" fill="%23337ab7"/><path d="M0 0h24v24H0z" fill="none"/></svg>');
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);
}

View File

@ -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
*/

View File

@ -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 {

View File

@ -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 {

View File

@ -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';

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -20,12 +20,52 @@
<h2><span translate>Motion</span> {{ data.data.identifier }}</h2>
</div>
<!-- Text (original) -->
<div *ngIf="!data.data.is_child" [innerHTML]="data.data.text"></div>
<!-- Text -->
<span class="text-prefix-label">{{ preamble | translate }}</span>
<!-- Regular motions or traditional amendments -->
<ng-container *ngIf="!isStatuteAmendment() && !isParagraphBasedAmendment()">
<div
class="motion-text"
[class.line-numbers-none]="isLineNumberingNone()"
[class.line-numbers-inline]="isLineNumberingInline()"
[class.line-numbers-outside]="isLineNumberingOutside()"
>
<div [innerHTML]="sanitizedText(getFormattedText())"></div>
</div>
</ng-container>
<!-- Statute amendments -->
<div
class="motion-text line-numbers-none"
*ngIf="isStatuteAmendment()"
[innerHTML]="getFormattedStatuteAmendment()"
></div>
<!-- Amendment text -->
<div *ngIf="data.data.is_child && data.data.amendment_paragraphs"
[innerHTML]="data.data.amendment_paragraphs[0]"></div>
<section class="text-holder" *ngIf="isParagraphBasedAmendment()">
<div class="alert alert-info" *ngIf="getAmendedParagraphs().length === 0">
<span translate>No changes at the text.</span>
</div>
<div
*ngFor="let paragraph of getAmendedParagraphs()"
class="motion-text motion-text-diff amendment-view"
[class.line-numbers-none]="isLineNumberingNone()"
[class.line-numbers-inline]="isLineNumberingInline()"
[class.line-numbers-outside]="isLineNumberingOutside()"
>
<h3 *ngIf="paragraph.diffLineTo === paragraph.diffLineFrom + 1" class="amendment-line-header">
<span translate>Line</span> {{ paragraph.diffLineFrom }}:
</h3>
<h3 *ngIf="paragraph.diffLineTo !== paragraph.diffLineFrom + 1" class="amendment-line-header">
<span translate>Line</span> {{ paragraph.diffLineFrom }} - {{ paragraph.diffLineTo - 1 }}:
</h3>
<div class="paragraph-context" [innerHtml]="sanitizedText(paragraph.textPre)"></div>
<div [innerHtml]="sanitizedText(paragraph.text)"></div>
<div class="paragraph-context" [innerHtml]="sanitizedText(paragraph.textPost)"></div>
</div>
</section>
<!-- Reason -->
<div *ngIf="data.data.reason">

View File

@ -1,3 +1,9 @@
@import '../../../../assets/styles/motion-styles-common';
::ng-deep .paragraph-context {
opacity: 0.5;
}
#sidebox {
width: 260px;
right: 0;

View File

@ -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<MotionsMotionSlideData> {
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<MotionsMotionSlideData>;
@Input()
public set data(value: SlideData<MotionsMotionSlideData>) {
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<MotionsMotionSlideData> {
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);
}
}

View File

@ -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,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" fill="%23337ab7"/><path d="M0 0h24v24H0z" fill="none"/></svg>');
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;
}
}

View File

@ -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"])