New Feature: Paragraph based amendments

With new amendment list table:
- Removed title from table, leadmotion can be selected now
- rename the new list, added the export dialog, multiselect actions and supporter badge in the amendment list view
- Moved collission detection to own factory, compute collissions in the amendment list view
- Delegates can now enter paragraph based amendments
- new amendment list as pdf/csv export
- improved caching of amendments
- Parse styles in headings and removed all double-quotes
- Performance improvements:
  * Removed ng-mouseover/mouseleave actions in amendment-list
  * disable collission detection in amendment list view.
  * Improved state/recommendation dropdown in amendment list.
This commit is contained in:
Tobias Hößl 2018-06-12 13:43:28 +02:00 committed by Emanuel Schütze
parent 5735cebcf9
commit d9c08b65b7
38 changed files with 3619 additions and 951 deletions

View File

@ -10,6 +10,8 @@ Version 2.3 (unreleased)
Motions:
- New feature to scroll the projector to a specific line [#3748].
- New possibility to sort submitters [#3647].
- New representation of amendments (paragraph based creation, new diff
and list views for amendments) [#3637].
Version 2.2 (2018-06-06)

View File

@ -18,6 +18,10 @@
padding-right: 10px;
}
.col-space {
padding: 5px 7px 5px 7px;
}
// TODO: Isn't this defined in the _helper.scss?
.centered {
text-align: center;

View File

@ -229,6 +229,11 @@ strong, b, th {
padding: 0 30px;
}
#content .containerOSExpanded {
height: 100%;
margin: 0 auto 0 auto;
padding: 0 20px;
}
/** Content **/
#content {

View File

@ -20,7 +20,7 @@ angular.module('OpenSlidesApp.core.pdf', [])
PDFLayout.createTitle = function(title) {
return {
text: title,
style: "title"
style: 'title'
};
};
@ -28,7 +28,7 @@ angular.module('OpenSlidesApp.core.pdf', [])
PDFLayout.createSubtitle = function(subtitle) {
return {
text: subtitle.join('\n'),
style: "subtitle"
style: 'subtitle'
};
};
@ -45,9 +45,9 @@ angular.module('OpenSlidesApp.core.pdf', [])
// table row style
PDFLayout.flipTableRowStyle = function(currentTableSize) {
if (currentTableSize % 2 === 0) {
return "tableEven";
return 'tableEven';
} else {
return "tableOdd";
return 'tableOdd';
}
};
@ -76,7 +76,7 @@ angular.module('OpenSlidesApp.core.pdf', [])
BallotCircleDimensions.size)
},
{
width: "auto",
width: 'auto',
text: decision
}
],
@ -92,7 +92,7 @@ angular.module('OpenSlidesApp.core.pdf', [])
PDFLayout.imageURLtoBase64 = function(url) {
var promise = new Promise(function(resolve, reject) {
var img = new Image();
img.crossOrigin = "Anonymous";
img.crossOrigin = 'Anonymous';
img.onerror = function () {
reject({
msg: '<i class="fa fa-exclamation-triangle fa-lg spacer-right"></i>' +
@ -101,12 +101,12 @@ angular.module('OpenSlidesApp.core.pdf', [])
});
};
img.onload = function () {
var canvas = document.createElement("canvas");
var canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
var ctx = canvas.getContext("2d");
var ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
var dataURL = canvas.toDataURL("image/png");
var dataURL = canvas.toDataURL('image/png');
var imageData = {
data: dataURL,
width: img.width,
@ -154,7 +154,7 @@ angular.module('OpenSlidesApp.core.pdf', [])
});
return '<p>' + str + '</p>';
} else {
return ''; //needed for blank "reasons" field
return ''; //needed for blank 'reasons' field
}
};
return HTMLValidizer;
@ -460,25 +460,25 @@ angular.module('OpenSlidesApp.core.pdf', [])
*/
convertHTML = function(html, lineNumberMode) {
var elementStyles = {
"b": ["font-weight:bold"],
"strong": ["font-weight:bold"],
"u": ["text-decoration:underline"],
"em": ["font-style:italic"],
"i": ["font-style:italic"],
"h1": ["font-size:14", "font-weight:bold"],
"h2": ["font-size:12", "font-weight:bold"],
"h3": ["font-size:10", "font-weight:bold"],
"h4": ["font-size:10", "font-style:italic"],
"h5": ["font-size:10"],
"h6": ["font-size:10"],
"a": ["color:blue", "text-decoration:underline"],
"strike": ["text-decoration:line-through"],
"del": ["color:red", "text-decoration:line-through"],
"ins": ["color:green", "text-decoration:underline"]
'b': ['font-weight:bold'],
'strong': ['font-weight:bold'],
'u': ['text-decoration:underline'],
'em': ['font-style:italic'],
'i': ['font-style:italic'],
'h1': ['font-size:14', 'font-weight:bold'],
'h2': ['font-size:12', 'font-weight:bold'],
'h3': ['font-size:10', 'font-weight:bold'],
'h4': ['font-size:10', 'font-style:italic'],
'h5': ['font-size:10'],
'h6': ['font-size:10'],
'a': ['color:blue', 'text-decoration:underline'],
'strike': ['text-decoration:line-through'],
'del': ['color:red', 'text-decoration:line-through'],
'ins': ['color:green', 'text-decoration:underline']
},
classStyles = {
"delete": ["color:red", "text-decoration:line-through"],
"insert": ["color:green", "text-decoration:underline"]
'delete': ['color:red', 'text-decoration:line-through'],
'insert': ['color:green', 'text-decoration:underline']
},
getLineNumber = function (element) {
if (element && element.nodeName == 'SPAN' && element.getAttribute('class') &&
@ -595,54 +595,54 @@ angular.module('OpenSlidesApp.core.pdf', [])
*/
ComputeStyle = function(o, styles) {
styles.forEach(function(singleStyle) {
var styleDefinition = singleStyle.trim().toLowerCase().split(":");
var styleDefinition = singleStyle.trim().toLowerCase().split(':');
var style = styleDefinition[0];
var value = styleDefinition[1];
if (styleDefinition.length === 2) {
switch (style) {
case "padding-left":
case 'padding-left':
o.margin = [parseInt(value), 0, 0, 0];
break;
case "font-size":
case 'font-size':
o.fontSize = parseInt(value);
break;
case "text-align":
case 'text-align':
switch (value) {
case "right":
case "center":
case "justify":
case 'right':
case 'center':
case 'justify':
o.alignment = value;
break;
}
break;
case "font-weight":
case 'font-weight':
switch (value) {
case "bold":
case 'bold':
o.bold = true;
break;
}
break;
case "text-decoration":
case 'text-decoration':
switch (value) {
case "underline":
o.decoration = "underline";
case 'underline':
o.decoration = 'underline';
break;
case "line-through":
o.decoration = "lineThrough";
case 'line-through':
o.decoration = 'lineThrough';
break;
}
break;
case "font-style":
case 'font-style':
switch (value) {
case "italic":
case 'italic':
o.italics = true;
break;
}
break;
case "color":
case 'color':
o.color = parseColor(value);
break;
case "background-color":
case 'background-color':
o.background = parseColor(value);
break;
}
@ -683,16 +683,16 @@ angular.module('OpenSlidesApp.core.pdf', [])
styles = styles ? _.clone(styles) : [];
var classes = [];
if (element.getAttribute) {
var nodeStyle = element.getAttribute("style");
var nodeStyle = element.getAttribute('style');
if (nodeStyle) {
nodeStyle.split(";").forEach(function(nodeStyle) {
nodeStyle.split(';').forEach(function(nodeStyle) {
var tmp = nodeStyle.replace(/\s/g, '');
styles.push(tmp);
});
}
var nodeClass = element.getAttribute("class");
var nodeClass = element.getAttribute('class');
if (nodeClass) {
classes = nodeClass.toLowerCase().split(" ");
classes = nodeClass.toLowerCase().split(' ');
classes.forEach(function(nodeClass) {
if (typeof(classStyles[nodeClass]) != 'undefined') {
classStyles[nodeClass].forEach(function(style) {
@ -710,37 +710,40 @@ angular.module('OpenSlidesApp.core.pdf', [])
}
var nodeName = element.nodeName.toLowerCase();
switch (nodeName) {
case "h1":
case "h2":
case "h3":
case "h4":
case "h5":
case "h6":
if (lineNumberMode === "outside" &&
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
if (lineNumberMode === 'outside' &&
element.childNodes.length > 0 &&
element.childNodes[0].getAttribute) {
// A heading may have multiple lines, so handle line by line separated by line number elements
var outerStack = create("stack");
var currentCol;
var outerStack = create('stack');
var currentCol, currentText;
_.forEach(element.childNodes, function (node) {
if (node.getAttribute && node.getAttribute('data-line-number')) {
if (currentCol) {
ComputeStyle(currentCol, elementStyles[nodeName]);
outerStack.stack.push(currentCol);
}
currentText = create('text');
currentCol = {
columns: [
getLineNumberObject({
lineNumber: node.getAttribute('data-line-number')
}),
currentText,
],
margin: [0, 2, 0, 0],
};
} else if (node.textContent) {
var HeaderText = {
text: node.textContent,
};
currentCol.columns.push(HeaderText);
} else {
var parsedText = ParseElement([], node, create('text'), styles, diff_mode);
// append the parsed text to the currentText
_.forEach(parsedText.text, function (text) {
currentText.text.push(text);
});
}
});
ComputeStyle(currentCol, elementStyles[nodeName]);
@ -751,30 +754,30 @@ angular.module('OpenSlidesApp.core.pdf', [])
}
alreadyConverted.push(outerStack);
} else {
currentParagraph = create("text");
currentParagraph = create('text');
currentParagraph.marginBottom = 4;
currentParagraph.marginTop = 10;
currentParagraph = parseChildren(alreadyConverted, element, currentParagraph, styles.concat(elementStyles[nodeName]), diff_mode);
alreadyConverted.push(currentParagraph);
}
break;
case "a":
case "b":
case "strong":
case "u":
case "em":
case "i":
case "ins":
case "del":
case "strike":
case 'a':
case 'b':
case 'strong':
case 'u':
case 'em':
case 'i':
case 'ins':
case 'del':
case 'strike':
currentParagraph = parseChildren(alreadyConverted, element, currentParagraph, styles.concat(elementStyles[nodeName]), diff_mode);
break;
case "table":
var t = create("table", {
case 'table':
var t = create('table', {
widths: [],
body: []
});
var border = element.getAttribute("border");
var border = element.getAttribute('border');
var isBorder = false;
if (border) {
isBorder = (parseInt(border) === 1);
@ -782,58 +785,58 @@ angular.module('OpenSlidesApp.core.pdf', [])
t.layout = 'noBorders';
}
currentParagraph = parseChildren(t.table.body, element, currentParagraph, styles, diff_mode);
var widths = element.getAttribute("widths");
var widths = element.getAttribute('widths');
if (!widths) {
if (t.table.body.length !== 0) {
if (t.table.body[0].length !== 0)
for (var k = 0; k < t.table.body[0].length; k++)
t.table.widths.push("*");
t.table.widths.push('*');
}
} else {
var w = widths.split(",");
var w = widths.split(',');
for (var ko = 0; ko < w.length; ko++) t.table.widths.push(w[ko]);
}
alreadyConverted.push(t);
break;
case "tbody":
case 'tbody':
currentParagraph = parseChildren(alreadyConverted, element, currentParagraph, styles, diff_mode);
break;
case "tr":
case 'tr':
var row = [];
currentParagraph = parseChildren(row, element, currentParagraph, styles, diff_mode);
alreadyConverted.push(row);
break;
case "td":
currentParagraph = create("text");
var st = create("stack");
case 'td':
currentParagraph = create('text');
var st = create('stack');
st.stack.push(currentParagraph);
var rspan = element.getAttribute("rowspan");
var rspan = element.getAttribute('rowspan');
if (rspan)
st.rowSpan = parseInt(rspan);
var cspan = element.getAttribute("colspan");
var cspan = element.getAttribute('colspan');
if (cspan)
st.colSpan = parseInt(cspan);
currentParagraph = parseChildren(st.stack, element, currentParagraph, styles, diff_mode);
alreadyConverted.push(st);
break;
case "span":
if (element.getAttribute("data-line-number")) {
if (lineNumberMode === "inline") {
case 'span':
if (element.getAttribute('data-line-number')) {
if (lineNumberMode === 'inline') {
if (diff_mode !== DIFF_MODE_INSERT) {
var lineNumberInline = element.getAttribute("data-line-number"),
var lineNumberInline = element.getAttribute('data-line-number'),
lineNumberObjInline = {
text: lineNumberInline,
color: "gray",
color: 'gray',
fontSize: 5
};
currentParagraph.text.push(lineNumberObjInline);
}
} else if (lineNumberMode === "outside") {
} else if (lineNumberMode === 'outside') {
var lineNumberOutline;
if (diff_mode === DIFF_MODE_INSERT) {
lineNumberOutline = "";
lineNumberOutline = '';
} else {
lineNumberOutline = element.getAttribute("data-line-number");
lineNumberOutline = element.getAttribute('data-line-number');
}
var col = {
columns: [
@ -842,7 +845,7 @@ angular.module('OpenSlidesApp.core.pdf', [])
}),
]
};
currentParagraph = create("text");
currentParagraph = create('text');
currentParagraph.lineHeight = 1.25;
col.columns.push(currentParagraph);
alreadyConverted.push(col);
@ -852,7 +855,7 @@ angular.module('OpenSlidesApp.core.pdf', [])
currentParagraph = parseChildren(alreadyConverted, element, currentParagraph, styles, diff_mode);
}
break;
case "br":
case 'br':
var brParent = element.parentNode;
var brParentNodeName = brParent.nodeName;
//in case of no or inline-line-numbers and the ignore os-line-breaks.
@ -860,10 +863,10 @@ angular.module('OpenSlidesApp.core.pdf', [])
hasClass(element, 'os-line-break')) {
break;
} else {
currentParagraph = create("text");
if (lineNumberMode === "outside" &&
brParentNodeName !== "LI" &&
element.parentNode.parentNode.nodeName !== "LI") {
currentParagraph = create('text');
if (lineNumberMode === 'outside' &&
brParentNodeName !== 'LI' &&
element.parentNode.parentNode.nodeName !== 'LI') {
if (brParentNodeName === 'INS' || brParentNodeName === 'DEL') {
var hasPrevSiblingALineNumber = function (element) {
@ -896,11 +899,11 @@ angular.module('OpenSlidesApp.core.pdf', [])
alreadyConverted.push(currentParagraph);
}
break;
case "li":
case "div":
currentParagraph = create("text");
case 'li':
case 'div':
currentParagraph = create('text');
currentParagraph.lineHeight = 1.25;
var stackDiv = create("stack");
var stackDiv = create('stack');
if (_.indexOf(classes, 'os-split-before') > -1) {
stackDiv.listType = 'none';
}
@ -912,9 +915,9 @@ angular.module('OpenSlidesApp.core.pdf', [])
currentParagraph = parseChildren(stackDiv.stack, element, currentParagraph, [], diff_mode);
alreadyConverted.push(stackDiv);
break;
case "p":
case 'p':
var pObjectToPush; //determine what to push later
currentParagraph = create("text");
currentParagraph = create('text');
// If this element is inside a list (happens if copied from word), do not set spaces
// and margins. Just leave the paragraph there..
if (!isInsideAList(element)) {
@ -927,19 +930,19 @@ angular.module('OpenSlidesApp.core.pdf', [])
}
}
currentParagraph.lineHeight = 1.25;
var stackP = create("stack");
var stackP = create('stack');
stackP.stack.push(currentParagraph);
ComputeStyle(stackP, styles);
currentParagraph = parseChildren(stackP.stack, element, currentParagraph, [], diff_mode);
pObjectToPush = stackP; //usually we want to push stackP
if (lineNumberMode === "outside") {
if (lineNumberMode === 'outside') {
if (element.childNodes.length > 0) { //if we hit = 0, the code would fail
// add empty line number column for inline diff or pragraph diff mode
if (element.childNodes[0].tagName === "INS" ||
element.childNodes[0].tagName === "DEL") {
if (element.childNodes[0].tagName === 'INS' ||
element.childNodes[0].tagName === 'DEL') {
var pLineNumberPlaceholder = {
width: 20,
text: "",
text: '',
fontSize: 8,
margin: [0, 2, 0, 0]
};
@ -955,7 +958,7 @@ angular.module('OpenSlidesApp.core.pdf', [])
}
alreadyConverted.push(pObjectToPush);
break;
case "img":
case 'img':
var path = element.getAttribute('src');
var height = images[path].height;
var width = images[path].width;
@ -989,8 +992,8 @@ angular.module('OpenSlidesApp.core.pdf', [])
height: height,
});
break;
case "ul":
case "ol":
case 'ul':
case 'ol':
var list = create(nodeName);
if (nodeName == 'ol') {
var start = element.getAttribute('start');
@ -999,7 +1002,7 @@ angular.module('OpenSlidesApp.core.pdf', [])
}
}
ComputeStyle(list, styles);
if (lineNumberMode === "outside") {
if (lineNumberMode === 'outside') {
var lines = extractLineNumbers(element);
currentParagraph = parseChildren(list[nodeName], element, currentParagraph, styles, diff_mode);
if (lines.length > 0) {
@ -1028,7 +1031,7 @@ angular.module('OpenSlidesApp.core.pdf', [])
}
break;
default:
var defaultText = create("text", element.textContent.replace(/\n/g, ""));
var defaultText = create('text', element.textContent.replace(/\n/g, ''));
ComputeStyle(defaultText, styles);
if (!currentParagraph) {
currentParagraph = {};
@ -1047,8 +1050,8 @@ angular.module('OpenSlidesApp.core.pdf', [])
*/
ParseHtml = function(converted, htmlText) {
var html = HTMLValidizer.validize(htmlText);
html = $(html.replace(/\t/g, "").replace(/\n/g, ""));
var emptyParagraph = create("text");
html = $(html.replace(/\t/g, '').replace(/\n/g, ''));
var emptyParagraph = create('text');
slice(html).forEach(function(element) {
ParseElement(converted, element, null, [], DIFF_MODE_NORMAL);
});
@ -1067,7 +1070,7 @@ angular.module('OpenSlidesApp.core.pdf', [])
},
{
text: line.lineNumber,
color: "gray",
color: 'gray',
fontSize: standardFontsize - 2,
decoration: '',
},
@ -1082,8 +1085,8 @@ angular.module('OpenSlidesApp.core.pdf', [])
},
/**
* Creates containerelements for pdfMake
* e.g create("text":"MyText") result in { text: "MyText" }
* or complex objects create("stack", [{text:"MyText"}, {text:"MyText2"}])
* e.g create('text':'MyText') result in { text: 'MyText' }
* or complex objects create('stack', [{text:'MyText'}, {text:'MyText2'}])
*for units / paragraphs of text
*
* @function
@ -1160,8 +1163,8 @@ angular.module('OpenSlidesApp.core.pdf', [])
/*
* Returns a map from urls to arrays of font types used by PdfMake.
* E.g. if the font "regular" and bold" have the urls "fonts/myFont.ttf",
* the map fould be "fonts/myFont.ttf": ["OSFont-regular.ttf", "OSFont-bold.ttf"]
* E.g. if the font 'regular' and 'bold' have the urls 'fonts/myFont.ttf',
* the map fould be 'fonts/myFont.ttf': ['OSFont-regular.ttf', 'OSFont-bold.ttf']
*/
var getUrlMapping = function () {
var urlMap = {};

View File

@ -166,6 +166,19 @@ angular.module('OpenSlidesApp.core.site', [
}
])
// Make the main content expandable
.run([
'$rootScope',
function ($rootScope) {
$rootScope.$on('$stateChangeSuccess', function() {
$rootScope.expandContent = false;
});
$rootScope.toggleExpandContent = function () {
$rootScope.expandContent = !$rootScope.expandContent;
};
}
])
.config([
'mainMenuProvider',
'gettext',

View File

@ -178,9 +178,10 @@
<!-- Content -->
<div id="content" ng-controller="ProjectorSidebarCtrl">
<div class="containerOS">
<div ng-class="expandContent ? 'containerOSExpanded' : 'containerOS'">
<!-- col2 sidebar-xs (for small devices)-->
<div ng-if="!expandContent">
<div id="sidebar-xs" class="col2" os-perms="core.can_see_projector" ng-class="{
'sidebar-max': isProjectorSidebar && operator.hasPerms('core.can_see_projector'),
'sidebar-min': !isProjectorSidebar && operator.hasPerms('core.can_see_projector'),
@ -211,12 +212,13 @@
</div>
</div>
</div>
</div>
<!-- col1 -->
<div id="main-column" class="col1" ng-class="{
'sidebar-max': isProjectorSidebar && operator.hasPerms('core.can_see_projector'),
'sidebar-min': !isProjectorSidebar && operator.hasPerms('core.can_see_projector'),
'sidebar-none': !operator.hasPerms('core.can_see_projector') }">
'sidebar-max': isProjectorSidebar && operator.hasPerms('core.can_see_projector') && !expandContent,
'sidebar-min': !isProjectorSidebar && operator.hasPerms('core.can_see_projector') && !expandContent,
'sidebar-none': !operator.hasPerms('core.can_see_projector') || expandContent}">
<!-- dynamic views -->
<div ui-view ng-if="openslidesBootstrapDone && baseViewPermissionsGranted"></div>
<!-- footer -->
@ -228,6 +230,7 @@
</div>
<!-- col2 normal sidebar -->
<div ng-if="!expandContent">
<div id="sidebar" class="col2" os-perms="core.can_see_projector" ng-class="{
'sidebar-max': isProjectorSidebar && operator.hasPerms('core.can_see_projector'),
'sidebar-min': !isProjectorSidebar && operator.hasPerms('core.can_see_projector'),
@ -255,6 +258,7 @@
</div>
</div>
</div>
</div>
</div><!--end content-container-->

View File

@ -166,11 +166,15 @@ def get_config_variables():
subgroup='Amendments')
yield ConfigVariable(
name='motions_amendments_apply_text',
default_value=False,
input_type='boolean',
label='Apply text for new amendments',
help_text='The title of the motion is always applied.',
name='motions_amendments_text_mode',
default_value='freestyle',
input_type='choice',
label='How to create new amendments',
choices=(
{'value': 'freestyle', 'display_name': 'Empty text field'},
{'value': 'fulltext', 'display_name': 'Edit the whole motion text'},
{'value': 'paragraph', 'display_name': 'Paragraph-based, Diff-enabled'},
),
weight=342,
group='Motions',
subgroup='Amendments')

View File

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2018-03-07 10:46
from __future__ import unicode_literals
import jsonfield.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('motions', '0006_submitter_model'),
]
operations = [
migrations.AddField(
model_name='motionversion',
name='amendment_paragraphs',
field=jsonfield.fields.JSONField(null=True),
),
]

View File

@ -214,9 +214,12 @@ class Motion(RESTModelMixin, models.Model):
* Else the given version is used.
To create and use a new version object, you have to set it via the
use_version argument. You have to set the title, text and reason into
use_version argument. You have to set the title, text/amendment_paragraphs and reason into
this version object before giving it to this save method. The properties
motion.title, motion.text and motion.reason will be ignored.
motion.title, motion.text, motion.amendment_paragraphs and motion.reason will be ignored.
text and amendment_paragraphs are mutually exclusive; if both are given,
amendment_paragraphs takes precedence.
"""
if not self.state:
self.reset_state()
@ -261,8 +264,8 @@ class Motion(RESTModelMixin, models.Model):
return
elif use_version is None:
use_version = self.get_last_version()
# Save title, text and reason into the version object.
for attr in ['title', 'text', 'reason']:
# Save title, text, amendment paragraphs and reason into the version object.
for attr in ['title', 'text', 'amendment_paragraphs', 'reason']:
_attr = '_%s' % attr
data = getattr(self, _attr, None)
if data is not None:
@ -323,7 +326,7 @@ class Motion(RESTModelMixin, models.Model):
return True
last_version = self.get_last_version()
for attr in ['title', 'text', 'reason']:
for attr in ['title', 'text', 'amendment_paragraphs', 'reason']:
if getattr(last_version, attr) != getattr(version, attr):
return True
return False
@ -460,7 +463,33 @@ class Motion(RESTModelMixin, models.Model):
text = property(get_text, set_text)
"""
The text of a motin.
The text of a motion.
Is saved in a MotionVersion object.
"""
def get_amendment_paragraphs(self):
"""
Get the paragraphs of the amendment.
Returns an array of entries that are either null (paragraph is not changed)
or a string (the new version of this paragraph).
"""
try:
return self._amendment_paragraphs
except AttributeError:
return self.get_active_version().amendment_paragraphs
def set_amendment_paragraphs(self, text):
"""
Set the paragraphs of the amendment.
Has to be an array of entries that are either null (paragraph is not changed)
or a string (the new version of this paragraph).
"""
self._amendment_paragraphs = text
amendment_paragraphs = property(get_amendment_paragraphs, set_amendment_paragraphs)
"""
The paragraphs of the amendment.
Is saved in a MotionVersion object.
"""
@ -496,7 +525,7 @@ class Motion(RESTModelMixin, models.Model):
Return a version object, not saved in the database.
The version data of the new version object is populated with the data
set via motion.title, motion.text, motion.reason if these data are
set via motion.title, motion.text, motion.amendment_paragraphs and motion.reason if these data are
not given as keyword arguments. If the data is not set in the motion
attributes, it is populated with the data from the last version
object if such object exists.
@ -510,7 +539,7 @@ class Motion(RESTModelMixin, models.Model):
last_version = self.get_last_version()
else:
last_version = None
for attr in ['title', 'text', 'reason']:
for attr in ['title', 'text', 'amendment_paragraphs', 'reason']:
if attr in kwargs:
continue
_attr = '_%s' % attr
@ -693,6 +722,13 @@ class Motion(RESTModelMixin, models.Model):
"""
return config['motions_amendments_enabled'] and self.parent is not None
def is_paragraph_based_amendment(self):
"""
Returns True if the motion is an amendment that stores the changes on a per-paragraph-basis
and is therefore eligible to be shown in diff-view.
"""
return self.is_amendment() and self.amendment_paragraphs
def get_amendments_deep(self):
"""
Generator that yields all amendments of this motion including all
@ -702,6 +738,12 @@ class Motion(RESTModelMixin, models.Model):
yield amendment
yield from amendment.get_amendments_deep()
def get_paragraph_based_amendments(self):
"""
Returns a list of all paragraph-based amendments to this motion
"""
return list(filter(lambda amend: amend.is_paragraph_based_amendment(), self.amendments.all()))
class SubmitterManager(models.Manager):
"""
@ -789,6 +831,15 @@ class MotionVersion(RESTModelMixin, models.Model):
text = models.TextField()
"""The text of a motion."""
amendment_paragraphs = JSONField(null=True)
"""
If paragraph-based, diff-enabled amendment style is used, this field stores an array of strings or null values.
Each entry corresponds to a paragraph of the text of the original motion.
If the entry is null, then the paragraph remains unchanged.
If the entry is a string, this is the new text of the paragraph.
amendment_paragraphs and text are mutually exclusive.
"""
reason = models.TextField(null=True, blank=True)
"""The reason for a motion."""

View File

@ -29,10 +29,13 @@ class MotionSlide(ProjectorElement):
yield motion.agenda_item
yield motion.state.workflow
yield from self.required_motions_for_state_and_recommendation(motion)
yield from motion.get_paragraph_based_amendments()
for submitter in motion.submitters.all():
yield submitter.user
yield from motion.supporters.all()
yield from MotionChangeRecommendation.objects.filter(motion_version=motion.get_active_version().id)
if motion.parent:
yield motion.parent
def required_motions_for_state_and_recommendation(self, motion):
"""

View File

@ -134,6 +134,28 @@ class MotionCommentsJSONSerializerField(Field):
return data
class AmendmentParagraphsJSONSerializerField(Field):
"""
Serializer for motions's amendment_paragraphs JSONField.
"""
def to_representation(self, obj):
"""
Returns the value of the field.
"""
return obj
def to_internal_value(self, data):
"""
Checks that data is a list of strings.
"""
if type(data) is not list:
raise ValidationError({'detail': 'Data must be a list.'})
for paragraph in data:
if type(paragraph) is not str and paragraph is not None:
raise ValidationError({'detail': 'Paragraph must be either a string or null/None.'})
return data
class MotionLogSerializer(ModelSerializer):
"""
Serializer for motion.models.MotionLog objects.
@ -250,6 +272,8 @@ class MotionPollSerializer(ModelSerializer):
class MotionVersionSerializer(ModelSerializer):
amendment_paragraphs = AmendmentParagraphsJSONSerializerField(required=False)
"""
Serializer for motion.models.MotionVersion objects.
"""
@ -261,6 +285,7 @@ class MotionVersionSerializer(ModelSerializer):
'creation_time',
'title',
'text',
'amendment_paragraphs',
'reason',)
@ -315,8 +340,9 @@ class MotionSerializer(ModelSerializer):
polls = MotionPollSerializer(many=True, read_only=True)
reason = CharField(allow_blank=True, required=False, write_only=True)
state_required_permission_to_see = SerializerMethodField()
text = CharField(write_only=True)
text = CharField(write_only=True, allow_blank=True)
title = CharField(max_length=255, write_only=True)
amendment_paragraphs = AmendmentParagraphsJSONSerializerField(required=False, write_only=True)
versions = MotionVersionSerializer(many=True, read_only=True)
workflow_id = IntegerField(
min_value=1,
@ -334,6 +360,7 @@ class MotionSerializer(ModelSerializer):
'identifier',
'title',
'text',
'amendment_paragraphs',
'reason',
'versions',
'active_version',
@ -360,12 +387,25 @@ class MotionSerializer(ModelSerializer):
def validate(self, data):
if 'text'in data:
data['text'] = validate_html(data['text'])
if 'reason' in data:
data['reason'] = validate_html(data['reason'])
validated_comments = dict()
for id, comment in data.get('comments', {}).items():
validated_comments[id] = validate_html(comment)
data['comments'] = validated_comments
if 'amendment_paragraphs' in data:
data['amendment_paragraphs'] = list(map(lambda entry: validate_html(entry) if type(entry) is str else None,
data['amendment_paragraphs']))
data['text'] = ''
else:
if 'text' in data and len(data['text']) == 0:
raise ValidationError({
'detail': _('This field may not be blank.')
})
return data
@transaction.atomic
@ -379,6 +419,7 @@ class MotionSerializer(ModelSerializer):
motion = Motion()
motion.title = validated_data['title']
motion.text = validated_data['text']
motion.amendment_paragraphs = validated_data.get('amendment_paragraphs')
motion.reason = validated_data.get('reason', '')
motion.identifier = validated_data.get('identifier')
motion.category = validated_data.get('category')
@ -418,7 +459,7 @@ class MotionSerializer(ModelSerializer):
version = motion.get_last_version()
# Title, text, reason.
for key in ('title', 'text', 'reason'):
for key in ('title', 'text', 'amendment_paragraphs', 'reason'):
if key in validated_data.keys():
setattr(version, key, validated_data[key])

View File

@ -0,0 +1,94 @@
.paragraph-select-list {
display: table;
border: 1px solid #d3d3d3;
width: 100%;
margin-bottom: 10px;
.paragraph-select-holder {
display: table-row;
cursor: pointer;
border-bottom: 1px solid #d3d3d3;
.paragraph-select {
display: table-cell;
width: 30px;
padding-top: 5px;
text-align: center;
}
.text-holder {
display: table-cell;
background-color: white;
padding: 5px 10px;
:last-child {
margin-bottom: 0;
}
// Show line numbers at the side
@media screen and (min-width: 800px) {
padding-left: 30px;
position: relative;
.os-line-number {
display: inline-block;
font-size: 0;
line-height: 0;
width: 22px;
height: 22px;
position: absolute;
left: -15px;
padding-right: 45px;
&:after {
content: attr(data-line-number);
position: absolute;
top: 8px;
right: 5px;
vertical-align: top;
color: #a9a9a9;
font-size: 12px;
font-weight: normal;
}
}
}
// Show line numbers at the side
@media screen and (max-width: 799px) {
.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;
}
}
}
}
&:hover {
.text-holder {
background-color: #f0f0f0;
}
}
&.selected {
.paragraph-select {
background-color: #ddd;
}
.text-holder {
background-color: #ddd;
}
}
}
}

View File

@ -31,6 +31,12 @@ ul.os-split-after, ol.os-split-after {
padding-bottom: 0;
}
}
.collission-hint {
color: red;
float: left;
margin-left: -19px;
margin-top: 10px;
}
}
.motion-text-diff {
@ -51,4 +57,15 @@ ul.os-split-after, ol.os-split-after {
&.line-numbers-inline .insert .os-line-number {
display: none;
}
.paragraph-context {
opacity: 0.5;
}
&.amendment-context {
.paragraph-context {
opacity: 1;
}
}
.amendment-line-header {
margin: 10px 0 0 0;
}
}

View File

@ -14,8 +14,9 @@
padding-bottom: 10px;
}
ul {
margin-bottom: 0px;
ol, ul {
margin-left: 15px;
margin-bottom: 0;
}
.highlight {

View File

@ -1,3 +1,4 @@
@import "amendments";
@import "diff";
@import "change-recommendation-overview";
@import "inline-editing";
@ -72,6 +73,10 @@
margin-right: 5px;
}
ng-include {
display: inline-block;
}
.btn.disabled {
cursor: default;
opacity: 1;
@ -224,6 +229,11 @@
.btn-edit {
margin-left: 5px;
}
.btn-amend-info {
margin-left: 5px;
min-width: 68px;
}
}
.status-row {
font-style: italic;

View File

@ -229,6 +229,7 @@ angular.module('OpenSlidesApp.motions', [
.factory('Motion', [
'DS',
'$http',
'$cacheFactory',
'MotionPoll',
'MotionStateAndRecommendationParser',
'MotionChangeRecommendation',
@ -243,9 +244,13 @@ angular.module('OpenSlidesApp.motions', [
'Projector',
'ProjectHelper',
'operator',
function(DS, $http, MotionPoll, MotionStateAndRecommendationParser, MotionChangeRecommendation,
'UnifiedChangeObjectCollission',
function(DS, $http, $cacheFactory, MotionPoll, MotionStateAndRecommendationParser, MotionChangeRecommendation,
MotionComment, jsDataModel, gettext, gettextCatalog, Config, lineNumberingService,
diffService, OpenSlidesSettings, Projector, ProjectHelper, operator) {
diffService, OpenSlidesSettings, Projector, ProjectHelper, operator, UnifiedChangeObjectCollission) {
var diffCache = $cacheFactory('motion.service');
var name = 'motions/motion';
return DS.defineResource({
name: name,
@ -277,6 +282,10 @@ angular.module('OpenSlidesApp.motions', [
}
return this.versions[index] || {};
},
isParagraphBasedAmendment: function () {
var version = this.getVersion();
return this.isAmendment && version.amendment_paragraphs;
},
getTitle: function (versionId) {
return this.getVersion(versionId).title;
},
@ -331,19 +340,31 @@ angular.module('OpenSlidesApp.motions', [
return lineNumberingService.insertLineNumbers(html, lineLength, highlight, callback);
},
getTextBetweenChangeRecommendations: function (versionId, change1, change2, highlight) {
getTextBetweenChanges: function (versionId, change1, change2, highlight) {
var line_from = (change1 ? change1.line_to : 1),
line_to = (change2 ? change2.line_from : null);
if (line_from > line_to) {
throw 'Invalid call of getTextBetweenChangeRecommendations: change1 needs to be before change2';
throw 'Invalid call of getTextBetweenChanges: change1 needs to be before change2';
}
if (line_from == line_to) {
if (line_from === line_to) {
return '';
}
return this.getTextInLineRange(versionId, line_from, line_to, highlight);
},
getTextInLineRange: function (versionId, line_from, line_to, highlight) {
var lineLength = Config.get('motions_line_length').value,
html = lineNumberingService.insertLineNumbers(this.getVersion(versionId).text, lineLength),
htmlRaw = this.getVersion(versionId).text;
var cacheKey = 'getTextInLineRange ' + line_from + ' ' + line_to + ' ' + highlight + ' ' +
lineNumberingService.djb2hash(htmlRaw),
cached = diffCache.get(cacheKey);
if (!angular.isUndefined(cached)) {
return cached;
}
var html = lineNumberingService.insertLineNumbers(htmlRaw, lineLength),
data;
try {
@ -362,9 +383,11 @@ angular.module('OpenSlidesApp.motions', [
data.html + data.innerContextEnd + data.outerContextEnd;
html = lineNumberingService.insertLineNumbers(html, lineLength, highlight, null, line_from);
diffCache.put(cacheKey, html);
return html;
},
getTextRemainderAfterLastChangeRecommendation: function(versionId, changes, highlight) {
getTextRemainderAfterLastChange: function(versionId, changes, highlight) {
var maxLine = 0;
for (var i = 0; i < changes.length; i++) {
if (changes[i].line_to > maxLine) {
@ -398,18 +421,37 @@ angular.module('OpenSlidesApp.motions', [
}
return html;
},
_getTextWithChangeRecommendations: function (versionId, highlight, lineBreaks, statusCompareCb) {
_getTextWithChanges: function (versionId, highlight, lineBreaks, recommendation_filter, amendment_filter) {
var lineLength = Config.get('motions_line_length').value,
html = this.getVersion(versionId).text,
changes = this.getTextChangeRecommendations(versionId, 'DESC');
change_recommendations = this.getTextChangeRecommendations(versionId, 'DESC'),
amendments = this.getParagraphBasedAmendments();
for (var i = 0; i < changes.length; i++) {
var change = changes[i];
if (typeof statusCompareCb === 'undefined' || statusCompareCb(change.rejected)) {
var allChanges = [];
change_recommendations.filter(recommendation_filter).forEach(function(change) {
allChanges.push({"text": change.text, "line_from": change.line_from, "line_to": change.line_to});
});
amendments.filter(amendment_filter).forEach(function(amend) {
var change = amend.getAmendmentsAffectedLinesChanged();
allChanges.push({"text": change.text, "line_from": change.line_from, "line_to": change.line_to});
});
// Changes need to be applied from the bottom up, to prevent conflicts with changing line numbers.
allChanges.sort(function(change1, change2) {
if (change1.line_from < change2.line_from) {
return 1;
} else if (change1.line_from > change2.line_from) {
return -1;
} else {
return 0;
}
});
allChanges.forEach(function(change) {
html = lineNumberingService.insertLineNumbers(html, lineLength, null, null, 1);
html = diffService.replaceLines(html, change.text, change.line_from, change.line_to);
}
}
});
if (lineBreaks) {
html = lineNumberingService.insertLineNumbers(html, lineLength, highlight, null, 1);
@ -418,13 +460,23 @@ angular.module('OpenSlidesApp.motions', [
return html;
},
getTextWithAllChangeRecommendations: function (versionId, highlight, lineBreaks) {
return this._getTextWithChangeRecommendations(versionId, highlight, lineBreaks, function() {
return true;
return this._getTextWithChanges(versionId, highlight, lineBreaks, function() {
return true; // All change recommendations
}, function() {
return false; // No amendments
});
},
getTextWithoutRejectedChangeRecommendations: function (versionId, highlight, lineBreaks) {
return this._getTextWithChangeRecommendations(versionId, highlight, lineBreaks, function(rejected) {
return !rejected;
getTextWithAgreedChanges: function (versionId, highlight, lineBreaks) {
return this._getTextWithChanges(versionId, highlight, lineBreaks, function(recommendation) {
return !recommendation.rejected;
}, function(amendment) {
if (amendment.state && amendment.state.name === 'rejected') {
return false;
}
if (amendment.state && amendment.state.name === 'accepted') {
return true;
}
return (amendment.recommendation && amendment.recommendation.name === 'accepted');
});
},
getTextByMode: function(mode, versionId, highlight, lineBreaks) {
@ -447,13 +499,34 @@ angular.module('OpenSlidesApp.motions', [
}
break;
case 'diff':
var changes = this.getTextChangeRecommendations(versionId, 'ASC');
text = '';
for (var i = 0; i < changes.length; i++) {
text += this.getTextBetweenChangeRecommendations(versionId, (i === 0 ? null : changes[i - 1]), changes[i], highlight);
text += changes[i].getDiff(this, versionId, highlight);
var amendments_crs = this.getTextChangeRecommendations(versionId, 'ASC').map(function (cr) {
return cr.getUnifiedChangeObject();
}).concat(
this.getParagraphBasedAmendmentsForDiffView().map(function (amendment) {
return amendment.getUnifiedChangeObject();
})
);
amendments_crs.sort(function (change1, change2) {
if (change1.line_from > change2.line_from) {
return 1;
} else if (change1.line_from < change2.line_from) {
return -1;
} else {
return 0;
}
text += this.getTextRemainderAfterLastChangeRecommendation(versionId, changes);
});
text = '';
for (var i = 0; i < amendments_crs.length; i++) {
if (i===0) {
text += this.getTextBetweenChanges(versionId, null, amendments_crs[0], highlight);
} else if (amendments_crs[i - 1].line_to < amendments_crs[i].line_from) {
text += this.getTextBetweenChanges(versionId, amendments_crs[i - 1], amendments_crs[i], highlight);
}
text += amendments_crs[i].getDiff(this, versionId, highlight);
}
text += this.getTextRemainderAfterLastChange(versionId, amendments_crs);
if (!lineBreaks) {
text = lineNumberingService.stripLineNumbers(text);
@ -463,11 +536,313 @@ angular.module('OpenSlidesApp.motions', [
text = this.getTextWithAllChangeRecommendations(versionId, highlight, lineBreaks);
break;
case 'agreed':
text = this.getTextWithoutRejectedChangeRecommendations(versionId, highlight, lineBreaks);
text = this.getTextWithAgreedChanges(versionId, highlight, lineBreaks);
break;
}
return text;
},
getTextParagraphs: function(versionId, lineBreaks) {
/*
* @param versionId [if undefined, active_version will be used]
* @param lineBreaks [if line numbers / breaks should be included in the result]
*/
var text;
if (lineBreaks) {
text = this.getTextWithLineBreaks(versionId);
} else {
text = this.getVersion(versionId).text;
}
return lineNumberingService.splitToParagraphs(text);
},
getTextHeadings: function(versionId) {
var html = this.getTextWithLineBreaks(versionId);
return lineNumberingService.getHeadingsWithLineNumbers(html);
},
getAmendmentParagraphsByMode: function (mode, versionId, lineBreaks) {
/*
* @param mode ['original', 'diff', 'changed']
* @param versionId [if undefined, active_version will be used]
* @param lineBreaks [if line numbers / breaks should be included in the result]
*
* Structure of the return array elements:
* {
* "paragraphNo": paragraph number, starting with 0
* "lineFrom": First line number of the affected paragraph
* "lineTo": Last line number of the affected paragraph;
* refers to the line breaking element at the end, i.e. the start of the following line
* "text": the actual text
* }
*/
lineBreaks = (lineBreaks === undefined ? true : lineBreaks);
var cacheKey = 'getAmendmentParagraphsByMode ' + mode + ' ' + versionId + ' ' + lineBreaks +
lineNumberingService.djb2hash(JSON.stringify(this.getVersion(versionId).amendment_paragraphs)),
cached = diffCache.get(cacheKey);
if (!angular.isUndefined(cached)) {
return cached;
}
var original_text = this.getParentMotion().getTextByMode('original', null, null, true);
var original_paragraphs = lineNumberingService.splitToParagraphs(original_text);
var output = [];
this.getVersion(versionId).amendment_paragraphs.forEach(function(paragraph_amend, paragraphNo) {
if (paragraph_amend === null) {
return;
}
if (original_paragraphs[paragraphNo] === undefined) {
throw "The amendment appears to have more paragraphs than the motion. This means, the data might be corrupt";
}
var paragraph_orig = original_paragraphs[paragraphNo];
var line_range = lineNumberingService.getLineNumberRange(paragraph_orig);
var line_length = Config.get('motions_line_length').value;
paragraph_orig = lineNumberingService.stripLineNumbers(paragraph_orig);
var text = null;
switch (mode) {
case "diff":
if (lineBreaks) {
text = diffService.diff(paragraph_orig, paragraph_amend, line_length, line_range.from);
} else {
text = diffService.diff(paragraph_orig, paragraph_amend);
}
break;
case "original":
text = paragraph_orig;
if (lineBreaks) {
text = lineNumberingService.insertLineNumbers(text, line_length, null, null, line_range.from);
}
break;
case "changed":
text = paragraph_amend;
if (lineBreaks) {
text = lineNumberingService.insertLineNumbers(text, line_length, null, null, line_range.from);
}
break;
default:
throw "Invalid text mode: " + mode;
}
output.push({
"paragraphNo": paragraphNo,
"lineFrom": line_range.from,
"lineTo": line_range.to,
"text": text
});
});
diffCache.put(cacheKey, output);
return output;
},
getAmendmentParagraphsLinesByMode: function (mode, versionId, lineBreaks) {
/*
* @param mode ['original', 'diff', 'changed']
* @param versionId [if undefined, active_version will be used]
* @param lineBreaks [if line numbers / breaks should be included in the result]
*
* Structure of the return array elements:
* {
* "paragraphNo": paragraph number, starting with 0
* "paragraphLineFrom": First line number of the affected paragraph
* "paragraphLineTo": End of the affected paragraph (line number + 1)
* "diffLineFrom": First line number of the affected lines
* "diffLineTo": End of the affected lines (line number + 1)
* "textPre": The beginning of the paragraph, before the diff
* "text": the diff
* "textPost": The end of the paragraph, after the diff
* }
*/
if (!this.isParagraphBasedAmendment() || !this.getParentMotion()) {
return [];
}
var cacheKey = 'getAmendmentParagraphsLinesByMode ' + mode + ' ' + versionId + ' ' + lineBreaks +
lineNumberingService.djb2hash(JSON.stringify(this.getVersion(versionId).amendment_paragraphs)),
cached = diffCache.get(cacheKey);
if (!angular.isUndefined(cached)) {
return cached;
}
var original_text = this.getParentMotion().getTextByMode('original', null, null, true);
var original_paragraphs = lineNumberingService.splitToParagraphs(original_text);
var output = [];
this.getVersion(versionId).amendment_paragraphs.forEach(function(paragraph_amend, paragraphNo) {
if (paragraph_amend === null) {
return;
}
if (original_paragraphs[paragraphNo] === undefined) {
throw "The amendment appears to have more paragraphs than the motion. This means, the data might be corrupt";
}
var line_length = Config.get('motions_line_length').value,
paragraph_orig = original_paragraphs[paragraphNo],
paragraph_line_range = lineNumberingService.getLineNumberRange(paragraph_orig),
diff = diffService.diff(paragraph_orig, paragraph_amend),
affected_lines = diffService.detectAffectedLineRange(diff);
if (!affected_lines) {
return;
}
// TODO: Make this work..
var base_paragraph;
switch (mode) {
case 'original':
//base_paragraph = paragraph_orig;
//base_paragraph = diffService.diff(paragraph_orig, paragraph_orig, line_length, paragraph_line_range.from);
base_paragraph = diff;
break;
case 'diff':
base_paragraph = diff;
break;
case 'changed':
//base_paragraph = paragraph_amend;
//base_paragraph = diffService.diff(paragraph_amend, paragraph_amend, line_length, paragraph_line_range.from);
base_paragraph = diff;
break;
}
var textPre = '';
var textPost = '';
if (affected_lines.from > paragraph_line_range.from) {
textPre = diffService.extractRangeByLineNumbers(base_paragraph, paragraph_line_range.from, affected_lines.from);
if (lineBreaks) {
textPre = diffService.formatDiffWithLineNumbers(textPre, line_length, paragraph_line_range.from);
}
}
if (paragraph_line_range.to > affected_lines.to) {
textPost = diffService.extractRangeByLineNumbers(base_paragraph, affected_lines.to, paragraph_line_range.to);
if (lineBreaks) {
textPost = diffService.formatDiffWithLineNumbers(textPost, line_length, affected_lines.to);
}
}
var text = diffService.extractRangeByLineNumbers(base_paragraph, affected_lines.from, affected_lines.to);
if (lineBreaks) {
text = diffService.formatDiffWithLineNumbers(text, line_length, affected_lines.from);
}
output.push({
"paragraphNo": paragraphNo,
"paragraphLineFrom": paragraph_line_range.from,
"paragraphLineTo": paragraph_line_range.to,
"diffLineFrom": affected_lines.from,
"diffLineTo": affected_lines.to,
"textPre": textPre,
"text": text,
"textPost": textPost
});
});
diffCache.put(cacheKey, output);
return output;
},
getAmendmentParagraphsLinesDiff: function (versionId) {
/*
* @param versionId [if undefined, active_version will be used]
*
*/
return this.getAmendmentParagraphsLinesByMode('diff', versionId, true);
},
getAmendmentsAffectedLinesChanged: function () {
var paragraph_diff = this.getAmendmentParagraphsByMode("diff")[0],
affected_lines = diffService.detectAffectedLineRange(paragraph_diff.text);
var extracted_lines = diffService.extractRangeByLineNumbers(paragraph_diff.text, affected_lines.from, affected_lines.to);
var diff_html = extracted_lines.outerContextStart + extracted_lines.innerContextStart +
extracted_lines.html + extracted_lines.innerContextEnd + extracted_lines.outerContextEnd;
diff_html = diffService.diffHtmlToFinalText(diff_html);
return {
"line_from": affected_lines.from,
"line_to": affected_lines.to,
"text": diff_html
};
},
getUnifiedChangeObject: function () {
var paragraph = this.getAmendmentParagraphsByMode("diff")[0];
var affected_lines = diffService.detectAffectedLineRange(paragraph.text);
if (!affected_lines) {
// no changes, no object to use
return null;
}
var extracted_lines = diffService.extractRangeByLineNumbers(paragraph.text, affected_lines.from, affected_lines.to);
var lineLength = Config.get('motions_line_length').value;
var diff_html = diffService.formatDiffWithLineNumbers(extracted_lines, lineLength, affected_lines.from);
var acceptance_state = null;
var rejection_state = null;
this.state.getRecommendations().forEach(function(state) {
if (state.name === "accepted") {
acceptance_state = state.id;
}
if (state.name === "rejected") {
rejection_state = state.id;
}
});
// The interface of this object needs to be synchronized with the same method in MotionChangeRecommendation
//
// The change object needs to be cached to prevent confusing Angular's change detection
// Otherwise, a new object would be created with every call, leading to flickering
var amendment = this;
if (this._change_object === undefined) {
// Properties that are guaranteed to be constant
this._change_object = {
"type": "amendment",
"id": "amendment-" + amendment.id,
"original": amendment,
"saveStatus": function () {
// The status needs to be reset first, as the workflow does not allow changing from
// acceptance to rejection directly or vice-versa.
amendment.setState(null).then(function () {
if (amendment._change_object.accepted) {
amendment.setState(acceptance_state);
}
if (amendment._change_object.rejected) {
amendment.setState(rejection_state);
}
});
},
"getDiff": function (motion, version, highlight) {
if (highlight > 0) {
diff_html = lineNumberingService.highlightLine(diff_html, highlight);
}
return diff_html;
}
};
}
// Properties that might change when the Amendment is edited
this._change_object.line_from = affected_lines.from;
this._change_object.line_to = affected_lines.to;
this._change_object.accepted = false;
this._change_object.rejected = false;
if (this.state && this.state.name === 'rejected') {
this._change_object.rejected = true;
} else if (this.state && this.state.name === 'accepted') {
this._change_object.accepted = true;
} else if (this.recommendation && this.recommendation.name === 'rejected') {
this._change_object.rejected = true;
}
UnifiedChangeObjectCollission.populate(this._change_object);
return this._change_object;
},
setTextStrippingLineBreaks: function (text) {
this.text = lineNumberingService.stripLineNumbers(text);
},
@ -490,6 +865,14 @@ angular.module('OpenSlidesApp.motions', [
}
return MotionStateAndRecommendationParser.parse(name);
},
// ID of the state - or null, if to be reset
setState: function(state_id) {
if (state_id === null) {
return $http.put('/rest/motions/motion/' + this.id + '/set_state/', {});
} else {
return $http.put('/rest/motions/motion/' + this.id + '/set_state/', {'state': state_id});
}
},
// full recommendation string - optional with custom recommendationextension
// depended by state and provided by a custom comment field
getRecommendationName: function () {
@ -506,6 +889,14 @@ angular.module('OpenSlidesApp.motions', [
}
return MotionStateAndRecommendationParser.parse(recommendation);
},
// ID of the state - or null, if to be reset
setRecommendation: function(recommendation_id) {
if (recommendation_id === null) {
return $http.put('/rest/motions/motion/' + this.id + '/set_recommendation/', {});
} else {
return $http.put('/rest/motions/motion/' + this.id + '/set_recommendation/', {'recommendation': recommendation_id});
}
},
// link name which is shown in search result
getSearchResultName: function () {
return this.getTitle();
@ -578,9 +969,42 @@ angular.module('OpenSlidesApp.motions', [
});
return (changes.length > 0 ? changes[0] : null);
},
getAmendments: function () {
return DS.filter('motions/motion', {parent_id: this.id});
},
hasAmendments: function () {
return DS.filter('motions/motion', {parent_id: this.id}).length > 0;
},
getParagraphBasedAmendments: function () {
return DS.filter('motions/motion', {parent_id: this.id}).filter(function(amendment) {
return (amendment.isParagraphBasedAmendment());
});
},
getParagraphBasedAmendmentsForDiffView: function () {
return _.filter(this.getParagraphBasedAmendments(), function(amendment) {
// If no accepted/rejected status is given, only amendments that have a recommendation
// of "accepted" and have not been officially rejected are to be shown in the diff-view
if (amendment.state && amendment.state.name === 'rejected') {
return false;
}
if (amendment.state && amendment.state.name === 'accepted') {
return true;
}
return (amendment.recommendation && amendment.recommendation.name === 'accepted');
});
},
getParentMotion: function () {
if (this.parent_id > 0) {
var parents = DS.filter('motions/motion', {id: this.parent_id});
if (parents.length > 0) {
return parents[0];
} else {
return null;
}
} else {
return null;
}
},
isAllowed: function (action) {
/*
* Return true if the requested user is allowed to do the specific action.
@ -844,6 +1268,23 @@ angular.module('OpenSlidesApp.motions', [
}
);
},
getFormField : function (id) {
var fields = this.getNoSpecialCommentsFields();
var field = fields[id];
if (field) {
return {
key: 'comment_' + id,
type: 'editor',
templateOptions: {
label: field.name,
},
data: {
ckeditorOptions: Editor.getOptions()
},
hide: !operator.hasPerms("motions.can_manage_comments")
};
}
},
populateFields: function (motion) {
// Populate content of motion.comments to the single comment
var fields = this.getCommentsFields();
@ -915,8 +1356,10 @@ angular.module('OpenSlidesApp.motions', [
'jsDataModel',
'diffService',
'lineNumberingService',
'UnifiedChangeObjectCollission',
'gettextCatalog',
function (DS, Config, jsDataModel, diffService, lineNumberingService, gettextCatalog) {
function (DS, Config, jsDataModel, diffService, lineNumberingService,
UnifiedChangeObjectCollission, gettextCatalog) {
return DS.defineResource({
name: 'motions/motion-change-recommendation',
useClass: jsDataModel,
@ -993,12 +1436,89 @@ angular.module('OpenSlidesApp.motions', [
}
title = title.replace('%FROM%', this.line_from).replace('%TO%', (this.line_to - 1));
return title;
},
getUnifiedChangeObject: function () {
// The interface of this object needs to be synchronized with the same method in Motion
//
// The change object needs to be cached to prevent confusing Angular's change detection
// Otherwise, a new object would be created with every call, leading to flickering
var recommendation = this;
if (this._change_object === undefined) {
// Properties that are guaranteed to be constant
this._change_object = {
"type": "recommendation",
"id": "recommendation-" + recommendation.id,
"original": recommendation,
"saveStatus": function () {
recommendation.rejected = recommendation._change_object.rejected;
recommendation.saveStatus();
},
"getDiff": function (motion, version, highlight) {
return recommendation.getDiff(motion, version, highlight);
}
};
}
// Properties that might change when the Change Recommendation is edited
this._change_object.line_from = recommendation.line_from;
this._change_object.line_to = recommendation.line_to;
this._change_object.rejected = recommendation.rejected;
this._change_object.accepted = !recommendation.rejected;
UnifiedChangeObjectCollission.populate(this._change_object);
return this._change_object;
}
}
});
}
])
.factory('UnifiedChangeObjectCollission', [
function () {
return {
populate: function (obj) {
obj.otherChanges = [];
obj.setOtherChangesForCollission = function (changes) {
obj.otherChanges = changes;
};
obj.getCollissions = function(onlyAccepted) {
return obj.otherChanges.filter(function(otherChange) {
if (onlyAccepted && !otherChange.accepted) {
return false;
}
return (otherChange.id !== obj.id && (
(otherChange.line_from >= obj.line_from && otherChange.line_from < obj.line_to) ||
(otherChange.line_to > obj.line_from && otherChange.line_to <= obj.line_to) ||
(otherChange.line_from < obj.line_from && otherChange.line_to > obj.line_to)
));
});
};
obj.getAcceptedCollissions = function() {
return obj.getCollissions().filter(function(colliding) {
return colliding.accepted;
});
};
obj.setAccepted = function($event) {
if (obj.getAcceptedCollissions().length > 0) {
$event.preventDefault();
$event.stopPropagation();
return;
}
obj.accepted = true;
obj.rejected = false;
obj.saveStatus();
};
obj.setRejected = function($event) {
obj.rejected = true;
obj.accepted = false;
obj.saveStatus();
};
},
};
}
])
.run([
'Motion',
'Category',

View File

@ -127,6 +127,84 @@ angular.module('OpenSlidesApp.motions.csv', [])
},
};
}
])
.factory('AmendmentCsvExport', [
'gettextCatalog',
'CsvDownload',
'lineNumberingService',
function (gettextCatalog, CsvDownload, lineNumberingService) {
var makeHeaderline = function () {
var headerline = ['Identifier', 'Submitters', 'Category', 'Motion block',
'Leadmotion', 'Line', 'Old text', 'New text'];
return _.map(headerline, function (entry) {
return gettextCatalog.getString(entry);
});
};
return {
export: function (amendments) {
var csvRows = [
makeHeaderline()
];
_.forEach(amendments, function (amendment) {
var row = [];
// Identifier and title
row.push('"' + amendment.identifier !== null ? amendment.identifier : '' + '"');
// Submitters
var submitters = [];
angular.forEach(amendment.submitters, function(user) {
var user_short_name = [user.title, user.first_name, user.last_name].join(' ').trim();
submitters.push(user_short_name);
});
row.push('"' + submitters.join('; ') + '"');
// Category
var category = amendment.category ? amendment.category.name : '';
row.push('"' + category + '"');
// Motion block
var blockTitle = amendment.motionBlock ? amendment.motionBlock.title : '';
row.push('"' + blockTitle + '"');
// Lead motion
var leadmotion = amendment.getParentMotion();
if (leadmotion) {
var leadmotionTitle = leadmotion.identifier ? leadmotion.identifier + ': ' : '';
leadmotionTitle += leadmotion.getTitle();
row.push('"' + leadmotionTitle + '"');
} else {
row.push('""');
}
// changed paragraph
if (amendment.isParagraphBasedAmendment()) {
// TODO: get old and new paragraphLine. Resolve todo
// in motion.getAmendmentParagraphsLinesByMode
var p_old = amendment.getAmendmentParagraphsLinesByMode('original', null, false)[0];
//var p_new = amendment.getAmendmentParagraphsLinesByMode('changed', null, false)[0];
var lineStr = p_old.diffLineFrom;
if (p_old.diffLineTo != p_old.diffLineFrom + 1) {
lineStr += '-' + p_old.diffLineTo;
}
row.push('"' + lineStr + '"');
//row.push('"' + p_old.text.html + '"');
//row.push('"' + p_new.text.html + '"');
// Work around: Export the full paragraphs instead of changed lines
row.push('"' + amendment.getAmendmentParagraphsByMode('original', null, false)[0].text + '"');
row.push('"' + amendment.getAmendmentParagraphsByMode('changed', null, false)[0].text + '"');
} else {
row.push('""');
row.push('""');
row.push('"' + amendment.getText() + '"');
}
csvRows.push(row);
});
CsvDownload(csvRows, 'amendments-export.csv');
},
};
}
]);
}());

View File

@ -23,6 +23,42 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
return fragment.querySelector('os-linebreak.os-line-number.line-number-' + lineNumber);
};
/**
* @param {Element} element
*/
this._getFirstLineNumberNode = function(element) {
if (element.nodeType === TEXT_NODE) {
return null;
}
if (element.nodeName === 'OS-LINEBREAK') {
return element;
}
var found = element.querySelectorAll('OS-LINEBREAK');
if (found.length > 0) {
return found.item(0);
} else {
return null;
}
};
/**
* @param {Element} element
*/
this._getLastLineNumberNode = function(element) {
if (element.nodeType === TEXT_NODE) {
return null;
}
if (element.nodeName === 'OS-LINEBREAK') {
return element;
}
var found = element.querySelectorAll('OS-LINEBREAK');
if (found.length > 0) {
return found.item(found.length - 1);
} else {
return null;
}
};
this._getNodeContextTrace = function(node) {
var context = [],
currNode = node;
@ -169,14 +205,14 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
};
this._serializeTag = function(node) {
if (node.nodeType == DOCUMENT_FRAGMENT_NODE) {
if (node.nodeType === DOCUMENT_FRAGMENT_NODE) {
// Fragments are only placeholders and do not have an HTML representation
return '';
}
var html = '<' + node.nodeName;
for (var i = 0; i < node.attributes.length; i++) {
var attr = node.attributes[i];
if (attr.name != 'os-li-number') {
if (attr.name !== 'os-li-number') {
html += ' ' + attr.name + '="' + attr.value + '"';
}
}
@ -226,21 +262,21 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
if (lineNumberingService._isOsLineNumberNode(node) || lineNumberingService._isOsLineBreakNode(node)) {
return '';
}
if (node.nodeName == 'OS-LINEBREAK') {
if (node.nodeName === 'OS-LINEBREAK') {
return '';
}
var html = this._serializeTag(node);
for (var i = 0, found = false; i < node.childNodes.length && !found; i++) {
if (node.childNodes[i] == toChildTrace[0]) {
if (node.childNodes[i] === toChildTrace[0]) {
found = true;
var remainingTrace = toChildTrace;
remainingTrace.shift();
if (!lineNumberingService._isOsLineNumberNode(node.childNodes[i])) {
html += this._serializePartialDomToChild(node.childNodes[i], remainingTrace, stripLineNumbers);
}
} else if (node.childNodes[i].nodeType == TEXT_NODE) {
} else if (node.childNodes[i].nodeType === TEXT_NODE) {
html += node.childNodes[i].nodeValue;
} else {
if (!stripLineNumbers || (!lineNumberingService._isOsLineNumberNode(node.childNodes[i]) &&
@ -263,13 +299,13 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
if (lineNumberingService._isOsLineNumberNode(node) || lineNumberingService._isOsLineBreakNode(node)) {
return '';
}
if (node.nodeName == 'OS-LINEBREAK') {
if (node.nodeName === 'OS-LINEBREAK') {
return '';
}
var html = '';
for (var i = 0, found = false; i < node.childNodes.length; i++) {
if (node.childNodes[i] == fromChildTrace[0]) {
if (node.childNodes[i] === fromChildTrace[0]) {
found = true;
var remainingTrace = fromChildTrace;
remainingTrace.shift();
@ -277,7 +313,7 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
html += this._serializePartialDomFromChild(node.childNodes[i], remainingTrace, stripLineNumbers);
}
} else if (found) {
if (node.childNodes[i].nodeType == TEXT_NODE) {
if (node.childNodes[i].nodeType === TEXT_NODE) {
html += node.childNodes[i].nodeValue;
} else {
if (!stripLineNumbers || (!lineNumberingService._isOsLineNumberNode(node.childNodes[i]) &&
@ -291,12 +327,16 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
console.trace();
throw "Inconsistency or invalid call of this function detected (from)";
}
if (node.nodeType != DOCUMENT_FRAGMENT_NODE) {
if (node.nodeType !== DOCUMENT_FRAGMENT_NODE) {
html += '</' + node.nodeName + '>';
}
return html;
};
/**
* @param {string} html
* @return {DocumentFragment}
*/
this.htmlToFragment = function(html) {
var fragment = document.createDocumentFragment(),
div = document.createElement('DIV');
@ -332,7 +372,7 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
* Returns the HTML snippet between two given line numbers.
*
* Hint:
* - The last line (toLine) is not included anymore, as the number refers to the line breaking element
* - The last line (toLine) is not included anymore, as the number refers to the line breaking element at the end of the line
* - if toLine === null, then everything from fromLine to the end of the fragment is returned
*
* In addition to the HTML snippet, additional information is provided regarding the most specific DOM element
@ -408,7 +448,6 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
followingHtmlStartSnippet = '',
fakeOl, offset;
fromChildTraceAbs.shift();
var previousHtml = this._serializePartialDomToChild(fragment, fromChildTraceAbs, false);
toChildTraceAbs.shift();
@ -526,6 +565,16 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
return ret;
};
/*
* Convenience method that takes the html-attribute from an extractRangeByLineNumbers()-method,
* wraps it with the context and adds line numbers.
*/
this.formatDiffWithLineNumbers = function(diff, lineLength, firstLine) {
var text = diff.outerContextStart + diff.innerContextStart + diff.html + diff.innerContextEnd + diff.outerContextEnd;
text = lineNumberingService.insertLineNumbers(text, lineLength, null, null, firstLine);
return text;
};
/*
* This is a workardoun to prevent the last word of the inserted text from accidently being merged with the
* first word of the following line.
@ -537,13 +586,13 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
this._insertDanglingSpace = function(element) {
if (element.childNodes.length > 0) {
var lastChild = element.childNodes[element.childNodes.length - 1];
if (lastChild.nodeType == TEXT_NODE && !lastChild.nodeValue.match(/[\S]/) && element.childNodes.length > 1) {
if (lastChild.nodeType === TEXT_NODE && !lastChild.nodeValue.match(/[\S]/) && element.childNodes.length > 1) {
// If the text node only contains whitespaces, chances are high it's just space between block elmeents,
// like a line break between </LI> and </UL>
lastChild = element.childNodes[element.childNodes.length - 2];
}
if (lastChild.nodeType == TEXT_NODE) {
if (lastChild.nodeValue === '' || lastChild.nodeValue.substr(-1) != ' ') {
if (lastChild.nodeType === TEXT_NODE) {
if (lastChild.nodeValue === '' || lastChild.nodeValue.substr(-1) !== ' ') {
lastChild.nodeValue += ' ';
}
} else {
@ -674,7 +723,13 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
'&Auml;': 'Ä',
'&Ouml;': 'Ö',
'&Uuml;': 'Ü',
'&szlig;': 'ß'
'&szlig;': 'ß',
'&bdquo;': '„',
'&ldquo;': '“',
'&bull;': '•',
'&sect;': '§',
'&eacute;': 'é',
'&euro;': '€'
};
html = html.replace(/\s+<\/P>/gi, '</P>').replace(/\s+<\/DIV>/gi, '</DIV>').replace(/\s+<\/LI>/gi, '</LI>');
@ -693,6 +748,111 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
return html;
};
this._getAllNextSiblings = function(element) {
var elements = [];
while (element.nextSibling) {
elements.push(element.nextSibling);
element = element.nextSibling;
}
return elements;
};
this._getAllPrevSiblingsReversed = function(element) {
var elements = [];
while (element.previousSibling) {
elements.push(element.previousSibling);
element = element.previousSibling;
}
return elements;
};
/**
* This returns the line number range in which changes (insertions, deletions) are encountered.
* As in extractRangeByLineNumbers(), "to" refers to the line breaking element at the end, i.e. the start of the following line.
*
* @param {string} diffHtml
*/
this.detectAffectedLineRange = function (diffHtml) {
var cacheKey = lineNumberingService.djb2hash(diffHtml),
cached = diffCache.get(cacheKey);
if (!angular.isUndefined(cached)) {
return cached;
}
var fragment = this.htmlToFragment(diffHtml);
this._insertInternalLineMarkers(fragment);
this._insertInternalLiNumbers(fragment);
var changes = fragment.querySelectorAll('ins, del, .insert, .delete'),
firstChange = changes.item(0),
lastChange = changes.item(changes.length - 1),
i, j;
if (!firstChange || !lastChange) {
// There are no changes
return null;
}
var firstTrace = this._getNodeContextTrace(firstChange),
lastLineNumberBefore = null;
for (j = firstTrace.length - 1; j >= 0 && lastLineNumberBefore === null; j--) {
var prevSiblings = this._getAllPrevSiblingsReversed(firstTrace[j]);
for (i = 0; i < prevSiblings.length && lastLineNumberBefore === null; i++) {
lastLineNumberBefore = this._getLastLineNumberNode(prevSiblings[i]);
}
}
var lastTrace = this._getNodeContextTrace(lastChange),
firstLineNumberAfter = null;
for (j = lastTrace.length - 1; j >= 0 && firstLineNumberAfter === null; j--) {
var nextSiblings = this._getAllNextSiblings(lastTrace[j]);
for (i = 0; i < nextSiblings.length && firstLineNumberAfter === null; i++) {
firstLineNumberAfter = this._getFirstLineNumberNode(nextSiblings[i]);
}
}
var range = {
"from": parseInt(lastLineNumberBefore.getAttribute("data-line-number")),
"to": parseInt(firstLineNumberAfter.getAttribute("data-line-number"))
};
diffCache.put(cacheKey, range);
return range;
};
/**
* Removes .delete-nodes and <del>-Tags (including content)
* Removes the .insert-classes and the wrapping <ins>-Tags (while maintaining content)
* @param html
*/
this.diffHtmlToFinalText = function(html) {
var fragment = this.htmlToFragment(html);
var delNodes = fragment.querySelectorAll('.delete, del');
for (var i = 0; i < delNodes.length; i++) {
delNodes[i].parentNode.removeChild(delNodes[i]);
}
var insNodes = fragment.querySelectorAll('ins');
for (i = 0; i < insNodes.length; i++) {
var ins = insNodes[i];
while (ins.childNodes.length > 0) {
var child = ins.childNodes.item(0);
ins.removeChild(child);
ins.parentNode.insertBefore(child, ins);
}
ins.parentNode.removeChild(ins);
}
var insertNodes = fragment.querySelectorAll('.insert');
for (i = 0;i < insertNodes.length; i++) {
this.removeCSSClass(insertNodes[i], 'insert');
}
return this._serializeDom(fragment, false);
};
/**
* @param {string} htmlOld
* @param {string} htmlNew
@ -702,13 +862,13 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
htmlOld = this._normalizeHtmlForDiff(htmlOld);
htmlNew = this._normalizeHtmlForDiff(htmlNew);
if (htmlOld == htmlNew) {
if (htmlOld === htmlNew) {
return this.TYPE_REPLACEMENT;
}
var i, foundDiff;
for (i = 0, foundDiff = false; i < htmlOld.length && i < htmlNew.length && foundDiff === false; i++) {
if (htmlOld[i] != htmlNew[i]) {
if (htmlOld[i] !== htmlNew[i]) {
foundDiff = true;
}
}
@ -718,11 +878,11 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
type = this.TYPE_REPLACEMENT;
if (remainderOld.length > remainderNew.length) {
if (remainderOld.substr(remainderOld.length - remainderNew.length) == remainderNew) {
if (remainderOld.substr(remainderOld.length - remainderNew.length) === remainderNew) {
type = this.TYPE_DELETION;
}
} else if (remainderOld.length < remainderNew.length) {
if (remainderNew.substr(remainderNew.length - remainderOld.length) == remainderOld) {
if (remainderNew.substr(remainderNew.length - remainderOld.length) === remainderOld) {
type = this.TYPE_INSERTION;
}
}
@ -867,7 +1027,7 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
}
for (i in ns) {
if (ns[i].rows.length == 1 && typeof(os[i]) != "undefined" && os[i].rows.length == 1) {
if (ns[i].rows.length === 1 && typeof(os[i]) !== "undefined" && os[i].rows.length === 1) {
newArr[ns[i].rows[0]] = {text: newArr[ns[i].rows[0]], row: os[i].rows[0]};
oldArr[os[i].rows[0]] = {text: oldArr[os[i].rows[0]], row: ns[i].rows[0]};
}
@ -1034,28 +1194,42 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
};
/**
*
* @param {string} html
* @return {boolean}
* @private
*/
this._isValidInlineHtml = function(html) {
// If there are no HTML tags, we assume it's valid and skip further checks
if (!html.match(/<[^>]*>/)) {
return true;
}
// We check if this is a valid HTML that closes all its tags again using the innerHTML-Hack to correct
// the string and check if the number of HTML tags changes by this
var doc = document.createElement('div');
doc.innerHTML = html;
var tagsBefore = (html.match(/</g) || []).length;
var tagsCorrected = (doc.innerHTML.match(/</g) || []).length;
if (tagsBefore !== tagsCorrected) {
// The HTML has changed => it was not valid
return false;
}
// If there is any block element inside, we consider it as broken, as this string will be displayed
// inside of <ins>/<del> tags
if (html.match(/<(div|p|ul|li|blockquote)\W/i)) {
return false;
}
return true;
};
/**
* @param {string} html
* @returns {boolean}
* @private
*/
this._diffDetectBrokenDiffHtml = function(html) {
// If a regular HTML tag is enclosed by INS/DEL, the HTML is broken
var match = html.match(/<(ins|del)><[^>]*><\/(ins|del)>/gi);
if (match !== null && match.length > 0) {
return true;
}
// Opening tags, followed by </del> or </ins>, indicate broken HTML (if it's not a <ins> / <del>)
var brokenRegexp = /<(\w+)[^>]*><\/(ins|del)>/gi,
result;
while ((result = brokenRegexp.exec(html)) !== null) {
if (result[1].toLowerCase() !== 'ins' && result[1].toLowerCase() !== 'del') {
return true;
}
}
// If other HTML tags are contained within INS/DEL (e.g. "<ins>Test</p></ins>"), let's better be cautious
// The "!!(found=...)"-construction is only used to make jshint happy :)
var findDel = /<del>(.*?)<\/del>/gi,
@ -1069,7 +1243,7 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
}
while (!!(found = findIns.exec(html))) {
inner = found[1].replace(/<br[^>]*>/gi, '');
if (inner.match(/<[^>]*>/)) {
if (!this._isValidInlineHtml(inner)) {
return true;
}
}
@ -1194,14 +1368,55 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
}
};
/**
* This fixes a very specific, really weird bug that is tested in the test case "does not a change in a very specific case".
*
* @param {string}diffStr
* @return {string}
* @private
*/
this._fixWrongChangeDetection = function (diffStr) {
if (diffStr.indexOf('<del>') === -1 || diffStr.indexOf('<ins>') === -1) {
return diffStr;
}
var findDelGroupFinder = /(?:<del>.*?<\/del>)+/gi,
found,
returnStr = diffStr;
while (!!(found = findDelGroupFinder.exec(diffStr))) {
var del = found[0],
split = returnStr.split(del);
var findInsGroupFinder = /^(?:<ins>.*?<\/ins>)+/gi,
foundIns = findInsGroupFinder.exec(split[1]);
if (foundIns) {
var ins = foundIns[0];
var delShortened = del.replace(
/<del>((<BR CLASS="os-line-break"><\/del><del>)?(<span[^>]+os-line-number[^>]+?>)(\s|<\/?del>)*<\/span>)<\/del>/gi,
''
).replace(/<\/del><del>/g, '');
var insConv = ins.replace(/<ins>/g, '<del>').replace(/<\/ins>/g, '</del>').replace(/<\/del><del>/g, '');
if (delShortened.indexOf(insConv) !== -1) {
delShortened = delShortened.replace(insConv, '');
if (delShortened === '') {
returnStr = returnStr.replace(del + ins, del.replace(/<del>/g, '').replace(/<\/del>/g, ''));
}
}
}
}
return returnStr;
};
/**
* This function calculates the diff between two strings and tries to fix problems with the resulting HTML.
* If lineLength and firstLineNumber is given, line numbers will be returned es well
*
* @param {number} lineLength
* @param {number} firstLineNumber
* @param {string} htmlOld
* @param {string} htmlNew
* @param {number} lineLength - optional
* @param {number} firstLineNumber - optional
* @returns {string}
*/
this.diff = function (htmlOld, htmlNew, lineLength, firstLineNumber) {
@ -1241,6 +1456,9 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
var str = this._diffString(workaroundPrepend + htmlOld, workaroundPrepend + htmlNew),
diffUnnormalized = str.replace(/^\s+/g, '').replace(/\s+$/g, '').replace(/ {2,}/g, ' ');
diffUnnormalized = this._fixWrongChangeDetection(diffUnnormalized);
// Remove <del> tags that only delete line numbers
// We need to do this before removing </del><del> as done in one of the next statements
diffUnnormalized = diffUnnormalized.replace(
@ -1287,7 +1505,7 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
remainderOld = oldText, remainderNew = newText;
while (remainderOld.length > 0 && remainderNew.length > 0 && !foundDiff) {
if (remainderOld[0] == remainderNew[0]) {
if (remainderOld[0] === remainderNew[0]) {
commonStart += remainderOld[0];
remainderOld = remainderOld.substr(1);
remainderNew = remainderNew.substr(1);
@ -1298,7 +1516,7 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
foundDiff = false;
while (remainderOld.length > 0 && remainderNew.length > 0 && !foundDiff) {
if (remainderOld[remainderOld.length - 1] == remainderNew[remainderNew.length - 1]) {
if (remainderOld[remainderOld.length - 1] === remainderNew[remainderNew.length - 1]) {
commonEnd = remainderOld[remainderOld.length - 1] + commonEnd;
remainderNew = remainderNew.substr(0, remainderNew.length - 1);
remainderOld = remainderOld.substr(0, remainderOld.length - 1);

View File

@ -75,7 +75,7 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
this._isOsLineBreakNode = function (node) {
var isLineBreak = false;
if (node && node.nodeType === ELEMENT_NODE && node.nodeName == 'BR' && node.hasAttribute('class')) {
if (node && node.nodeType === ELEMENT_NODE && node.nodeName === 'BR' && node.hasAttribute('class')) {
var classes = node.getAttribute('class').split(' ');
if (classes.indexOf('os-line-break') > -1) {
isLineBreak = true;
@ -86,7 +86,7 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
this._isOsLineNumberNode = function (node) {
var isLineNumber = false;
if (node && node.nodeType === ELEMENT_NODE && node.nodeName == 'SPAN' && node.hasAttribute('class')) {
if (node && node.nodeType === ELEMENT_NODE && node.nodeName === 'SPAN' && node.hasAttribute('class')) {
var classes = node.getAttribute('class').split(' ');
if (classes.indexOf('os-line-number') > -1) {
isLineNumber = true;
@ -189,13 +189,16 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
out.push(node);
return node;
};
var addLinebreakToPreviousNode = function (node, offset, highlight) {
var addLinebreakToPreviousNode = function (node, offset) {
var firstText = node.nodeValue.substr(0, offset + 1),
secondText = node.nodeValue.substr(offset + 1);
var lineBreak = service._createLineBreak();
var firstNode = document.createTextNode(firstText);
node.parentNode.insertBefore(firstNode, node);
node.parentNode.insertBefore(lineBreak, node);
if (service._currentLineNumber !== null) {
node.parentNode.insertBefore(service._createLineNumber(), node);
}
node.nodeValue = secondText;
};
@ -244,7 +247,7 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
} else {
// The last possible breaking point was not in this text not, but one we have already passed
var remainderOfPrev = lineBreakAt.node.nodeValue.length - lineBreakAt.offset - 1;
addLinebreakToPreviousNode(lineBreakAt.node, lineBreakAt.offset, highlight);
addLinebreakToPreviousNode(lineBreakAt.node, lineBreakAt.offset);
this._currentInlineOffset = i + remainderOfPrev;
this._lastInlineBreakablePoint = null;
@ -298,7 +301,7 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
if (!node.firstChild) {
return 0;
}
if (node.firstChild.nodeType == TEXT_NODE) {
if (node.firstChild.nodeType === TEXT_NODE) {
var parts = node.firstChild.nodeValue.split(' ');
return parts[0].length;
} else {
@ -317,12 +320,12 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
}
for (i = 0; i < oldChildren.length; i++) {
if (oldChildren[i].nodeType == TEXT_NODE) {
if (oldChildren[i].nodeType === TEXT_NODE) {
var ret = this._textNodeToLines(oldChildren[i], length, highlight);
for (var j = 0; j < ret.length; j++) {
node.appendChild(ret[j]);
}
} else if (oldChildren[i].nodeType == ELEMENT_NODE) {
} else if (oldChildren[i].nodeType === ELEMENT_NODE) {
var firstword = this._lengthOfFirstInlineWord(oldChildren[i]),
overlength = ((this._currentInlineOffset + firstword) > length && this._currentInlineOffset > 0);
if (overlength && this._isInlineElement(oldChildren[i])) {
@ -399,7 +402,7 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
}
for (i = 0; i < oldChildren.length; i++) {
if (oldChildren[i].nodeType == TEXT_NODE) {
if (oldChildren[i].nodeType === TEXT_NODE) {
if (!oldChildren[i].nodeValue.match(/\S/)) {
// White space nodes between block elements should be ignored
var prevIsBlock = (i > 0 && !this._isInlineElement(oldChildren[i - 1]));
@ -413,7 +416,7 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
for (var j = 0; j < ret.length; j++) {
node.appendChild(ret[j]);
}
} else if (oldChildren[i].nodeType == ELEMENT_NODE) {
} else if (oldChildren[i].nodeType === ELEMENT_NODE) {
var firstword = this._lengthOfFirstInlineWord(oldChildren[i]),
overlength = ((this._currentInlineOffset + firstword) > length && this._currentInlineOffset > 0);
if (overlength && this._isInlineElement(oldChildren[i]) && !this._isIgnoredByLineNumbering(oldChildren[i])) {
@ -491,7 +494,7 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
/**
*
* @param {string} html
* @param {number} lineLength
* @param {number|string} lineLength
* @param {number|null} highlight - optional
* @param {number|null} firstLine
*/
@ -588,6 +591,115 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
return root.innerHTML;
};
/**
* @param {string} html
* @returns {object}
* {"from": 23, "to": 42} ; "to" refers to the line breaking element at the end of the last line,
* i.e. the line number of the following line
*/
this.getLineNumberRange = function (html) {
var fragment = this._htmlToFragment(html),
range = {
"from": null,
"to": null
};
var lineNumbers = fragment.querySelectorAll('.os-line-number');
for (var i = 0; i < lineNumbers.length; i++) {
var node = lineNumbers.item(i);
var number = parseInt(node.getAttribute("data-line-number"));
if (range.from === null || number < range.from) {
range.from = number;
}
if (range.to === null || (number + 1) > range.to) {
range.to = number + 1;
}
}
return range;
};
/**
* @param {string} html
*/
this.getHeadingsWithLineNumbers = function (html) {
var fragment = this._htmlToFragment(html),
headings = [];
var headingNodes = fragment.querySelectorAll('h1, h2, h3, h4, h5, h6');
for (var i = 0; i < headingNodes.length; i++) {
var heading = headingNodes.item(i);
var linenumbers = heading.querySelectorAll('.os-line-number');
if (linenumbers.length > 0) {
var number = parseInt(linenumbers.item(0).getAttribute("data-line-number"));
headings.push({
"lineNumber": number,
"level": parseInt(heading.nodeName.substr(1)),
"text": heading.innerText.replace(/^\s/, "").replace(/\s$/, "")
});
}
}
return headings.sort(function(heading1, heading2) {
if (heading1.lineNumber < heading2.lineNumber) {
return 0;
} else if (heading1.lineNumber > heading2.lineNumber) {
return 1;
} else {
return 0;
}
});
};
/**
* @param {Element} node
* @returns {array}
* @private
*/
this._splitNodeToParagraphs = function (node) {
var elements = [];
for (var i = 0; i < node.childNodes.length; i++) {
var childNode = node.childNodes.item(i);
if (childNode.nodeType === TEXT_NODE) {
continue;
}
if (childNode.nodeName === 'UL' || childNode.nodeName === 'OL') {
var start = 1;
if (childNode.getAttribute("start") !== null) {
start = parseInt(childNode.getAttribute("start"));
}
for (var j = 0; j < childNode.childNodes.length; j++) {
if (childNode.childNodes.item(j).nodeType === TEXT_NODE) {
continue;
}
var newParent = childNode.cloneNode(false);
if (childNode.nodeName === 'OL') {
newParent.setAttribute('start', start);
}
newParent.appendChild(childNode.childNodes.item(j).cloneNode(true));
elements.push(newParent);
start++;
}
} else {
elements.push(childNode);
}
}
return elements;
};
/**
* Splitting the text into paragraphs:
* - Each root-level-element is considered as a paragraph.
* Inline-elements at root-level are not expected and treated as block elements.
* Text-nodes at root-level are not expected and ignored. Every text needs to be wrapped e.g. by <p> or <div>.
* - If a UL or OL is encountered, paragraphs are defined by the child-LI-elements.
* List items of nested lists are not considered as a paragraph of their own.
*
* @param {string} html
* @return {string[]}
*/
this.splitToParagraphs = function (html) {
var fragment = this._htmlToFragment(html);
return this._splitNodeToParagraphs(fragment).map(function(node) { return node.outerHTML; });
};
/**
* Traverses up the DOM tree until it finds a node with a nextSibling, then returns that sibling
*

View File

@ -391,10 +391,11 @@ angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions',
'$interval',
'$timeout',
function (Motion, MotionChangeRecommendation, Config, lineNumberingService, diffService, $interval, $timeout) {
var $scope;
var $scope, motion;
var obj = {
mode: 'original'
mode: 'original',
context: null
};
obj.diffFormatterCb = function (change, oldFragment, newFragment) {
@ -430,7 +431,7 @@ angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions',
MotionChangeRecommendation.destroy(changeId);
};
obj.rejectAll = function (motion) {
obj.rejectAllChangeRecommendations = function (motion) {
var changeRecommendations = MotionChangeRecommendation.filter({
'where': {'motion_version_id': {'==': motion.active_version}}
});
@ -508,8 +509,99 @@ angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions',
}, 0, false);
};
obj.init = function (_scope, viewMode) {
// $scope.amendments_crs holds the change objects of all change recommendations regarding the text,
// and all amendments with a "accepted"-recommendation, ordered by the first affected line number.
obj.set_amendments_crs_watcher = function($scope, motion) {
$scope.amendments_crs = [];
$scope.change_recommendations = [];
$scope.paragraph_amendments = [];
$scope.has_proposed_changes = false;
$scope.changed_version_has_collissions = false;
var rebuild_amendments_crs = function () {
$scope.amendments_crs = $scope.change_recommendations.map(function (cr) {
return cr.getUnifiedChangeObject();
}).concat(
$scope.paragraph_amendments.map(function (amendment) {
return amendment.getUnifiedChangeObject();
})
);
$scope.amendments_crs.sort(function (change1, change2) {
if (change1.line_from > change2.line_from) {
return 1;
} else if (change1.line_from < change2.line_from) {
return -1;
} else {
return 0;
}
});
// Set all crs and amendments for collission detection.
_.forEach($scope.amendments_crs, function (change) {
change.setOtherChangesForCollission($scope.amendments_crs);
});
$scope.has_proposed_changes = ($scope.amendments_crs.length > 0);
$scope.changed_version_has_accepted_collissions = ($scope.amendments_crs.find(function(change) {
return (change.getCollissions(true).length !== 0);
}) !== undefined);
if (obj.context === 'site') {
if (!$scope.has_proposed_changes) {
$scope.setProjectionMode($scope.projectionModes[0]);
}
if ($scope.has_proposed_changes) {
$scope.disableMotionInlineEditing();
}
}
};
$scope.$watch(function () {
return MotionChangeRecommendation.lastModified();
}, function () {
$scope.change_recommendations = [];
$scope.title_change_recommendation = null;
MotionChangeRecommendation.filter({
'where': {'motion_version_id': {'==': motion.active_version}}
}).forEach(function (change) {
if (change.isTextRecommendation()) {
$scope.change_recommendations.push(change);
}
if (change.isTitleRecommendation()) {
$scope.title_change_recommendation = change;
}
});
rebuild_amendments_crs();
});
$scope.$watch(function () {
return Motion.lastModified();
}, function () {
$scope.paragraph_amendments = motion.getParagraphBasedAmendmentsForDiffView();
rebuild_amendments_crs();
});
};
obj.setVersion = function (_motion/*, _version*/) {
motion = _motion;
};
obj.initProjector = function (_scope, _motion, viewMode) {
obj.context = 'projector';
$scope = _scope;
motion = _motion;
obj.set_amendments_crs_watcher($scope, motion);
obj.mode = viewMode;
};
obj.initSite = function (_scope, _motion, viewMode) {
obj.context = 'site';
$scope = _scope;
motion = _motion;
obj.set_amendments_crs_watcher($scope, motion);
$scope.$evalAsync(function() {
obj.repositionOriginalAnnotations();
});
@ -518,12 +610,12 @@ angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions',
}, obj.repositionOriginalAnnotations);
var checkGotoOriginal = function () {
if ($scope.change_recommendations.length === 0 && $scope.title_change_recommendation === null) {
if ($scope.amendments_crs.length === 0 && $scope.title_change_recommendation === null) {
obj.mode = 'original';
}
};
$scope.$watch(function () {
return $scope.change_recommendations.length;
return $scope.amendments_crs.length;
}, checkGotoOriginal);
$scope.$watch(function () {
return $scope.title_change_recommendation;
@ -535,7 +627,7 @@ angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions',
var $holder = $(".motion-text-original"),
newHeight = $holder.height(),
classes = $holder.attr("class");
if (newHeight != sizeCheckerLastSize || sizeCheckerLastClass != classes) {
if (newHeight !== sizeCheckerLastSize || sizeCheckerLastClass !== classes) {
sizeCheckerLastSize = newHeight;
sizeCheckerLastClass = classes;
obj.repositionOriginalAnnotations();

View File

@ -327,7 +327,7 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf'])
// motion title
var motionTitle = function() {
if (params.include.text) {
if (params.include.text && !motion.isParagraphBasedAmendment()) {
return [{
text: titlePlain,
style: 'heading3'
@ -337,30 +337,45 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf'])
// motion preamble
var motionPreamble = function () {
if (params.include.text) {
return {
text: Config.translate(Config.get('motions_preamble').value),
margin: [0, 10, 0, 0]
};
}
};
var escapeHtml = function(text) {
return text.replace(/&/, "&amp;").replace(/</, "&lt;").replace(/>/, "&gt;");
return text.replace(/&/, '&amp;').replace(/</, '&lt;').replace(/>/, '&gt;');
};
// motion text (with line-numbers)
var motionText = function() {
var content = [];
if (params.include.text) {
var motionTextContent = '';
if (motion.isParagraphBasedAmendment()) {
// paragraph based amendment
var diffs = motion.getAmendmentParagraphsLinesDiff();
if (diffs.length) {
content.push(motionPreamble());
_.forEach(diffs, function (diff) {
motionTextContent += diff.textPre + diff.text + diff.textPost;
});
} else {
motionTextContent += gettextCatalog.getString('No changes at the text.');
}
} else {
// lead motion or normal amendment
content.push(motionPreamble());
var titleChange = motion.getTitleChangeRecommendation();
if (params.changeRecommendationMode === 'diff' && titleChange) {
motionTextContent += '<p><strong>' + gettextCatalog.getString('New title') + ':</strong> ' +
escapeHtml(titleChange.text) + '</p>';
}
motionTextContent += motion.getTextByMode(params.changeRecommendationMode, motionVersion);
return converter.convertHTML(motionTextContent, params.lineNumberMode);
}
content.push(converter.convertHTML(motionTextContent, params.lineNumberMode));
}
return content;
};
// motion reason heading
@ -421,10 +436,10 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf'])
title,
subtitle,
metaTable(),
motionTitle(),
motionPreamble(),
motionText(),
motionTitle()
];
content = content.concat(motionText());
var reason = motionReason();
if (reason) {
content.push(reason);
@ -965,6 +980,210 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf'])
}
])
.factory('AmendmentContentProvider', [
'$q',
'ImageConverter',
'PdfMakeConverter',
'HTMLValidizer',
'PDFLayout',
'Config',
'gettextCatalog',
function ($q, ImageConverter, PdfMakeConverter, HTMLValidizer, PDFLayout, Config, gettextCatalog) {
var createInstance = function (motions) {
motions = _.filter(motions, function (motion) {
return motion.parent_id;
});
var converter, imageMap = {};
// Query all image sources from motion text and reason
var getImageSources = function () {
var sources = [];
_.forEach(motions, function (motion) {
var text = motion.getText();
var reason = motion.getReason();
var content = HTMLValidizer.validize(text) + HTMLValidizer.validize(motion.getReason());
_.forEach($(content).find('img'), function (element) {
sources.push(element.getAttribute('src'));
});
});
return _.uniq(sources);
};
var createBundleContent = function (bundle) {
return _.flatten(_.map(bundle, function (motion) {
var content = [];
// get diffs and title of the changed motions
var motionText;
var title = motion.identifier ? gettextCatalog.getString('Motion') + ' ' + motion.identifier : motion.getTitle();
if (motion.isParagraphBasedAmendment()) {
// get changed parts
var paragraphs = motion.getAmendmentParagraphsLinesDiff();
if (paragraphs.length) {
// Put the changed lines into the info column
var p = paragraphs[0];
title += ' (' + gettextCatalog.getString('Line') + ' ';
if (p.diffLineTo === p.diffLineFrom + 1) {
title += p.diffLineFrom;
} else {
title += p.diffLineFrom + '-' + p.diffLineTo;
}
title += ')';
// get the diff
motionText = p.text;
} else {
motionText = gettextCatalog.getString('No changes at the text.');
}
} else { // 'normal' amendment
motionText = motion.getText();
}
content.push({
text: title,
style: 'heading3',
marginTop: 15,
});
// submitters
var submitters = _.map(motion.submitters, function (submitter) {
return submitter.get_full_name();
}).join(', ');
content.push({
text: gettextCatalog.getString('Submitters') + ': ' + submitters,
});
// state
content.push({
text: gettextCatalog.getString('State') + ': ' + motion.getStateName(),
});
// recommendation
var recommendations_by = Config.get('motions_recommendations_by').value;
var recommendation = motion.getRecommendationName();
if (recommendations_by && recommendation) {
content.push({
text: recommendations_by + ': ' + recommendation,
});
}
return _.concat(content, converter.convertHTML(motionText, 'outside'));
}));
};
var getBundleContent = function (bundle) {
var leadMotion = bundle[0].getParentMotion();
// title
var title = leadMotion.identifier ? ' ' + leadMotion.identifier : '';
title += ': ' + leadMotion.getTitle();
title = PDFLayout.createTitle(gettextCatalog.getString('Amendments of motion') + title);
var content = [title],
foundAmendments = [];
var headings = leadMotion.getTextHeadings().map(function(heading) {
heading.amendments = [];
return heading;
});
bundle.forEach(function(amendment) {
var headingIdx = null;
var changes = amendment.getAmendmentParagraphsByMode('diff');
if (changes.length === 0) {
return;
}
var amendmentLineNumber = changes[0].lineFrom;
for (var i = 0; i < headings.length; i++) {
if (headings[i].lineNumber <= amendmentLineNumber) {
headingIdx = i;
}
}
if (headingIdx !== null) {
headings[headingIdx].amendments.push(amendment);
foundAmendments.push(amendment.id);
}
});
headings.forEach(function(heading) {
if (heading.amendments.length === 0) {
return;
}
content.push({
text: heading.text,
style: "heading2",
marginTop: 25,
});
content = _.concat(content, createBundleContent(heading.amendments));
});
// If there was an amendment that did not have a heading, we append it at the bottom
var missedAmendments = [];
bundle.forEach(function(amendment) {
if (foundAmendments.indexOf(amendment.id) === -1) {
missedAmendments.push(amendment);
}
});
if (missedAmendments.length > 0) {
content = _.concat(content, createBundleContent(missedAmendments));
}
return content;
};
// Generates content as a pdfmake consumable
var getContent = function() {
if (motions.length === 0) {
return [];
}
// Creates bundles of motions. All motions with the same parent are bundled together
// respecting the order, in which they are sorted.
// motionBundles is an array containing Arrays of motions with the same parent.
var parentId = motions[0].parent_id;
var motionBundles = [];
var currentBundle = [];
_.forEach(motions, function (motion) {
if (motion.parent_id === parentId) {
currentBundle.push(motion);
} else {
motionBundles.push(currentBundle);
currentBundle = [motion];
parentId = motion.parent_id;
}
});
motionBundles.push(currentBundle);
// Make the amendment table for each motion bundle.
return _.map(motionBundles, function (bundle, index) {
var content = getBundleContent(bundle);
if (index < motionBundles.length - 1) {
content.push(PDFLayout.addPageBreak());
}
return content;
});
};
var getImageMap = function() {
return imageMap;
};
return $q(function (resolve) {
ImageConverter.toBase64(getImageSources()).then(function (_imageMap) {
imageMap = _imageMap;
converter = PdfMakeConverter.createInstance(_imageMap);
resolve({
getContent: getContent,
getImageMap: getImageMap,
});
});
});
};
return {
createInstance: createInstance,
};
}
])
.factory('MotionPdfExport', [
'$http',
'$q',
@ -980,6 +1199,7 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf'])
'PollContentProvider',
'PdfMakeBallotPaperProvider',
'MotionPartialContentProvider',
'AmendmentContentProvider',
'PdfCreate',
'PDFLayout',
'PersonalNoteManager',
@ -988,8 +1208,8 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf'])
'FileSaver',
function ($http, $q, operator, Config, gettextCatalog, MotionChangeRecommendation, HTMLValidizer,
PdfMakeConverter, MotionContentProvider, MotionCatalogContentProvider, PdfMakeDocumentProvider,
PollContentProvider, PdfMakeBallotPaperProvider, MotionPartialContentProvider, PdfCreate,
PDFLayout, PersonalNoteManager, MotionComment, Messaging, FileSaver) {
PollContentProvider, PdfMakeBallotPaperProvider, MotionPartialContentProvider, AmendmentContentProvider,
PdfCreate, PDFLayout, PersonalNoteManager, MotionComment, Messaging, FileSaver) {
return {
getDocumentProvider: function (motions, params, singleMotion) {
params = _.clone(params || {}); // Clone this to avoid sideeffects.
@ -1158,6 +1378,13 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf'])
});
}
},
exportAmendments: function (motions, filename) {
AmendmentContentProvider.createInstance(motions).then(function (contentProvider) {
PdfMakeDocumentProvider.createInstance(contentProvider).then(function (documentProvider) {
PdfCreate.download(documentProvider, filename);
});
});
},
};
}
]);

View File

@ -4,6 +4,7 @@
angular.module('OpenSlidesApp.motions.projector', [
'OpenSlidesApp.motions',
'OpenSlidesApp.motions.motionservices',
'OpenSlidesApp.motions.motionBlockProjector',
])
@ -18,17 +19,20 @@ angular.module('OpenSlidesApp.motions.projector', [
.controller('SlideMotionCtrl', [
'$scope',
'Config',
'Motion',
'MotionChangeRecommendation',
'ChangeRecommendationView',
'User',
'Notify',
'ProjectorID',
function($scope, Motion, MotionChangeRecommendation, User, Notify, ProjectorID) {
function($scope, Config, Motion, MotionChangeRecommendation, ChangeRecommendationView, User, Notify, ProjectorID) {
// Attention! Each object that is used here has to be dealt on server side.
// Add it to the coresponding get_requirements method of the ProjectorElement
// class.
var id = $scope.element.id;
var motionId = $scope.element.id;
$scope.mode = $scope.element.mode || 'original';
$scope.lineNumberMode = Config.get('motions_default_line_numbering').value;
var notifyNamePrefix = 'projector_' + ProjectorID() + '_motion_line_';
var callbackId = Notify.registerCallback(notifyNamePrefix + 'request', function (params) {
@ -55,27 +59,19 @@ angular.module('OpenSlidesApp.motions.projector', [
Notify.deregisterCallback(callbackId);
});
Motion.bindOne(id, $scope, 'motion');
User.bindAll({}, $scope, 'users');
$scope.$watch(function () {
return MotionChangeRecommendation.lastModified();
return Motion.lastModified(motionId);
}, function () {
$scope.change_recommendations = [];
$scope.title_change_recommendation = null;
if ($scope.motion) {
MotionChangeRecommendation.filter({
'where': {'motion_version_id': {'==': $scope.motion.active_version}}
}).forEach(function(change) {
if (change.isTextRecommendation()) {
$scope.change_recommendations.push(change);
}
if (change.isTitleRecommendation()) {
$scope.title_change_recommendation = change;
}
});
}
$scope.motion = Motion.get(motionId);
$scope.amendment_diff_paragraphs = $scope.motion.getAmendmentParagraphsLinesDiff();
$scope.viewChangeRecommendations.setVersion($scope.motion, $scope.motion.active_version);
});
// Change recommendation viewing
$scope.viewChangeRecommendations = ChangeRecommendationView;
$scope.viewChangeRecommendations.initProjector($scope, Motion.get(motionId), $scope.mode);
}
]);

View File

@ -102,6 +102,21 @@ angular.module('OpenSlidesApp.motions.site', [
basePerm: 'motions.can_manage',
},
})
.state('motions.motion.amendment-list', {
url: '/{id:int}/amendments',
controller: 'MotionAmendmentListStateCtrl',
params: {
motionId: null,
},
})
.state('motions.motion.allamendments', {
url: '/amendments',
templateUrl: 'static/templates/motions/motion-amendment-list.html',
controller: 'MotionAmendmentListStateCtrl',
resolve: {
motionId: function() { return void 0; },
}
})
.state('motions.motion.import', {
url: '/import',
controller: 'MotionImportCtrl',
@ -365,6 +380,28 @@ angular.module('OpenSlidesApp.motions.site', [
}
])
// Service for choosing the paragraph of a given motion that is to be amended
.factory('AmendmentParagraphChooseForm', [
function () {
return {
// ngDialog for motion form
getDialog: function (motion, successCb) {
return {
template: 'static/templates/motions/amendment-paragraph-choose-form.html',
controller: 'AmendmentParagraphChooseCtrl',
className: 'ngdialog-theme-default wide-form',
closeByEscape: false,
closeByDocument: false,
resolve: {
motion: function () { return motion; },
successCb: function() { return successCb; },
}
};
}
};
}
])
// Service for generic motion form (create and update)
.factory('MotionForm', [
'$filter',
@ -385,7 +422,11 @@ angular.module('OpenSlidesApp.motions.site', [
Config, Mediafile, MotionBlock, Tag, User, Workflow, Agenda, AgendaTree) {
return {
// ngDialog for motion form
getDialog: function (motion) {
// If motion is given and not null, we're editing an already existing motion
// If parentMotion is give, we're dealing with an amendment
// If paragraphNo is given as well, the amendment is paragraph-based
// If paragraphTextPre is given, we're creating a modified version of another paragraph-based amendment
getDialog: function (motion, parentMotion, paragraphNo, paragraphTextPre) {
return {
template: 'static/templates/motions/motion-form.html',
controller: motion ? 'MotionUpdateCtrl' : 'MotionCreateCtrl',
@ -394,11 +435,14 @@ angular.module('OpenSlidesApp.motions.site', [
closeByDocument: false,
resolve: {
motionId: function () {return motion ? motion.id : void 0;},
},
parentMotion: function () {return parentMotion;},
paragraphNo: function () {return paragraphNo;},
paragraphTextPre: function () {return paragraphTextPre;}
}
};
},
// angular-formly fields for motion form
getFormFields: function (isCreateForm) {
getFormFields: function (isCreateForm, isParagraphBasedAmendment) {
var workflows = Workflow.getAll();
var images = Mediafile.getAllImages();
var formFields = [];
@ -432,7 +476,8 @@ angular.module('OpenSlidesApp.motions.site', [
templateOptions: {
label: gettextCatalog.getString('Title'),
required: true
}
},
hide: isParagraphBasedAmendment && isCreateForm
},
{
template: '<p class="spacer-top-lg no-padding">' + Config.translate(Config.get('motions_preamble').value) + '</p>'
@ -442,7 +487,7 @@ angular.module('OpenSlidesApp.motions.site', [
type: 'editor',
templateOptions: {
label: gettextCatalog.getString('Text'),
required: true
required: !isParagraphBasedAmendment // Deleting the whole paragraph in an amendment should be possible
},
data: {
ckeditorOptions: Editor.getOptions()
@ -612,6 +657,34 @@ angular.module('OpenSlidesApp.motions.site', [
}
])
.factory('MotionCommentForm', [
'MotionComment',
function (MotionComment) {
return {
// ngDialog for motion comment form
getDialog: function (motion, commentFieldId) {
return {
template: 'static/templates/motions/motion-comment-form.html',
controller: 'MotionCommentCtrl',
className: 'ngdialog-theme-default wide-form',
closeByEscape: false,
closeByDocument: false,
resolve: {
motionId: function () {return motion.id;},
commentFieldId: function () {return commentFieldId;},
},
};
},
// angular-formly fields for motion comment form
getFormFields: function (commentFieldId) {
return [
MotionComment.getFormField(commentFieldId)
];
},
};
}
])
.factory('CategoryForm', [
'gettextCatalog',
function (gettextCatalog) {
@ -739,6 +812,9 @@ angular.module('OpenSlidesApp.motions.site', [
getFormFields: function (singleMotion, motions, formatChangeCallback) {
var fields = [];
var commentsAvailable = _.keys(noSpecialCommentsFields).length !== 0;
var someMotionsHaveAmendments = _.some(motions, function (motion) {
return motion.hasAmendments();
});
var getMetaInformationOptions = function (disabled) {
if (!disabled) {
disabled = {};
@ -788,6 +864,19 @@ angular.module('OpenSlidesApp.motions.site', [
}
];
}
if (someMotionsHaveAmendments) {
fields.push({
key: 'amendments',
type: 'radio-buttons',
templateOptions: {
label: gettextCatalog.getString('Amendments'),
options: [
{name: gettextCatalog.getString('Include'), value: true},
{name: gettextCatalog.getString('Exclude'), value: false},
],
},
});
}
if (operator.hasPerms('motions.can_manage')) {
fields.push.apply(fields, [
{
@ -952,6 +1041,7 @@ angular.module('OpenSlidesApp.motions.site', [
pdfFormat: 'pdf',
changeRecommendationMode: Config.get('motions_recommendation_text_mode').value,
lineNumberMode: Config.get('motions_default_line_numbering').value,
amendments: false,
include: {
text: true,
reason: true,
@ -967,7 +1057,24 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.motions = motions;
$scope.singleMotion = singleMotion;
// Add amendments to motions. The amendments are sorted by their identifier
var prepareAmendments = function (motions) {
var allMotions = [];
_.forEach(motions, function (motion) {
allMotions.push(motion);
allMotions = allMotions.concat(
_.sortBy(motion.getAmendments(), function (amendment) {
return amendment.identifier;
})
);
});
return allMotions;
};
$scope.export = function () {
if ($scope.params.amendments) {
motions = prepareAmendments(motions);
}
switch ($scope.params.format) {
case 'pdf':
if ($scope.params.pdfFormat === 'pdf') {
@ -1078,8 +1185,8 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.$watch(function () {
return Motion.lastModified();
}, function () {
// always order by identifier (after custom ordering)
$scope.motions = _.orderBy(Motion.getAll(), ['identifier']);
// get all main motions and order by identifier (after custom ordering)
$scope.motions = _.orderBy(Motion.filter({parent_id: undefined}), ['identifier']);
_.forEach($scope.motions, function (motion) {
MotionComment.populateFields(motion);
motion.personalNote = PersonalNoteManager.getNote(motion);
@ -1172,8 +1279,12 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.stateFilter = _.uniq($scope.stateFilter);
};
// This value may be overritten, so the filters, sorting and pagination in an
// derived view are independent to this view.
var osTablePrefix = $scope.osTablePrefix || 'MotionTable';
// Filtering
$scope.filter = osTableFilter.createInstance('MotionTableFilter');
$scope.filter = osTableFilter.createInstance(osTablePrefix + 'Filter');
if (!$scope.filter.existsStorageEntry()) {
$scope.filter.multiselectFilters = {
@ -1185,11 +1296,6 @@ angular.module('OpenSlidesApp.motions.site', [
comment: [],
};
$scope.filter.booleanFilters = {
isAmendment: {
value: undefined,
choiceYes: gettext('Is an amendment'),
choiceNo: gettext('Is not an amendment'),
},
isFavorite: {
value: undefined,
choiceYes: gettext('Marked as favorite'),
@ -1245,7 +1351,7 @@ angular.module('OpenSlidesApp.motions.site', [
updateStateFilter();
};
// Sorting
$scope.sort = osTableSort.createInstance('MotionTableSort');
$scope.sort = osTableSort.createInstance(osTablePrefix + 'Sort');
if (!$scope.sort.column) {
$scope.sort.column = 'identifier';
}
@ -1269,24 +1375,7 @@ angular.module('OpenSlidesApp.motions.site', [
];
// pagination
$scope.pagination = osTablePagination.createInstance('MotionTablePagination');
// update state
$scope.updateState = function (motion, state_id) {
$http.put('/rest/motions/motion/' + motion.id + '/set_state/', {'state': state_id});
};
// reset state
$scope.resetState = function (motion) {
$http.put('/rest/motions/motion/' + motion.id + '/set_state/', {});
};
// update recommendation
$scope.updateRecommendation = function (motion, recommendation_id) {
$http.put('/rest/motions/motion/' + motion.id + '/set_recommendation/', {'recommendation': recommendation_id});
};
// reset recommendation
$scope.resetRecommendation = function (motion) {
$http.put('/rest/motions/motion/' + motion.id + '/set_recommendation/', {});
};
$scope.pagination = osTablePagination.createInstance(osTablePrefix + 'Pagination');
$scope.hasTag = function (motion, tag) {
return _.indexOf(motion.tags_id, tag.id) > -1;
@ -1340,19 +1429,19 @@ angular.module('OpenSlidesApp.motions.site', [
ngDialog.open(MotionForm.getDialog(motion));
};
// Export dialog
$scope.openExportDialog = function () {
ngDialog.open(MotionExportForm.getDialog($scope.motionsFiltered));
$scope.openExportDialog = function (motions) {
ngDialog.open(MotionExportForm.getDialog(motions));
};
$scope.pdfExport = function () {
MotionPdfExport.export($scope.motionsFiltered);
$scope.pdfExport = function (motions) {
MotionPdfExport.export(motions);
};
// *** select mode functions ***
$scope.isSelectMode = false;
// check all checkboxes from filtered motions
$scope.checkAll = function () {
$scope.checkAll = function (motions) {
$scope.selectedAll = !$scope.selectedAll;
angular.forEach($scope.motionsFiltered, function (motion) {
_.forEach(motions, function (motion) {
motion.selected = $scope.selectedAll;
});
};
@ -1360,13 +1449,13 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.uncheckAll = function () {
if (!$scope.isSelectMode) {
$scope.selectedAll = false;
angular.forEach($scope.motions, function (motion) {
_.forEach($scope.motions, function (motion) {
motion.selected = false;
});
}
};
var selectModeAction = function (predicate) {
angular.forEach($scope.motionsFiltered, function (motion) {
var selectModeAction = function (motions, predicate) {
angular.forEach(motions, function (motion) {
if (motion.selected) {
predicate(motion);
}
@ -1375,27 +1464,27 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.uncheckAll();
};
// delete selected motions
$scope.deleteMultiple = function () {
selectModeAction(function (motion) {
$scope.deleteMultiple = function (motions) {
selectModeAction(motions, function (motion) {
$scope.delete(motion);
});
};
// set status for selected motions
$scope.setStatusMultiple = function (stateId) {
selectModeAction(function (motion) {
$scope.updateState(motion, stateId);
$scope.setStatusMultiple = function (motions, stateId) {
selectModeAction(motions, function (motion) {
$http.put('/rest/motions/motion/' + motion.id + '/set_state/', {'state': stateId});
});
};
// set category for selected motions
$scope.setCategoryMultiple = function (categoryId) {
selectModeAction(function (motion) {
$scope.setCategoryMultiple = function (motions, categoryId) {
selectModeAction(motions, function (motion) {
motion.category_id = categoryId === 'no_category_selected' ? null : categoryId;
$scope.save(motion);
});
};
// set status for selected motions
$scope.setMotionBlockMultiple = function (motionBlockId) {
selectModeAction(function (motion) {
$scope.setMotionBlockMultiple = function (motions, motionBlockId) {
selectModeAction(motions, function (motion) {
motion.motion_block_id = motionBlockId === 'no_motionBlock_selected' ? null : motionBlockId;
$scope.save(motion);
});
@ -1413,6 +1502,7 @@ angular.module('OpenSlidesApp.motions.site', [
'ngDialog',
'gettextCatalog',
'MotionForm',
'AmendmentParagraphChooseForm',
'ChangeRecommendationCreate',
'ChangeRecommendationView',
'MotionStateAndRecommendationParser',
@ -1438,7 +1528,7 @@ angular.module('OpenSlidesApp.motions.site', [
'WebpageTitle',
'EditingWarning',
function($scope, $http, $timeout, $window, $filter, operator, ngDialog, gettextCatalog,
MotionForm, ChangeRecommendationCreate, ChangeRecommendationView,
MotionForm, AmendmentParagraphChooseForm, ChangeRecommendationCreate, ChangeRecommendationView,
MotionStateAndRecommendationParser, MotionChangeRecommendation, Motion, MotionComment,
Category, Mediafile, Tag, User, Workflow, Config, motionId, MotionInlineEditing,
MotionCommentsInlineEditing, Editor, Projector, ProjectionDefault, MotionBlock,
@ -1451,29 +1541,8 @@ angular.module('OpenSlidesApp.motions.site', [
Workflow.bindAll({}, $scope, 'workflows');
MotionBlock.bindAll({}, $scope, 'motionBlocks');
Motion.bindAll({}, $scope, 'motions');
$scope.$watch(function () {
return MotionChangeRecommendation.lastModified();
}, function () {
$scope.change_recommendations = [];
$scope.title_change_recommendation = null;
MotionChangeRecommendation.filter({
'where': {'motion_version_id': {'==': motion.active_version}}
}).forEach(function(change) {
if (change.isTextRecommendation()) {
$scope.change_recommendations.push(change);
}
if (change.isTitleRecommendation()) {
$scope.title_change_recommendation = change;
}
});
if ($scope.change_recommendations.length === 0) {
$scope.setProjectionMode($scope.projectionModes[0]);
}
if ($scope.change_recommendations.length > 0) {
$scope.disableMotionInlineEditing();
}
});
$scope.$watch(function () {
return Projector.lastModified();
}, function () {
@ -1487,6 +1556,7 @@ angular.module('OpenSlidesApp.motions.site', [
return Motion.lastModified(motionId);
}, function () {
$scope.motion = Motion.get(motionId);
$scope.amendment_diff_paragraphs = $scope.motion.getAmendmentParagraphsLinesDiff();
MotionComment.populateFields($scope.motion);
if (motion.comments) {
$scope.stateExtension = $scope.motion.comments[$scope.commentFieldForStateId];
@ -1503,6 +1573,7 @@ angular.module('OpenSlidesApp.motions.site', [
WebpageTitle.updateTitle(webpageTitle);
$scope.createChangeRecommendation.setVersion(motion, motion.active_version);
$scope.viewChangeRecommendations.setVersion(motion, motion.active_version);
});
$scope.$watch(function () {
return Motion.lastModified();
@ -1554,6 +1625,13 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.lineNumberMode = mode;
};
$scope.showAmendmentContext = false;
$scope.setShowAmendmentContext = function($event) {
$event.preventDefault();
$event.stopPropagation();
$scope.showAmendmentContext = !$scope.showAmendmentContext;
};
if (motion.parent_id) {
Motion.bindOne(motion.parent_id, $scope, 'parent');
}
@ -1641,27 +1719,26 @@ angular.module('OpenSlidesApp.motions.site', [
};
// open dialog for new amendment
$scope.newAmendment = function () {
var dialog = MotionForm.getDialog();
if (typeof dialog.scope === 'undefined') {
dialog.scope = {};
}
var openMainDialog = function (paragraphNo) {
var dialog = MotionForm.getDialog(null, motion, paragraphNo);
dialog.scope = $scope;
ngDialog.open(dialog);
};
if (Config.get('motions_amendments_text_mode').value === 'paragraph') {
var dialog = AmendmentParagraphChooseForm.getDialog($scope.motion, openMainDialog);
dialog.scope = $scope;
ngDialog.open(dialog);
} else {
openMainDialog();
}
};
// follow recommendation
$scope.followRecommendation = function () {
$http.post('/rest/motions/motion/' + motion.id + '/follow_recommendation/', {
'recommendationExtension': $scope.recommendationExtension
});
};
// update state
$scope.updateState = function (state_id) {
$http.put('/rest/motions/motion/' + motion.id + '/set_state/', {'state': state_id});
};
// reset state
$scope.reset_state = function () {
$http.put('/rest/motions/motion/' + motion.id + '/set_state/', {});
};
// toggle functions for meta information
$scope.toggleCategory = function (category) {
if ($scope.motion.category_id == category.id) {
@ -1706,14 +1783,6 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.addMotionToRecommendationField = function (motion) {
$scope.recommendationExtension += MotionStateAndRecommendationParser.formatMotion(motion);
};
// update recommendation
$scope.updateRecommendation = function (recommendation_id) {
$http.put('/rest/motions/motion/' + motion.id + '/set_recommendation/', {'recommendation': recommendation_id});
};
// reset recommendation
$scope.resetRecommendation = function () {
$http.put('/rest/motions/motion/' + motion.id + '/set_recommendation/', {});
};
// create poll
$scope.create_poll = function () {
$http.post('/rest/motions/motion/' + motion.id + '/create_poll/', {});
@ -1746,6 +1815,7 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.inlineEditing.setVersion(motion, version.id);
$scope.reasonInlineEditing.setVersion(motion, version.id);
$scope.createChangeRecommendation.setVersion(motion, version.id);
$scope.viewChangeRecommendations.setVersion(motion, motion.active_version);
};
// permit specific version
$scope.permitVersion = function (version) {
@ -1882,9 +1952,9 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.createChangeRecommendation = ChangeRecommendationCreate;
$scope.createChangeRecommendation.init($scope, motion);
// Change recommendation viewing
// Change recommendation and amendment viewing
$scope.viewChangeRecommendations = ChangeRecommendationView;
$scope.viewChangeRecommendations.init($scope, Config.get('motions_recommendation_text_mode').value);
$scope.viewChangeRecommendations.initSite($scope, motion, Config.get('motions_recommendation_text_mode').value);
// PDF creating functions
$scope.pdfExport = function () {
@ -2052,6 +2122,32 @@ angular.module('OpenSlidesApp.motions.site', [
}
])
.controller('AmendmentParagraphChooseCtrl', [
'$scope',
'$state',
'Motion',
'motion',
'successCb',
function($scope, $state, Motion, motion, successCb) {
$scope.model = angular.copy(motion);
$scope.model.paragraph_selected = null;
$scope.paragraphs = motion.getTextParagraphs(motion.active_version, true).map(function(text, index) {
// This prevents an error in ng-repeater's duplication detection if two identical paragraphs occur
return {
"paragraphNo": index,
"text": text
};
});
$scope.gotoMotionForm = function() {
var paragraphNo = parseInt($scope.model.paragraph_selected);
successCb(paragraphNo);
$scope.closeThisDialog();
};
}
])
.controller('MotionCreateCtrl', [
'$scope',
'$state',
@ -2060,6 +2156,9 @@ angular.module('OpenSlidesApp.motions.site', [
'operator',
'Motion',
'MotionForm',
'parentMotion',
'paragraphNo',
'paragraphTextPre',
'Category',
'Config',
'Mediafile',
@ -2068,8 +2167,9 @@ angular.module('OpenSlidesApp.motions.site', [
'Workflow',
'Agenda',
'ErrorMessage',
function($scope, $state, gettext, gettextCatalog, operator, Motion, MotionForm,
Category, Config, Mediafile, Tag, User, Workflow, Agenda, ErrorMessage) {
function($scope, $state, gettext, gettextCatalog, operator, Motion, MotionForm, parentMotion,
paragraphNo, paragraphTextPre, Category, Config, Mediafile, Tag, User, Workflow,
Agenda, ErrorMessage) {
Category.bindAll({}, $scope, 'categories');
Mediafile.bindAll({}, $scope, 'mediafiles');
Tag.bindAll({}, $scope, 'tags');
@ -2080,30 +2180,54 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.alert = {};
// Check whether this is a new amendment.
var isAmendment = $scope.$parent.motion && $scope.$parent.motion.id;
var isAmendment = parentMotion && parentMotion.id,
isParagraphBasedAmendment = false;
// Set default values for create form
// ... for amendments add parent_id
if (isAmendment) {
if (Config.get('motions_amendments_apply_text').value) {
$scope.model.text = $scope.$parent.motion.getText();
if (Config.get('motions_amendments_text_mode').value === 'fulltext') {
$scope.model.text = parentMotion.getText();
}
$scope.model.title = $scope.$parent.motion.getTitle();
$scope.model.parent_id = $scope.$parent.motion.id;
$scope.model.category_id = $scope.$parent.motion.category_id;
$scope.model.motion_block_id = $scope.$parent.motion.motion_block_id;
if (Config.get('motions_amendments_text_mode').value === 'paragraph' &&
paragraphNo !== undefined) {
var paragraphs = parentMotion.getTextParagraphs(parentMotion.active_version, false);
$scope.model.text = paragraphs[paragraphNo];
isParagraphBasedAmendment = true;
}
if (paragraphTextPre !== undefined) {
$scope.model.text = paragraphTextPre;
}
if (parentMotion.identifier) {
$scope.model.title = gettextCatalog.getString('Amendment to') +
' ' + parentMotion.identifier;
} else {
$scope.model.title = gettextCatalog.getString('Amendment to motion ') +
' ' + parentMotion.getTitle();
}
$scope.model.paragraphNo = paragraphNo;
$scope.model.parent_id = parentMotion.id;
$scope.model.category_id = parentMotion.category_id;
$scope.model.motion_block_id = parentMotion.motion_block_id;
Motion.bindOne($scope.model.parent_id, $scope, 'parent');
}
// ... preselect default workflow
if (operator.hasPerms('motions.can_manage')) {
$scope.model.workflow_id = Config.get('motions_workflow').value;
}
// get all form fields
$scope.formFields = MotionForm.getFormFields(true);
$scope.formFields = MotionForm.getFormFields(true, isParagraphBasedAmendment);
// save motion
$scope.save = function (motion, gotoDetailView) {
motion.agenda_type = motion.showAsAgendaItem ? 1 : 2;
if (isAmendment && motion.paragraphNo !== undefined) {
var orig_paragraphs = parentMotion.getTextParagraphs(parentMotion.active_version, false);
motion.amendment_paragraphs = orig_paragraphs.map(function (_, idx) {
return (idx === motion.paragraphNo ? motion.text : null);
});
}
// The attribute motion.agenda_parent_id is set by the form, see form definition.
Motion.create(motion).then(
function(success) {
@ -2149,12 +2273,48 @@ angular.module('OpenSlidesApp.motions.site', [
// set initial values for form model by create deep copy of motion object
// so list/detail view is not updated while editing
var motion = Motion.get(motionId);
$scope.model = angular.copy(motion);
// We need to clone this by hand. angular and lodash are not capable of keeping
// crossreferences out.
$scope.model = {
id: motion.id,
parent_id: motion.parent_id,
identifier: motion.identifier,
title: motion.getTitle(),
text: motion.getText(),
reason: motion.getReason(),
submitters_id: _.map(motion.submitters_id),
supporters_id: _.map(motion.supporters_id),
tags_id: _.map(motion.tags_id),
state_id: motion.state_id,
recommendation_id: motion.recommendation_id,
origin: motion.origin,
workflow_id: motion.workflow_id,
comments: _.clone(motion.comments),
attachments_id: _.map(motion.attachments_id),
active_version: motion.active_version,
agenda_item_id: motion.agenda_item_id,
category_id: motion.category_id,
motion_block_id: motion.motion_block_id,
};
// Clone comments
_.forEach(motion.comments, function (comment, index) {
$scope.model['comment_' + index] = comment;
});
$scope.model.disable_versioning = false;
$scope.model.more = false;
if (motion.isParagraphBasedAmendment()) {
motion.getVersion(motion.active_version).amendment_paragraphs.forEach(function(paragraph_amend, paragraphNo) {
// Hint: this assumes there is only one modified paragraph
if (paragraph_amend !== null) {
$scope.model.text = paragraph_amend;
$scope.model.paragraphNo = paragraphNo;
}
});
$scope.model.title = motion.getTitle();
}
// get all form fields
$scope.formFields = MotionForm.getFormFields();
$scope.formFields = MotionForm.getFormFields(false, motion.isParagraphBasedAmendment());
// override default values for update form
for (var i = 0; i < $scope.formFields.length; i++) {
if ($scope.formFields[i].key == "identifier") {
@ -2190,15 +2350,90 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.$on('$destroy', editingStoppedCallback);
// Save motion
$scope.save = function (motion, gotoDetailView) {
$scope.save = function (model, gotoDetailView) {
if ($scope.model.paragraphNo !== undefined) {
var parentMotion = motion.getParentMotion();
var orig_paragraphs = parentMotion.getTextParagraphs(parentMotion.active_version, false);
$scope.model.amendment_paragraphs = orig_paragraphs.map(function (_, idx) {
return (idx === $scope.model.paragraphNo ? $scope.model.text : null);
});
}
// inject the changed motion (copy) object back into DS store
Motion.inject(model);
// save changed motion object on server
Motion.save(model).then(
function(success) {
if (gotoDetailView) {
$state.go('motions.motion.detail', {id: success.id});
}
$scope.closeThisDialog();
},
function (error) {
// save error: revert all changes by restore
// (refresh) original motion object from server
Motion.refresh(model);
$scope.alert = ErrorMessage.forAlert(error);
}
);
};
}
])
.controller('MotionCommentCtrl', [
'$scope',
'Motion',
'MotionComment',
'MotionCommentForm',
'motionId',
'commentFieldId',
'gettextCatalog',
'ErrorMessage',
function ($scope, Motion, MotionComment, MotionCommentForm, motionId, commentFieldId,
gettextCatalog, ErrorMessage) {
$scope.alert = {};
// set initial values for form model by create deep copy of motion object
// so list/detail view is not updated while editing
var motion = Motion.get(motionId);
$scope.model = angular.copy(motion);
$scope.formFields = MotionCommentForm.getFormFields(commentFieldId);
var fields = MotionComment.getNoSpecialCommentsFields();
var title = gettextCatalog.getString('Edit comment %%comment%% of motion %%motion%%');
title = title.replace('%%comment%%', fields[commentFieldId].name);
$scope.title = title.replace('%%motion%%', motion.getTitle());
$scope.model.title = motion.getTitle(-1);
$scope.model.text = motion.getText(-1);
$scope.model.reason = motion.getReason(-1);
if (motion.isParagraphBasedAmendment()) {
motion.getVersion(motion.active_version).amendment_paragraphs.forEach(function(paragraph_amend, paragraphNo) {
// Hint: this assumes there is only one modified paragraph
if (paragraph_amend !== null) {
$scope.model.text = paragraph_amend;
$scope.model.paragraphNo = paragraphNo;
}
});
}
$scope.save = function (motion) {
if (motion.isParagraphBasedAmendment()) {
motion.getVersion(motion.active_version).amendment_paragraphs.forEach(function(paragraph_amend, paragraphNo) {
// Hint: this assumes there is only one modified paragraph
if (paragraph_amend !== null) {
$scope.model.text = paragraph_amend;
$scope.model.paragraphNo = paragraphNo;
}
});
}
// inject the changed motion (copy) object back into DS store
Motion.inject(motion);
// save changed motion object on server
Motion.save(motion).then(
function (success) {
if (gotoDetailView) {
$state.go('motions.motion.detail', {id: success.id});
}
function(success) {
$scope.closeThisDialog();
},
function (error) {
@ -2309,6 +2544,202 @@ angular.module('OpenSlidesApp.motions.site', [
}
])
.controller('MotionAmendmentListStateCtrl', [
'$scope',
'motionId',
function ($scope, motionId) {
$scope.motionId = motionId;
$scope.osTablePrefix = 'AmendmentTable';
}
])
.controller('MotionAmendmentListCtrl', [
'$scope',
'$sessionStorage',
'$state',
'Motion',
'MotionComment',
'MotionForm',
'PersonalNoteManager',
'ngDialog',
'MotionCommentForm',
'MotionChangeRecommendation',
'MotionPdfExport',
'AmendmentCsvExport',
'gettextCatalog',
'gettext',
function ($scope, $sessionStorage, $state, Motion, MotionComment, MotionForm,
PersonalNoteManager, ngDialog, MotionCommentForm, MotionChangeRecommendation,
MotionPdfExport, AmendmentCsvExport, gettextCatalog, gettext) {
if ($scope.motionId) {
$scope.leadMotion = Motion.get($scope.motionId);
}
var updateMotions = function () {
// check, if lead motion is given
var amendments;
if ($scope.leadMotion) {
amendments = Motion.filter({parent_id: $scope.leadMotion.id});
} else {
amendments = _.filter(Motion.getAll(), function (motion) {
return motion.parent_id;
});
}
// always order by identifier (after custom ordering)
$scope.amendments = _.orderBy(amendments, ['identifier']);
_.forEach($scope.amendments, function (amendment) {
MotionComment.populateFields(amendment);
amendment.personalNote = PersonalNoteManager.getNote(amendment);
// For filtering, we cannot filter for .personalNote.star
amendment.star = amendment.personalNote ? amendment.personalNote.star : false;
amendment.hasPersonalNote = amendment.personalNote ? !!amendment.personalNote.note : false;
if (amendment.star === undefined) {
amendment.star = false;
}
// add a custom sort attribute
var parentMotion = amendment.getParentMotion();
amendment.parentMotionAndLineNumber = parentMotion.identifier;
if (amendment.isParagraphBasedAmendment()) {
var paragraphs = amendment.getAmendmentParagraphsLinesDiff();
var diffLine = '0';
if (paragraphs.length) {
diffLine = '' + paragraphs[0].diffLineFrom;
}
while (diffLine.length < 6) {
diffLine = '0' + diffLine;
}
amendment.parentMotionAndLineNumber += ' ' + diffLine;
}
});
// Get all lead motions
$scope.leadMotions = _.orderBy(Motion.filter({parent_id: undefined}), ['identifier']);
//updateCollissions();
};
var updateCollissions = function () {
$scope.collissions = {};
_.forEach($scope.amendments, function (amendment) {
if (amendment.isParagraphBasedAmendment()) {
var parentMotion = amendment.getParentMotion();
// get all change recommendations _and_ changes by amendments from the
// parent motion. From all get the unified change object.
var parentChangeRecommendations = _.filter(
MotionChangeRecommendation.filter({
'where': {'motion_version_id': {'==': parentMotion.active_version}}
}), function (change) {
return change.isTextRecommendation();
}
);
var parentChanges = parentChangeRecommendations.map(function (cr) {
return cr.getUnifiedChangeObject();
}).concat(
_.map(parentMotion.getParagraphBasedAmendmentsForDiffView(), function (amendment) {
return amendment.getUnifiedChangeObject();
})
);
var change = amendment.getUnifiedChangeObject();
if (change) {
change.setOtherChangesForCollission(parentChanges);
$scope.collissions[amendment.id] = !!change.getCollissions().length;
}
}
});
};
//$scope.$watch(function () {
// return MotionChangeRecommendation.lastModified();
//}, updateCollissions);
$scope.$watch(function () {
return Motion.lastModified();
}, updateMotions);
$scope.selectLeadMotion = function (motion) {
$scope.leadMotion = motion;
updateMotions();
if ($scope.leadMotion) {
$state.transitionTo('motions.motion.amendment-list',
{id: $scope.leadMotion.id},
{notify: false}
);
} else {
$state.transitionTo('motions.motion.allamendments', {},
{notify: false}
);
}
};
// Save expand state so the session
if ($sessionStorage.amendmentTableExpandState) {
$scope.toggleExpandContent();
}
$scope.saveExpandState = function (state) {
$sessionStorage.amendmentTableExpandState = state;
};
// add custom sorting
$scope.sortOptions.unshift({
name: 'parentMotionAndLineNumber',
display_name: gettext('Parent motion and line number'),
});
if (!$scope.sort.column || $scope.sort.column === 'identifier') {
$scope.sort.column = 'parentMotionAndLineNumber';
}
$scope.isTextExpandable = function (comment, characters) {
comment = $(comment).text();
return comment.length > characters;
};
$scope.getTextPreview = function (comment, characters) {
comment = $(comment).text();
if (comment.length > characters) {
comment = comment.substr(0, characters) + '...';
}
return comment;
};
$scope.editComment = function (motion, fieldId) {
ngDialog.open(MotionCommentForm.getDialog(motion, fieldId));
};
$scope.createModifiedAmendment = function (amendment) {
var paragraphNo,
paragraphText;
if (amendment.isParagraphBasedAmendment()) {
// We assume there is only one affected paragraph
amendment.getVersion(amendment.active_version).amendment_paragraphs.forEach(function(parText, parNo) {
if (parText !== null) {
paragraphNo = parNo;
paragraphText = parText;
}
});
} else {
paragraphText = amendment.getText();
}
ngDialog.open(MotionForm.getDialog(null, amendment.getParentMotion(), paragraphNo, paragraphText));
};
$scope.amendmentPdfExport = function (motions) {
var filename;
if ($scope.leadMotion) {
filename = gettextCatalog.getString('Amendments to') + ' ' +
$scope.leadMotion.getTitle();
} else {
filename = gettextCatalog.getString('Amendments');
}
filename += '.pdf';
MotionPdfExport.exportAmendments(motions, filename);
};
$scope.exportCsv = function (motions) {
AmendmentCsvExport.export(motions);
};
}
])
.controller('MotionImportCtrl', [
'$scope',
'$q',
@ -2581,7 +3012,6 @@ angular.module('OpenSlidesApp.motions.site', [
}
])
.controller('CategoryListCtrl', [
'$scope',
'Category',
@ -2594,8 +3024,8 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.sortColumn = 'name';
$scope.reverse = false;
// function to sort by clicked column
$scope.toggleSort = function ( column ) {
if ( $scope.sortColumn === column ) {
$scope.toggleSort = function (column) {
if ($scope.sortColumn === column) {
$scope.reverse = !$scope.reverse;
}
$scope.sortColumn = column;
@ -2622,7 +3052,7 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.formFields = CategoryForm.getFormFields();
$scope.save = function (category) {
Category.create(category).then(
function(success) {
function (success) {
$scope.closeThisDialog();
},
function (error) {
@ -2639,14 +3069,14 @@ angular.module('OpenSlidesApp.motions.site', [
'categoryId',
'CategoryForm',
'ErrorMessage',
function($scope, Category, categoryId, CategoryForm, ErrorMessage) {
function ($scope, Category, categoryId, CategoryForm, ErrorMessage) {
$scope.alert = {};
$scope.model = angular.copy(Category.get(categoryId));
$scope.formFields = CategoryForm.getFormFields();
$scope.save = function (category) {
Category.inject(category);
Category.save(category).then(
function(success) {
function (success) {
$scope.closeThisDialog();
},
function (error) {
@ -2668,7 +3098,7 @@ angular.module('OpenSlidesApp.motions.site', [
'categoryId',
'Motion',
'ErrorMessage',
function($scope, $stateParams, $http, Category, categoryId, Motion, ErrorMessage) {
function ($scope, $stateParams, $http, Category, categoryId, Motion, ErrorMessage) {
Category.bindOne(categoryId, $scope, 'category');
Motion.bindAll({}, $scope, 'motions');
$scope.filter = { category_id: categoryId,
@ -2740,6 +3170,7 @@ angular.module('OpenSlidesApp.motions.site', [
gettext('Name of recommender');
gettext('Default text version for change recommendations');
gettext('Will be displayed as label before selected recommendation. Use an empty value to disable the recommendation system.');
gettext('Edit comment %%comment%% of motion %%motion%%');
// subgroup Amendments
gettext('Amendments');
@ -2747,6 +3178,7 @@ angular.module('OpenSlidesApp.motions.site', [
gettext('Prefix for the identifier for amendments');
gettext('Apply text for new amendments');
gettext('The title of the motion is always applied.');
gettext('Amendment to');
// subgroup Supporters
gettext('Supporters');

View File

@ -0,0 +1,27 @@
<h1 translate>Choose the paragraph to amend</h1>
<div uib-alert ng-show="alert.show" ng-class="'alert-' + (alert.type || 'warning')" close="alert={}">
{{ alert.msg }}
</div>
<form name="motionForm" ng-submit="gotoMotionForm(model)" novalidate>
<div class="paragraph-select-list motion-text">
<div ng-repeat="paragraph in paragraphs" class="paragraph-select-holder"
ng-class="model.paragraph_selected == paragraph.paragraphNo ? 'selected' : ''">
<div class="paragraph-select">
<input type="radio" name="paragraph_selected" value="{{ paragraph.paragraphNo }}"
ng-model="model.paragraph_selected">
</div>
<div class="text-holder" ng-click="model.paragraph_selected = paragraph.paragraphNo"
ng-bind-html="paragraph.text | trusted"></div>
</div>
</div>
<button type="submit" ng-disabled="model.paragraph_selected === null" class="btn btn-primary" translate>
Continue
</button>
<button type="button" ng-click="closeThisDialog()" class="btn btn-default" translate>
Cancel
</button>
</form>

View File

@ -0,0 +1,408 @@
<div ng-controller="MotionListCtrl">
<div ng-controller="MotionAmendmentListCtrl">
<div class="header">
<div class="title">
<div class="submenu">
<a ui-sref="motions.motion.list" class="btn btn-sm btn-default">
<i class="fa fa-angle-double-left fa-lg"></i>
<translate>Back to motions overview</translate>
</a>
<button type="button" class="btn btn-sm"
ng-class="expandContent ? 'btn-primary' : 'btn-default'"
ng-click="toggleExpandContent(); saveExpandState(expandContent)">
<i class="fa fa-arrows-h fa-lg"></i>
<span ng-if="!expandContent" translate>Expand</span>
<span ng-if="expandContent" translate>Reduce</span>
</button>
</div>
<h1 translate>Amendments</h1>
<div ng-mouseover="selectHover=true" ng-mouseleave="selectHover=false"
class="dropdown-hover-space">
<h3>
<a ui-sref="motions.motion.detail({id: leadMotion.id})" ng-if="leadMotion">
<span ng-if="leadMotion.identifier">
{{ leadMotion.identifier }} &mdash;
</span>
{{ leadMotion.getTitle() }}
</a>
<span ng-if="!leadMotion" translate>
All motions
</span>
<span ng-class="{'hiddenDiv': !selectHover}" uib-dropdown>
<i class="fa fa-cog pointer" uib-dropdown-toggle id="selectDropdown"></i>
<ul class="dropdown-menu" aria-labelledby="selectDropdown">
<li ng-repeat="motion in leadMotions">
<a href ng-click="selectLeadMotion(motion)">
<span ng-if="motion.identifier">
{{ motion.identifier }} &mdash;
</span>
{{ motion.getTitle() | limitTo: 35 }}{{ motion.getTitle().length > 35 ? '...' : '' }}
</a>
</li>
<li class="divider" ng-if="amendment.state.getNextStates().length"></li>
<li>
<a href ng-click="selectLeadMotion(null)" translate>
All motions
</a>
</li>
</ul>
</span>
</h3>
</div>
</div>
</div>
<div class="details">
<div class="row">
<div class="col-sm-12">
<!-- select mode -->
<button os-perms="motions.can_manage" class="btn btn-sm"
ng-class="$parent.isSelectMode ? 'btn-primary' : 'btn-default'"
ng-click="$parent.isSelectMode = !$parent.isSelectMode; uncheckAll()">
<i class="fa fa-check-square-o"></i>
<translate>Select ...</translate>
</button>
<!-- Export dropdown -->
<div class="pull-right" uib-dropdown>
<button type="button" class="btn btn-default btn-sm" id="dropdownExport" uib-dropdown-toggle>
<i class="fa fa-upload"></i>
<span ng-if="amendmentsFiltered.length === amendments.length" translate>
Export all
</span>
<span ng-if="amendmentsFiltered.length !== amendments.length" translate>
Export filtered
</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownExport">
<!-- PDF export -->
<li os-perms="motions.can_manage">
<a href="" ng-click="openExportDialog(amendmentsFiltered)">
<i class="fa fa-file-pdf-o"></i>
<translate>Export dialog</translate>
</a>
</li>
<li os-perms="!motions.can_manage">
<a href="" ng-click="pdfExport(amendmentsFiltered)">
<i class="fa fa-file-pdf-o"></i>
PDF
</a>
</li>
<!-- amendment PDF export -->
<li>
<a href="" ng-click="amendmentPdfExport(amendmentsFiltered)">
<i class="fa fa-file-pdf-o"></i>
<translate>Amendment list PDF</translate>
</a>
</li>
<!-- CSV export -->
<li>
<a href="" ng-click="exportCsv(amendmentsFiltered)">
<i class="fa fa-file-text-o"></i>
CSV
</a>
</li>
</ul>
</div>
</div>
</div>
<div uib-collapse="!isSelectMode" class="row spacer">
<div class="col-sm-12 text-left form-inline" ng-show="isSelectMode" os-perms="motions.can_manage">
<!-- actions -->
<select ng-model="selectedAction" class="form-control input-sm">
<option value="" translate>--- Select action ---</option>
<option value="delete" translate>Delete</option>
<option value="setStatus" translate>Set status</option>
<option value="setCategory" ng-if="categories.length" translate>Set category</option>
<option value="setMotionBlock" ng-if="motionBlocks.length" translate>Set motion block</option>
</select>
<!-- state select -->
<select ng-show="selectedAction == 'setStatus'" ng-model="selectedState" class="form-control input-sm">
<option value="" translate>--- Select state ---</option>
<option ng-repeat="state in states" ng-if="!state.divider" ng-disabled="state.workflowHeader" value="{{ state.id }}">
{{ (state.workflowHeader ? state.headername : state.name) | translate }}
</option>
</select>
<!-- set state button -->
<a ng-show="selectedAction == 'setStatus' && selectedState"
ng-click="setStatusMultiple(amendmentsFiltered, selectedState)" class="btn btn-default btn-sm">
<translate>Set status</translate>
</a>
<!-- category select -->
<select ng-show="selectedAction == 'setCategory'" ng-model="selectedCategory" class="form-control input-sm">
<option value="" translate>--- Select category ---</option>
<option ng-repeat="category in categories | orderBy: config('motions_export_category_sorting')"
value="{{ category.id }}">
{{ category.prefix }} &ndash; {{ category.name }}
</option>
<option value="no_category_selected" translate>No category</option>
</select>
<!-- set category button -->
<a ng-show="selectedAction == 'setCategory' && selectedCategory"
ng-click="setCategoryMultiple(amendmentsFiltered, selectedCategory)" class="btn btn-default btn-sm">
<translate>Set category</translate>
</a>
<!-- motionBlock select -->
<select ng-show="selectedAction == 'setMotionBlock'" ng-model="selectedMotionBlock" class="form-control input-sm">
<option value="" translate>--- Select motion block ---</option>
<option ng-repeat="motionBlock in motionBlocks" value="{{ motionBlock.id }}">
{{ motionBlock.title }}
</option>
<option value="no_motionBlock_selected" translate>No motion block</option>
</select>
<!-- set motion block button -->
<a ng-show="selectedAction == 'setMotionBlock' && selectedMotionBlock"
ng-click="setMotionBlockMultiple(amendmentsFiltered, selectedMotionBlock)" class="btn btn-default btn-sm">
<translate>Set motion block</translate>
</a>
<!-- delete button -->
<a ng-show="selectedAction == 'delete'"
ng-bootbox-confirm="{{ 'Are you sure you want to delete all selected amendments?' | translate }}"
ng-bootbox-confirm-action="deleteMultiple(amendmentsFiltered)"
class="btn btn-default btn-sm btn-danger">
<i class="fa fa-trash fa-lg"></i>
<translate>Delete selected amendments</translate>
</a>
</div>
</div>
<div class="spacer-top-lg italic row">
<div class="col-md-6">
{{ amendmentsFiltered.length }} /
{{ amendments.length }}
<translate>amendments</translate><span ng-if="(amendments|filter:{selected:true}).length > 0">,
{{(amendments|filter:{selected:true}).length}} {{ "selected" | translate }}</span>
</div>
<div class="col-md-6" ng-show="amendmentsFiltered.length > pagination.itemsPerPage">
<span class="pull-right">
<translate>Page</translate> {{ pagination.currentPage }} /
{{ Math.ceil(amendmentsFiltered.length/pagination.itemsPerPage) }}
</span>
</div>
</div>
<div class="os-table container-fluid">
<div class="row header-row">
<div class="col-xs-1 centered" ng-if="isSelectMode">
<i class="fa text-danger pointer" ng-class=" selectedAll ? 'fa-check-square-o' : 'fa-square-o'"
ng-click="checkAll(amendmentsFiltered)"></i>
</div>
<div class="col-xs-11 main-header" ng-style="{'width': isSelectMode ? '' : '100%'}">
<ng-include src="'static/templates/motions/motion-table-filters.html'"></ng-include>
</div>
</div>
<!-- main table -->
<div class="row data-row" ng-repeat="amendment in amendmentsFiltered = (amendments
| osFilter: filter.filterString : filter.getObjectQueryString
| MultiselectFilter: stateFilter : getItemId.state
| MultiselectFilter: filter.multiselectFilters.comment : getItemId.comment
| MultiselectFilter: filter.multiselectFilters.category : getItemId.category
| MultiselectFilter: filter.multiselectFilters.motionBlock : getItemId.motionBlock
| MultiselectFilter: filter.multiselectFilters.recommendation : getItemId.recommendation
| MultiselectFilter: filter.multiselectFilters.tag : getItemId.tag
| filter: {star: filter.booleanFilters.isFavorite.value}
| filter: {hasPersonalNote: filter.booleanFilters.hasPersonalNote.value}
| filter: {isAmendment: filter.booleanFilters.isAmendment.value}
| toArray
| orderByEmptyLast: sort.column : sort.reverse)
| limitTo : pagination.itemsPerPage : pagination.limitBegin"
ng-class="{'projected': amendment.isProjected().length}">
<!-- select column -->
<div ng-show="isSelectMode" os-perms="motions.can_manage" class="col-xs-1 centered">
<i class="fa text-danger pointer" ng-click="amendment.selected=!amendment.selected"
ng-class="amendment.selected ? 'fa-check-square-o' : 'fa-square-o'"></i>
</div>
<!-- projector column -->
<div class="col-xs-1 centered projector" os-perms="core.can_manage_projector">
<projector-button model="amendment" default-projector-id="defaultProjectorId">
</projector-button>
</div>
<div class="no-projector-spacer" os-perms="!core.can_manage_projector"></div>
<!-- data -->
<div class="col-xs-2 content">
<div>
<!-- ID and title -->
<div>
<a class="title" ui-sref="motions.motion.detail({id: amendment.id})">
<span ng-if="amendment.identifier">{{ amendment.identifier }}</span>
<span ng-if="!amendment.identifier">{{ amendment.getTitle() }}</span>
<span ng-if="amendment.isParagraphBasedAmendment()"
ng-init="paragraph = amendment.getAmendmentParagraphsLinesDiff()[0]">
<span ng-if="paragraph">
<br>
<span ng-if="paragraph.diffLineTo == paragraph.diffLineFrom + 1">
(<translate>Line</translate> {{ paragraph.diffLineFrom }})
</span>
<span ng-if="paragraph.diffLineTo != paragraph.diffLineFrom + 1">
(<translate>Line</translate> {{ paragraph.diffLineFrom }}-{{ paragraph.diffLineTo }})
</span>
</span>
</span>
</a>
<a href="" ng-click="toggleStar(amendment)">
<i class="fa" ng-class="amendment.personalNote.star ? 'fa-star' : 'fa-star-o'"
title="{{ 'Set as favorite' | translate }}" ng-if="(amendment.personalNote.star || amendment.hover) && operator.user"></i>
</a>
<i class="fa fa-paperclip" ng-if="amendment.attachments_id.length > 0"></i>
</div>
<!-- state -->
<div os-perms="!motions.can_manage">
<span class="label" ng-class="'label-'+amendment.state.css_class">
{{ amendment.getStateName() }}
</span>
</div>
<div os-perms="motions.can_manage" uib-dropdown>
<a class="pointer label" uib-dropdown-toggle id="stateDropdown{{ amendment.id }}"
ng-class="'label-'+amendment.state.css_class">
{{ amendment.getStateName() }}
</a>
<ul class="dropdown-menu" aria-labelledby="stateDropdown{{ amendment.id }}">
<li ng-repeat="state in amendment.state.getNextStates()">
<a href ng-click="amendment.setState(state.id)">{{ state.action_word | translate }}</a>
</li>
<li class="divider" ng-if="amendment.state.getNextStates().length"></li>
<li>
<a href ng-if="amendment.isAllowed('reset_state')" ng-click="amendment.setState(null)">
<i class="fa fa-exclamation-triangle"></i>
<translate>Reset state</translate>
</a>
</li>
</ul>
</div>
<!-- recommendation -->
<div ng-if="config('motions_recommendations_by')">
<span os-perms="!motions.can_manage">
<span ng-if="amendment.recommendation" class="label"
ng-class="'label-'+amendment.recommendation.css_class">
{{ amendment.getRecommendationName() }}
</span>
<span ng-if="!amendment.recommendation" class="label label-default"
uib-tooltip="{{ config('motions_recommendations_by') }}" translate>
No recomendation set
</span>
</span>
<span os-perms="motions.can_manage" uib-dropdown>
<a class="pointer" uib-dropdown-toggle id="recommendationDropdown{{ amendment.id }}">
<span ng-if="amendment.recommendation" class="label"
ng-class="'label-'+amendment.recommendation.css_class">
{{ amendment.getRecommendationName() }}
</span>
<span ng-if="!amendment.recommendation" class="label label-default"
uib-tooltip="{{ config('motions_recommendations_by') }}" translate>
No recomendation set
</span>
</a>
<ul class="dropdown-menu" aria-labelledby="recommendationDropdown{{ amendment.id }}">
<li ng-repeat="recommendation in amendment.state.getRecommendations()">
<a href ng-click="amendment.setRecommendation(recommendation.id)">
{{ recommendation.recommendation_label | translate }}
</a>
</li>
<li class="divider" ng-if="amendment.state.getRecommendations().length && amendment.recommendation"></li>
<li ng-if="amendment.recommendation">
<a href ng-click="amendment.setRecommendation(null)">
<i class="fa fa-exclamation-triangle"></i>
<translate>Reset recommendation</translate>
</a>
</li>
</ul>
</span>
</div>
<!-- Submitters -->
<div ng-if="amendment.submitters.length">
<small>
<span class="optional" translate>by</span>
<span class="optional" ng-repeat="submitter in amendment.submitters | limitTo:1">
{{ submitter.get_full_name() }}<span ng-if="!$last">,</span></span><span ng-if="amendment.submitters.length > 1">,
... [+{{ amendment.submitters.length - 1 }}]</span>
<!-- sorry for merging them together, but otherwise there would be a whitespace because of the new line -->
</small>
</div>
<!-- hover menu -->
<div ng-if="amendment.isAllowed('update')">
<small>
<a href="" ng-click="openDialog(amendment)" translate>Edit</a>
<span ng-if="amendment.isAllowed('delete')"> &middot;
<a href="" class="text-danger"
ng-bootbox-confirm="{{ 'Are you sure you want to delete this entry?' | translate }}<br><b>{{ amendment.getTitle() }}</b>"
ng-bootbox-confirm-action="delete(amendment)" translate>Delete</a>
</span>
</small>
</div>
</div>
</div>
<div class="col-xs-6 col-space">
<!-- The diff -->
<section class="motion-text-holder" ng-if="amendment.isParagraphBasedAmendment()"
ng-init="paragraphs = amendment.getAmendmentParagraphsLinesDiff()">
<div ng-if="!paragraphs.length" translate>
No changes at the text.
</div>
<div ng-repeat="paragraph in paragraphs" class="motion-text motion-text-diff line-numbers-none">
<div ng-bind-html="paragraph.text | trusted"></div>
</div>
</section> <!-- Diff end -->
<div ng-if="!amendment.isParagraphBasedAmendment()">
{{ getTextPreview(amendment.getText(), 400) }}
</div>
</div>
<div class="col-xs-4 content" ng-style="{'width': isSelectMode ? 'calc(33.33% - 120px)' : 'calc(33.33% - 70px)'}">
<div style="width: 90%;">
<div ng-repeat="(id, field) in noSpecialCommentsFields">
<div class="nobr">
<i class="fa pointer spacer-right" ng-class="field[amendment.id] ? 'fa-caret-down' : 'fa-caret-right'"
ng-click="field[amendment.id] = !field[amendment.id]"
ng-if="isTextExpandable(amendment.comments[id], 30)"></i>
<strong>{{ field.name }}</strong>
</div>
<div ng-if="!field[amendment.id]">
{{ getTextPreview(amendment.comments[id], 30) }}
</div>
<div ng-if="field[amendment.id]" ng-bind-html="amendment.comments[id]"></div>
</div>
<div class="spacer-top" os-perms="motions.can_manage">
<button class="btn-link" ng-click="createModifiedAmendment(amendment)">
<translate>Create modified amendment</translate>
</button>
</div>
</div>
<div class="pull-right" style="width: 10%;">
<div class="centered" ng-if="config('motions_min_supporters') != 0"
uib-tooltip="{{ amendment.supporters.length }} {{ 'Supporters' | translate }}
{{ (config('motions_min_supporters') - amendment.supporters.length) > 0 ? '(' + (config('motions_min_supporters') - amendment.supporters.length) + ' ' + ('needed' | translate) + ')': '' }}"
tooltip-class="nobr">
<span class="badge"
ng-class="amendment.supporters.length < config('motions_min_supporters') ? 'badge-info' : 'badge-success'">
{{ amendment.supporters.length }}
</span>
</div>
</div>
</div>
</div>
<ul uib-pagination
ng-show="amendmentsFiltered.length > pagination.itemsPerPage"
total-items="amendmentsFiltered.length"
items-per-page="pagination.itemsPerPage"
ng-model="pagination.currentPage"
ng-change="pagination.pageChanged()"
class="pagination-sm"
direction-links="false"
boundary-links="true"
first-text="&laquo;"
last-text="&raquo;">
</ul>
</div> <!-- container -->
</div>
</div>
</div>

View File

@ -0,0 +1,16 @@
<h1>{{ title }}</h1>
<div uib-alert ng-show="alert.show" ng-class="'alert-' + (alert.type || 'warning')" close="alert={}">
{{ alert.msg }}
</div>
<form name="motionCommentForm" ng-submit="save(model)" novalidate>
<formly-form model="model" fields="formFields">
<button type="submit" ng-disabled="motionCommentForm.$invalid" class="btn btn-primary" translate>
Save
</button>
<button type="button" ng-click="closeThisDialog()" class="btn btn-default" translate>
Cancel
</button>
</formly-form>
</form>

View File

@ -1,10 +1,21 @@
<div class="header motion-header">
<div class="title">
<div class="submenu">
<a ui-sref="motions.motion.list" class="btn btn-sm btn-default">
<a ng-if="motion.isAmendment" ui-sref="motions.motion.amendment-list({id: motion.getParentMotion().id })"
class="btn btn-sm btn-default">
<i class="fa fa-angle-double-left fa-lg"></i>
<translate>Back to overview</translate>
</a>
<a ng-if="!motion.isAmendment" ui-sref="motions.motion.list"
class="btn btn-sm btn-default">
<i class="fa fa-angle-double-left fa-lg"></i>
<translate>Back to overview</translate>
</a>
<a ng-if="motion.hasAmendments()" ui-sref="motions.motion.amendment-list({id: motion.id})"
class="btn btn-sm btn-default">
<i class="fa fa-book fa-lg"></i>
<translate>Amendments</translate>
</a>
<!-- List of speakers -->
<a ui-sref="agenda.item.detail({id: motion.agenda_item_id})"
os-perms="agenda.can_see" class="btn btn-sm btn-default">
@ -23,20 +34,20 @@
<i class="fa fa-video-camera"></i>
</button>
<button type="button" class="btn btn-default btn-sm slimDropDown" uib-dropdown-toggle
ng-if="projectors.length > 1 || change_recommendations.length"
ng-if="projectors.length > 1 || has_proposed_changes"
ng-class="{ 'btn-primary': motion.isProjected().length && !inArray(motion.isProjected(), defaultProjectorId)}">
<span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu" aria-labelledby="split-button"
ng-if="projectors.length > 1 || change_recommendations.length">
<li role="menuitem" ng-repeat="mode in projectionModes" ng-if="change_recommendations.length">
ng-if="projectors.length > 1 || has_proposed_changes">
<li role="menuitem" ng-repeat="mode in projectionModes" ng-if="has_proposed_changes">
<a href="" ng-click="setProjectionMode(mode); $event.stopPropagation();">
<i class="fa" ng-class="mode.mode == $parent.projectionMode.mode ? 'fa-check-square-o' : 'fa-square-o'"></i>
<span ng-if="mode.mode!='agreed'">{{ mode.label | translate }}</span>
<span ng-if="mode.mode=='agreed'"><translate translate-context="resolution">Final version</translate></span>
</a>
</li>
<li class="divider" ng-show="projectors.length > 1 && change_recommendations.length > 0"></li>
<li class="divider" ng-show="projectors.length > 1 && has_proposed_changes"></li>
<li role="menuitem" ng-repeat="projector in projectors | orderBy:'id'" ng-show="projectors.length > 1">
<a href="" ng-click="motion.project(projector.id, projectionMode.mode)"
ng-class="{ 'projected': inArray(motion.isProjected(), projector.id) }">
@ -62,13 +73,18 @@
</div>
<h1 class="motion-title">
<span class="title-change-indicator"
ng-if="viewChangeRecommendations.mode == 'original' && title_change_recommendation"
<span ng-if="!motion.isAmendment && viewChangeRecommendations.mode == 'original'">
<span class="title-change-indicator" ng-if="title_change_recommendation"
ng-click="viewChangeRecommendations.scrollToDiffBox(title_change_recommendation.id)"></span>
<span class="change-title"
ng-if="motion.isAllowed('update') && viewChangeRecommendations.mode == 'original' && !title_change_recommendation"></span>
<span class="change-title" ng-if="motion.isAllowed('update') && !title_change_recommendation"></span>
</span>
<span>{{ motion.getTitleWithChanges(viewChangeRecommendations.mode) }}</span>
<a ui-sref="motions.motion.detail({id: motion.getParentMotion().id })" ng-if="motion.isAmendment">
{{ motion.getTitleWithChanges(viewChangeRecommendations.mode) }}
</a>
<span ng-if="!motion.isAmendment">
{{ motion.getTitleWithChanges(viewChangeRecommendations.mode) }}
</span>
<i class="fa pointer" ng-class="motion.personalNote.star ? 'fa-star' : 'fa-star-o'"
ng-if="operator.user"
@ -79,10 +95,6 @@
<div class="col-sm-6">
<h2>
<translate>Motion</translate> {{ motion.identifier }}
<span ng-if="parent">
(<translate>Amendment of motion</translate>
<a ui-sref="motions.motion.detail({id: parent.id})">{{ parent.identifier || parent.getTitle() }}</a>)
</span>
<span ng-if="motion.versions.length > 1" >| Version {{ motion.getVersion(version).version_number }}</span>
<span ng-if="motion.active_version != version" class="label label-warning">
<i class="fa fa-exclamation-triangle"></i>
@ -154,15 +166,13 @@
<translate>Unsupport motion</translate>
</button>
</div>
<!-- Amendments -->
<div ng-if="motion.isAllowed('can_see_amendments')">
<!-- Amendments -->
<div ng-if="!motion.isAmendment && motion.isAllowed('can_see_amendments')">
<h3 translate>Amendments</h3>
<div ng-repeat="amendment in amendments | orderBy: 'identifier'">
<a ui-sref="motions.motion.detail({id: amendment.id})">
<translate>Motion</translate> {{ amendment.identifier || amendment.getTitle() }}
<a ng-if="motion.hasAmendments()" ui-sref="motions.motion.amendment-list({id: motion.id})">
{{ motion.getAmendments().length }} <translate>Amendments</translate><br>
</a>
</div>
<button ng-if="motion.isAllowed('can_create_amendment')" ng-click="newAmendment()" class="btn btn-default btn-sm">
<i class="fa fa-plus"></i>
<translate>New amendment</translate>
@ -180,13 +190,13 @@
</span>
<ul uib-dropdown-menu class="dropdown-menu" aria-labelledby="state-dropdown">
<li ng-repeat="state in motion.state.getNextStates()">
<a href ng-click="updateState(state.id)">
<a href ng-click="motion.setState(state.id)">
{{ state.action_word | translate }}
<span ng-if="state.show_state_extension_field">...</span>
</a>
<li class="divider" ng-if="motion.state.getNextStates().length && motion.isAllowed('reset_state')">
<li ng-if="motion.isAllowed('reset_state')">
<a href ng-click="reset_state()">
<a href ng-click="motion.setState(null)">
<i class="fa fa-exclamation-triangle"></i>
<translate>Reset state</translate>
</a>
@ -210,7 +220,7 @@
</div>
<!-- Recommendation -->
<div ng-if="config('motions_recommendations_by') != ''">
<div ng-if="config('motions_recommendations_by')">
<h3 ng-if="!motion.isAllowed('change_recommendation')" class="heading">
{{ config('motions_recommendations_by') }}
</h3>
@ -222,13 +232,13 @@
</span>
<ul uib-dropdown-menu class="dropdown-menu" aria-labelledby="recommendation-dropdown">
<li ng-repeat="recommendation in motion.state.getRecommendations()">
<a href ng-click="updateRecommendation(recommendation.id)">
<a href ng-click="motion.setRecommendation(recommendation.id)">
{{ recommendation.recommendation_label | translate }}
<span ng-if="recommendation.show_recommendation_extension_field">...</span>
</a>
<li class="divider" ng-if="motion.state.getRecommendations().length && motion.recommendation">
<li ng-if="motion.recommendation">
<a href ng-click="resetRecommendation()">
<a href ng-click="motion.setRecommendation(null)">
<i class="fa fa-exclamation-triangle"></i>
<translate>Reset recommendation</translate>
</a>
@ -498,9 +508,10 @@
<div class="row">
<!-- Motion toolbar -->
<ng-include src="'static/templates/motions/motion-detail/toolbar.html'"></ng-include>
<ng-include src="'static/templates/motions/motion-detail/toolbar.html'" ng-if="!motion.isParagraphBasedAmendment()"></ng-include>
<div ng-class="{'col-sm-8': (lineNumberMode != 'outside'), 'col-sm-12': (lineNumberMode == 'outside')}">
<div ng-class="{'col-sm-8': (lineNumberMode != 'outside'), 'col-sm-12': (lineNumberMode == 'outside')}"
ng-if="!motion.isParagraphBasedAmendment()">
<ng-include ng-if="viewChangeRecommendations.mode == 'diff'"
src="'static/templates/motions/motion-detail/change-summary.html'"></ng-include>
@ -532,6 +543,15 @@
<!-- Agreed View -->
<div ng-if="viewChangeRecommendations.mode == 'agreed'">
<div class="alert alert-danger" ng-if="changed_version_has_accepted_collissions">
<i class="fa fa-warning"></i>
<translate>
At least two amendments or change recommendations affecting the same line are to be integrated.
This leads to undeterministic results.
Please resolve this conflict by not accepting multiple changes affecting the same line.
</translate>
</div>
<div ng-bind-html="motion.getTextByMode('agreed', version, highlight) | trusted"
class="motion-text motion-text-changed line-numbers-{{ lineNumberMode }}"></div>
@ -546,6 +566,10 @@
</div>
</div>
</div>
<!-- Paragraph-based Amendments -->
<ng-include src="'static/templates/motions/motion-detail/amendment-paragraph-diff.html'"></ng-include>
</div>
<div class="row">
<div class="col-md-8">

View File

@ -0,0 +1,43 @@
<div class="motion-toolbar" ng-if="motion.isParagraphBasedAmendment()">
<div class="toolbar-left {{ lineNumberMode }}">
<div class="btn-group pull-right" data-toggle="buttons">
<label class="btn btn-sm btn-default" ng-class="{active: showAmendmentContext}" ng-click="setShowAmendmentContext($event)">
<input type="checkbox" autocomplete="off" ng-model="showAmendmentContext" ng-checked="showAmendmentContext">
<translate>Show entire motion text</translate>
</label>
</div>
<ng-include src="'static/templates/motions/motion-detail/toolbar-line-numbering.html'"></ng-include>
</div>
</div>
<div ng-class="{'col-sm-8': (lineNumberMode != 'outside'), 'col-sm-12': (lineNumberMode == 'outside')}"
ng-if="motion.isParagraphBasedAmendment()">
<section class="motion-text-holder">
<div class="alert alert-info" ng-if="amendment_diff_paragraphs.length === 0">
<translate>No changes at the text</translate>
</div>
<div ng-repeat="paragraph in amendment_diff_paragraphs" class="motion-text motion-text-diff line-numbers-{{ lineNumberMode }}"
ng-class="{'amendment-context': showAmendmentContext}">
<div class="amendment-context" ng-if="showAmendmentContext">
<div ng-bind-html="motion.getParentMotion().getTextInLineRange(null, 1, paragraph.paragraphLineFrom) | trusted"
class="context"></div>
</div>
<h3 ng-if="paragraph.diffLineTo == paragraph.diffLineFrom + 1 && !showAmendmentContext" class="amendment-line-header">
<translate>Line</translate> {{ paragraph.diffLineFrom }}:
</h3>
<h3 ng-if="paragraph.diffLineTo != paragraph.diffLineFrom + 1 && !showAmendmentContext" class="amendment-line-header">
<translate>Line</translate> {{ paragraph.diffLineFrom }} - {{ paragraph.diffLineTo - 1 }}:
</h3>
<div class="paragraph-context" ng-bind-html="paragraph.textPre | trusted"></div>
<div ng-bind-html="paragraph.text | trusted"></div>
<div class="paragraph-context" ng-bind-html="paragraph.textPost | trusted"></div>
<div class="amendment-context" ng-if="showAmendmentContext">
<div ng-bind-html="motion.getParentMotion().getTextInLineRange(null, paragraph.paragraphLineTo, null) | trusted"></div>
</div>
</div>
</section>
</div>

View File

@ -1,17 +1,17 @@
<!-- A summary of all changes -->
<section class="change-recommendation-overview">
<strong>
<translate>Summary of change recommendations</translate>:
<translate>Summary of changes</translate>:
</strong>
<button os-perms="motions.can_manage" class="btn btn-sm btn-default pull-right"
uib-tooltip="{{ 'Note: You have to reject all change recommendations if the plenum does not follow the recommendation.' | translate }}"
ng-click="viewChangeRecommendations.rejectAll(motion)">
uib-tooltip="{{ 'Note: You have to reject all change recommendations if the plenum does not follow the recommendation. This does not affect amendments.' | translate }}"
ng-click="viewChangeRecommendations.rejectAllChangeRecommendations(motion)">
<i class="fa fa-thumbs-down"></i>
<translate>Reject all change recommendations</translate>
</button>
<ul ng-if="change_recommendations.length > 0 || title_change_recommendation">
<ul ng-if="has_proposed_changes">
<li ng-if="title_change_recommendation">
<a href='' ng-click="viewChangeRecommendations.scrollToDiffBox(title_change_recommendation.id)">
<span class="line-number"><translate>Title</translate>:</span>
@ -21,30 +21,35 @@
</span>
</a>
</li>
<li ng-repeat="change in (changes = (change_recommendations | filter:{motion_version_id:version}:true | orderBy: 'line_from')) ">
<a href='' ng-click="viewChangeRecommendations.scrollToDiffBox(change.id)">
<li ng-repeat="change in amendments_crs">
<a href='' ng-click="viewChangeRecommendations.scrollToDiffBox(change.id)"
ng-class="{amendment: change.type === 'amendment', recommendation: change.type === 'recommendation'}">
<span ng-if="change.line_from >= change.line_to - 1" class="line-number">
<translate>Line</translate> {{ change.line_from }}:
<translate>Line</translate> {{ change.line_from }}<span ng-if="change.type === 'recommendation'"></span>
</span>
<span ng-if="change.line_from < change.line_to - 1" class="line-number">
<translate>Line</translate> {{ change.line_from }} - {{ change.line_to - 1 }}:
<translate>Line</translate> {{ change.line_from }} -
{{ change.line_to - 1 }}<span ng-if="change.type === 'recommendation'"></span>
</span>
<span class="operation">
<translate ng-if="change.getType(motion.getVersion(version).text) == 0">Replacement</translate>
<translate ng-if="change.getType(motion.getVersion(version).text) == 1">Insertion</translate>
<translate ng-if="change.getType(motion.getVersion(version).text) == 2">Deletion</translate>
<span ng-if="change.getType(motion.getVersion(version).text) == 3">
<span ng-if="change.type === 'recommendation'">(<translate>Change recommendation</translate>)</span>
<span ng-if="change.type === 'amendment'">({{ change.original.identifier }})</span>
<span class="operation" ng-if="change.type === 'recommendation'">
<translate ng-if="change.original.getType(motion.getVersion(version).text) == 0">Replacement</translate>
<translate ng-if="change.original.getType(motion.getVersion(version).text) == 1">Insertion</translate>
<translate ng-if="change.original.getType(motion.getVersion(version).text) == 2">Deletion</translate>
<span ng-if="change.original.getType(motion.getVersion(version).text) == 3">
{{ change.other_description }}
</span>
</span>
<span class="status">
<translate ng-if="change.rejected">Rejected</translate>
<translate ng-if="change.accepted && change.type === 'amendment'">Accepted</translate>
</span>
</a>
</li>
</ul>
<div ng-if="change_recommendations.length == 0 && !title_change_recommendation" class="no-changes">
<div ng-if="!has_proposed_changes" class="no-changes">
<translate>No change recommendations yet</translate>
</div>
</section>

View File

@ -0,0 +1,53 @@
<!-- line number mode for resonsive size medium/large (button group) -->
<div class="btn-group hidden-sm hidden-xs" data-toggle="buttons">
<span class="btn btn-sm btn-default disabled">
<i class="fa fa-list-ol" aria-hidden="true"></i>
<translate>Line numbering</translate>:
</span>
<label class="btn btn-sm btn-default" ng-class="{active: (lineNumberMode == 'none')}"
ng-click="setLineNumberMode('none')">
<input type="radio" name="lineNumberMode" value="none" ng-model="lineNumberMode"
ng-checked="lineNumberMode == 'none'">
<translate>none</translate>
</label>
<label class="btn btn-sm btn-default" ng-class="{active: (lineNumberMode == 'inline')}"
ng-click="setLineNumberMode('inline')">
<input type="radio" name="lineNumberMode" value="inline" ng-model="lineNumberMode"
ng-checked="lineNumberMode == 'inline'">
<translate>inline</translate>
</label>
<label class="btn btn-sm btn-default" ng-class="{active: (lineNumberMode == 'outside')}"
ng-click="setLineNumberMode('outside')">
<input type="radio" name="lineNumberMode" value="outside" ng-model="lineNumberMode"
ng-checked="lineNumberMode == 'outside'">
<translate>outside</translate>
</label>
</div>
<!-- line number mode for resonsive size small/extra small (dropdown) -->
<div class="dropdown hidden-md hidden-lg" uib-dropdown>
<button type="button" class="btn btn-default btn-sm" id="dropdownLineMode" uib-dropdown-toggle>
<i class="fa fa-list-ol" aria-hidden="true"></i>
<translate>Line numbering</translate>
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-left" aria-labelledby="dropdownLineMode">
<li>
<a href="" ng-click="setLineNumberMode('none')">
<i class="fa fa-check" ng-if="lineNumberMode == 'none'"></i>
<translate>none</translate>
</a>
</li>
<li>
<a href="" ng-click="setLineNumberMode('inline')">
<i class="fa fa-check" ng-if="lineNumberMode == 'inline'"></i>
<translate>inline</translate>
</a>
</li>
<li>
<a href="" ng-click="setLineNumberMode('outside')">
<i class="fa fa-check" ng-if="lineNumberMode == 'outside'"></i>
<translate>outside</translate>
</a>
</li>
</ul>
</div>

View File

@ -2,17 +2,17 @@
<!-- inline editing -->
<div class="pull-right inline-editing-activator"
ng-if="motion.isAllowed('update') && version == motion.getVersion(-1).id && viewChangeRecommendations.mode == 'original'">
<button ng-if="!inlineEditing.active && change_recommendations.length == 0" ng-click="enableMotionInlineEditing()"
<button ng-if="!inlineEditing.active && !has_proposed_changes" ng-click="enableMotionInlineEditing()"
class="btn btn-sm btn-default">
<i class="fa fa-pencil-square-o"></i>
<translate>Inline editing</translate>
</button>
<button ng-if="inlineEditing.active && change_recommendations.length == 0" ng-click="disableMotionInlineEditing()"
<button ng-if="inlineEditing.active && !has_proposed_changes" ng-click="disableMotionInlineEditing()"
class="btn btn-sm btn-default">
<i class="fa fa-times-circle"></i>
<translate>Inline editing</translate>
</button>
<button ng-if="change_recommendations.length > 0" class="btn btn-sm btn-default" disabled
<button ng-if="has_proposed_changes" class="btn btn-sm btn-default" disabled
title="{{ 'Editing the text is not possible anymore once there are change recommendations.' | translate }}">
<i class="fa fa-pencil-square-o"></i>
<translate>Inline editing</translate>
@ -21,56 +21,7 @@
<div class="toolbar-left {{ lineNumberMode }}">
<!-- line number mode for resonsive size medium/large (button group) -->
<div class="btn-group hidden-sm hidden-xs" data-toggle="buttons">
<span class="btn btn-sm btn-default disabled">
<i class="fa fa-list-ol" aria-hidden="true"></i>
<translate>Line numbering</translate>:
</span>
<label class="btn btn-sm btn-default" ng-class="{active: (lineNumberMode == 'none')}"
ng-click="setLineNumberMode('none')">
<input type="radio" name="lineNumberMode" value="none" ng-model="lineNumberMode"
ng-checked="lineNumberMode == 'none'">
<translate>none</translate>
</label>
<label class="btn btn-sm btn-default" ng-class="{active: (lineNumberMode == 'inline')}"
ng-click="setLineNumberMode('inline')">
<input type="radio" name="lineNumberMode" value="inline" ng-model="lineNumberMode"
ng-checked="lineNumberMode == 'inline'">
<translate>inline</translate>
</label>
<label class="btn btn-sm btn-default" ng-class="{active: (lineNumberMode == 'outside')}"
ng-click="setLineNumberMode('outside')">
<input type="radio" name="lineNumberMode" value="outside" ng-model="lineNumberMode"
ng-checked="lineNumberMode == 'outside'">
<translate>outside</translate>
</label>
</div>
<!-- line number mode for resonsive size small/extra small (dropdown) -->
<div class="dropdown hidden-md hidden-lg inline" uib-dropdown>
<button type="button" class="btn btn-default btn-sm" id="dropdownLineMode" uib-dropdown-toggle>
<i class="fa fa-list-ol" aria-hidden="true"></i>
<translate>Line numbering</translate>
<span class="caret"></span>
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownLineMode">
<li>
<a href="" ng-click="setLineNumberMode('none')">
<i class="fa fa-check" ng-if="lineNumberMode == 'none'"></i>
<translate>none</translate>
</a>
<li>
<a href="" ng-click="setLineNumberMode('inline')">
<i class="fa fa-check" ng-if="lineNumberMode == 'inline'"></i>
<translate>inline</translate>
</a>
<li>
<a href="" ng-click="setLineNumberMode('outside')">
<i class="fa fa-check" ng-if="lineNumberMode == 'outside'"></i>
<translate>outside</translate>
</a>
</ul>
</div>
<ng-include src="'static/templates/motions/motion-detail/toolbar-line-numbering.html'"></ng-include>
<!-- go to line number -->
<div class="popover-wrapper">
@ -98,7 +49,7 @@
</div>
<!-- View Modes (Original, Diff, Changed) -->
<div class="motion-toolbar" ng-if="change_recommendations.length > 0 || title_change_recommendation">
<div class="motion-toolbar" ng-if="has_proposed_changes">
<div class="toolbar-left">
<!-- change recommendations for resonsive size medium/large (button group) -->
@ -144,7 +95,7 @@
<translate>Change recommendations</translate>
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownChangeVersion">
<ul class="dropdown-menu dropdown-menu-left" aria-labelledby="dropdownChangeVersion">
<li>
<a href="" ng-click="viewChangeRecommendations.mode = 'original'">
<i class="fa fa-check" ng-if="viewChangeRecommendations.mode == 'original'"></i>

View File

@ -46,39 +46,55 @@
<!-- The actual diff view -->
<div class="motion-text-with-diffs line-numbers-{{ lineNumberMode }}">
<div ng-repeat="change in (changes = (change_recommendations | filter:{motion_version_id:version}:true | orderBy: 'line_from')) ">
<div ng-repeat="change in amendments_crs">
<div class="motion-text original-text line-numbers-{{ lineNumberMode }}"
ng-bind-html="motion.getTextBetweenChangeRecommendations(version, changes[$index - 1], change, highlight) | trusted"></div>
ng-if="$index === 0 || amendments_crs[$index - 1].line_to < change.line_from"
ng-bind-html="motion.getTextBetweenChanges(version, amendments_crs[$index - 1], change, highlight) | trusted"></div>
<div ng-class="motion.isAllowed('can_manage') ? 'diff-box' : ''"
class="diff-box-{{ change.id }} clearfix">
<div class="action-row" ng-if="motion.isAllowed('can_manage')">
<div class="btn-group" data-toggle="buttons">
<label class="btn btn-sm btn-default" ng-class="{active: !change.rejected}"
title="{{ 'Not rejected' | translate }}" ng-click="change.rejected = false; change.saveStatus();">
<div ng-class="{'collides': change.getCollissions().length > 0}"
class="diff-box diff-box-{{ change.id }} clearfix">
<div class="collission-hint" ng-if="change.getCollissions().length > 0">
<i class="fa fa-warning" uib-tooltip="{{ 'This change collides with another one.' | translate }}"></i>
</div>
<div class="action-row">
<span ng-if="motion.isAllowed('can_manage')">
<div class="btn-group" data-toggle="buttons" ng-if="change.type == 'recommendation'">
<label class="btn btn-sm btn-default"
ng-class="{active: change.accepted, disabled: change.getAcceptedCollissions().length > 0}"
title="{{ 'Not rejected' | translate }}" ng-click="change.setAccepted($event)">
<input type="radio" name="changeRecommendationRejected[{{ change.id }}]" value="0"
ng-change="change.saveStatus()" ng-model="change.rejected" ng-checked="change.rejected == false">
ng-disabled="change.getAcceptedCollissions().length > 0"
ng-change="change.saveStatus()" ng-model="change.rejected" ng-checked="change.accepted == true">
<i class="fa fa-thumbs-up"></i>
</label>
<label class="btn btn-sm btn-default" ng-class="{active: change.rejected}"
title="{{ 'Rejected' | translate }}" ng-click="change.rejected = true; change.saveStatus();">
title="{{ 'Rejected' | translate }}" ng-click="change.setRejected($event)">
<input type="radio" name="changeRecommendationRejected[{{ change.id }}]" value="1"
ng-change="change.saveStatus()" ng-model="change.rejected" ng-checked="change.rejected == true">
<i class="fa fa-thumbs-down"></i>
</label>
</div>
<button class="btn btn-default btn-sm pull-right btn-delete"
ng-if="change.type == 'recommendation'"
ng-bootbox-confirm="{{ 'Are you sure you want to delete this change recommendation?' | translate }}"
ng-bootbox-confirm-action="viewChangeRecommendations.delete(change.id)"
ng-bootbox-confirm-action="viewChangeRecommendations.delete(change.original.id)"
title="{{ 'Delete' | translate }}">
<i class="fa fa-trash"></i>
</button>
<button class="btn btn-default btn-sm pull-right btn-edit" ng-click="createChangeRecommendation.editTextDialog(change)"
<button class="btn btn-default btn-sm pull-right btn-edit"
ng-if="change.type == 'recommendation'"
ng-click="createChangeRecommendation.editTextDialog(change.original)"
title="{{ 'Edit' | translate }}">
<i class="fa fa-pencil"></i>
</button>
</span>
<a ng-if="change.type == 'amendment'" ui-sref="motions.motion.detail({id: change.original.id})"
uib-tooltip="{{ 'Open amendment' | translate }}"
class="btn btn-default btn-sm pull-right btn-amend-info">
<i class="fa fa-info"></i>
{{ change.original.identifier }}
</a>
</div>
<div class="status-row" ng-if="!motion.isAllowed('can_manage') && change.rejected">
<i class="grey"><translate>Rejected</translate>:</i>
@ -90,6 +106,6 @@
</div>
<div class="motion-text original-text line-numbers-{{ lineNumberMode }}"
ng-bind-html="motion.getTextRemainderAfterLastChangeRecommendation(version, changes, highlight) | trusted"></div>
ng-bind-html="motion.getTextRemainderAfterLastChange(version, amendments_crs, highlight) | trusted"></div>
</div>
</div>

View File

@ -5,6 +5,10 @@
<i class="fa fa-plus fa-lg"></i>
<translate>New</translate>
</a>
<a ui-sref="motions.motion.allamendments" class="btn btn-default btn-sm">
<i class="fa fa-book fa-lg"></i>
<translate>Amendments</translate>
</a>
<a ui-sref="motions.category.list" os-perms="motions.can_manage" class="btn btn-default btn-sm">
<i class="fa fa-sitemap fa-lg"></i>
<translate>Categories</translate>
@ -39,7 +43,7 @@
</button>
<!-- Export button -->
<button type="button" class="btn btn-default btn-sm pull-right"
os-perms="motions.can_manage" ng-click="openExportDialog()">
os-perms="motions.can_manage" ng-click="openExportDialog(motionsFiltered)">
<i class="fa fa-upload"></i>
<span ng-if="motionsFiltered.length === motions.length" translate>
Export all
@ -49,7 +53,7 @@
</span>
</button>
<button type="button" class="btn btn-default btn-sm pull-right"
os-perms="!motions.can_manage" ng-click="pdfExport()">
os-perms="!motions.can_manage" ng-click="pdfExport(motionsFiltered)">
<i class="fa fa-file-pdf-o"></i>
<span ng-if="motionsFiltered.length === motions.length" translate>
Export all
@ -74,13 +78,13 @@
<!-- state select -->
<select ng-show="selectedAction == 'setStatus'" ng-model="selectedState" class="form-control input-sm">
<option value="" translate>--- Select state ---</option>
<option ng-repeat="state in states" ng-disabled="state.workflowHeader" value="{{ state.id }}">
<option ng-repeat="state in states" ng-if="!state.divider" ng-disabled="state.workflowHeader" value="{{ state.id }}">
{{ (state.workflowHeader ? state.headername : state.name) | translate }}
</option>
</select>
<!-- set state button -->
<a ng-show="selectedAction == 'setStatus' && selectedState"
ng-click="setStatusMultiple(selectedState)" class="btn btn-default btn-sm">
ng-click="setStatusMultiple(motionsFiltered, selectedState)" class="btn btn-default btn-sm">
<translate>Set status</translate>
</a>
<!-- category select -->
@ -94,7 +98,7 @@
</select>
<!-- set category button -->
<a ng-show="selectedAction == 'setCategory' && selectedCategory"
ng-click="setCategoryMultiple(selectedCategory)" class="btn btn-default btn-sm">
ng-click="setCategoryMultiple(motionsFiltered, selectedCategory)" class="btn btn-default btn-sm">
<translate>Set category</translate>
</a>
<!-- motionBlock select -->
@ -107,13 +111,13 @@
</select>
<!-- set motion block button -->
<a ng-show="selectedAction == 'setMotionBlock' && selectedMotionBlock"
ng-click="setMotionBlockMultiple(selectedMotionBlock)" class="btn btn-default btn-sm">
ng-click="setMotionBlockMultiple(motionsFilterd, selectedMotionBlock)" class="btn btn-default btn-sm">
<translate>Set motion block</translate>
</a>
<!-- delete button -->
<a ng-show="selectedAction == 'delete'"
ng-bootbox-confirm="{{ 'Are you sure you want to delete all selected motions?' | translate }}"
ng-bootbox-confirm-action="deleteMultiple()"
ng-bootbox-confirm-action="deleteMultiple(motionsFiltered)"
class="btn btn-default btn-sm btn-danger">
<i class="fa fa-trash fa-lg"></i>
<translate>Delete selected motions</translate>
@ -124,7 +128,8 @@
<div class="spacer-top-lg italic row">
<div class="col-md-6">
{{ motionsFiltered.length }} /
{{ motions.length }} {{ "motions" | translate }}<span ng-if="(motions|filter:{selected:true}).length > 0">,
{{ motions.length }}
<translate>motions</translate><span ng-if="(motions|filter:{selected:true}).length > 0">,
{{(motions|filter:{selected:true}).length}} {{ "selected" | translate }}</span>
</div>
<div class="col-md-6" ng-show="motionsFiltered.length > pagination.itemsPerPage">
@ -147,382 +152,10 @@
<div class="row header-row">
<div class="col-xs-1 centered" ng-if="isSelectMode">
<i class="fa text-danger pointer" ng-class=" selectedAll ? 'fa-check-square-o' : 'fa-square-o'"
ng-click="checkAll()"></i>
ng-click="checkAll(motionsFiltered)"></i>
</div>
<div class="col-xs-11 main-header" ng-style="{'width': isSelectMode ? '' : '100%'}">
<div class="form-inline text-right pull-right">
<!-- State filter -->
<span uib-dropdown>
<span class="pointer" id="dropdownState" uib-dropdown-toggle
ng-class="{'bold': filter.multiselectFilters.state.length > 0, 'disabled': isSelectMode}"
ng-disabled="isSelectMode">
<translate>State</translate>
<span class="caret"></span>
</span>
<ul class="dropdown-menu dropdown-menu-left" aria-labelledby="dropdownState">
<li ng-repeat="state in states" ng-class="{'dropdown-header': state.workflowHeader, 'divider': state.divider}">
<a ng-if="state.workflowHeader">
{{ state.headername | translate }}
</a>
<a href ng-if="!state.workflowHeader && !state.divider"
ng-click="operateStateFilter(state.id, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.state.indexOf(state.id) > -1"></i>
{{ state.name | translate }}
</a>
</li>
<li class="divider"></li>
<li>
<a href ng-click="operateStateFilter(-1, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.state.indexOf(-1) > -1"></i>
<translate>done</translate>
</a>
</li>
<li>
<a href ng-click="operateStateFilter(-2, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.state.indexOf(-2) > -1"></i>
<translate>undone</translate>
</a>
</li>
</ul>
</span>
<!-- recommendation filter -->
<span uib-dropdown ng-if="config('motions_recommendations_by') != ''">
<span class="pointer" id="dropdownRecommendation" uib-dropdown-toggle
ng-class="{'bold': filter.multiselectFilters.recommendation.length > 0, 'disabled': isSelectMode}"
ng-disabled="isSelectMode">
<translate>Recommendation</translate>
<span class="caret"></span>
</span>
<ul class="dropdown-menu dropdown-menu-left" aria-labelledby="dropdownRecommentation">
<li ng-repeat="recommendation in recommendations" ng-class="recommendation.workflowHeader ? 'dropdown-header' : ''">
<a ng-if="recommendation.workflowHeader">
{{ recommendation.headername | translate }}
</a>
<a href ng-if="!recommendation.workflowHeader"
ng-click="filter.operateMultiselectFilter('recommendation', recommendation.id, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.recommendation.indexOf(recommendation.id) > -1"></i>
{{ recommendation.recommendation_label | translate }}
</a>
</li>
<li class="divider"></li>
<li>
<a href ng-click="filter.operateMultiselectFilter('recommendation', -1, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.recommendation.indexOf(-1) > -1"></i>
<translate>No recommendation set</translate>
</a>
</li>
</ul>
</span>
<!-- Category filter -->
<span uib-dropdown ng-if="categories.length > 0">
<span class="pointer" id="dropdownCategory" uib-dropdown-toggle
ng-class="{'bold': filter.multiselectFilters.category.length > 0, 'disabled': isSelectMode}"
ng-disabled="isSelectMode">
<translate>Category</translate>
<span class="caret"></span>
</span>
<ul class="dropdown-menu dropdown-menu-left" aria-labelledby="dropdownCategory">
<li ng-repeat="category in categories | orderBy: config('motions_export_category_sorting')">
<a href ng-click="filter.operateMultiselectFilter('category', category.id, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.category.indexOf(category.id) > -1"></i>
{{ category.prefix }} &ndash; {{ category.name }}
</a>
</li>
<li class="divider"></li>
<li>
<a href ng-click="filter.operateMultiselectFilter('category', -1, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.category.indexOf(-1) > -1"></i>
<translate>No category set</translate>
</a>
</li>
</ul>
</span>
<!-- Motion block filter -->
<span uib-dropdown ng-if="motionBlocks.length > 0">
<span class="pointer" id="dropdownBlock" uib-dropdown-toggle
ng-class="{'bold': filter.multiselectFilters.motionBlock.length > 0, 'disabled': isSelectMode}"
ng-disabled="isSelectMode">
<translate>Motion block</translate>
<span class="caret"></span>
</span>
<ul class="dropdown-menu dropdown-menu-left" aria-labelledby="dropdownBlock">
<li ng-repeat="block in motionBlocks | orderBy: 'title'">
<a href ng-click="filter.operateMultiselectFilter('motionBlock', block.id, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.motionBlock.indexOf(block.id) > -1"></i>
{{ block.title }}
</a>
</li>
<li class="divider"></li>
<li>
<a href ng-click="filter.operateMultiselectFilter('motionBlock', -1, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.motionBlock.indexOf(-1) > -1"></i>
<translate>No motion block set</translate>
</a>
</li>
</ul>
</span>
<!-- Comment filter -->
<span uib-dropdown ng-if="showCommentsFilter()">
<span class="pointer" id="dropdownComment" uib-dropdown-toggle
ng-class="{'bold': filter.multiselectFilters.comment.length > 0, 'disabled': isSelectMode}"
ng-disabled="isSelectMode">
<translate>Comment</translate>
<span class="caret"></span>
</span>
<ul class="dropdown-menu dropdown-menu-left" aria-labelledby="dropdownComment">
<li ng-repeat="(index, commentsField) in noSpecialCommentsFields">
<a href ng-click="filter.operateMultiselectFilter('comment', index, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.comment.indexOf(index) > -1"></i>
{{ commentsField.name }} <translate>is set</translate>
</a>
</li>
<li class="divider"></li>
<li>
<a href ng-click="filter.operateMultiselectFilter('comment', -1, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.comment.indexOf(-1) > -1"></i>
<translate>No comments set</translate>
</a>
</li>
</ul>
</span>
<!-- Tag filter -->
<span uib-dropdown ng-if="tags.length > 0">
<span class="pointer" id="dropdownTag" uib-dropdown-toggle
ng-class="{'bold': filter.multiselectFilters.tag.length > 0, 'disabled': isSelectMode}"
ng-disabled="isSelectMode">
<translate>Tag</translate>
<span class="caret"></span>
</span>
<ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownTag">
<li ng-repeat="tag in tags">
<a href ng-click="filter.operateMultiselectFilter('tag', tag.id, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.tag.indexOf(tag.id) > -1"></i>
{{ tag.name }}
</a>
</li>
<li class="divider"></li>
<li>
<a href ng-click="filter.operateMultiselectFilter('tag', -1, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.tag.indexOf(-1) > -1"></i>
<translate>No tag set</translate>
</a>
</li>
</ul>
</span>
<!-- boolean Filters (customized!) -->
<span ng-if="operator.user.id" uib-dropdown>
<span class="pointer" id="dropdownMisc" uib-dropdown-toggle
ng-class="{'bold': (filter.booleanFilters.isFavorite.value !== undefined) ||
(filter.booleanFilters.hasPersonalNote.value !== undefined) ||
(filter.booleanFilters.isAmendment.value !== undefined), 'disabled': isSelectMode}"
ng-disabled="isSelectMode">
<translate>Misc</translate>
<span class="caret"></span>
</span>
<ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMisc">
<li>
<a href ng-click="filter.booleanFilters.isAmendment.value = (filter.booleanFilters.isAmendment.value ? undefined : true); filter.save();">
<i class="fa" ng-class="{'fa-check': filter.booleanFilters.isAmendment.value === true}"></i>
{{ filter.booleanFilters.isAmendment.choiceYes | translate }}
</a>
</li>
<li>
<a href ng-click="filter.booleanFilters.isAmendment.value = (filter.booleanFilters.isAmendment.value === false) ? undefined : false; filter.save();">
<i class="fa" ng-class="{'fa-check': filter.booleanFilters.isAmendment.value === false}"></i>
{{ filter.booleanFilters.isAmendment.choiceNo | translate }}
</a>
</li>
<li class="divider"></li>
<li>
<a href ng-click="filter.booleanFilters.isFavorite.value = (filter.booleanFilters.isFavorite.value ? undefined : true); filter.save();">
<i class="fa" ng-class="{'fa-check': filter.booleanFilters.isFavorite.value === true}"></i>
{{ filter.booleanFilters.isFavorite.choiceYes | translate }}
</a>
</li>
<li>
<a href ng-click="filter.booleanFilters.isFavorite.value = (filter.booleanFilters.isFavorite.value === false) ? undefined : false; filter.save();">
<i class="fa" ng-class="{'fa-check': filter.booleanFilters.isFavorite.value === false}"></i>
{{ filter.booleanFilters.isFavorite.choiceNo | translate }}
</a>
</li>
<li class="divider"></li>
<li>
<a href ng-click="filter.booleanFilters.hasPersonalNote.value = (filter.booleanFilters.hasPersonalNote.value ? undefined : true); filter.save();">
<i class="fa" ng-class="{'fa-check': filter.booleanFilters.hasPersonalNote.value === true}"></i>
{{ filter.booleanFilters.hasPersonalNote.choiceYes | translate }}
</a>
</li>
<li>
<a href ng-click="filter.booleanFilters.hasPersonalNote.value = (filter.booleanFilters.hasPersonalNote.value === false) ? undefined : false; filter.save();">
<i class="fa" ng-class="{'fa-check': filter.booleanFilters.hasPersonalNote.value === false}"></i>
{{ filter.booleanFilters.hasPersonalNote.choiceNo | translate }}
</a>
</li>
</ul>
</span>
<!-- dropdown sort -->
<span uib-dropdown>
<span class="pointer" id="dropdownSort" uib-dropdown-toggle
ng-class="{'disabled': isSelectMode}"
ng-disabled="isSelectMode">
<translate>Sort</translate>
<span class="caret"></span>
</span>
<ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownSort">
<!-- item -->
<li>
<a href ng-click="sort.toggle('agenda_item.getItemNumberWithAncestors()')">
<translate translate-comment="short form of agenda item">Item</translate>
<span class="spacer-right pull-right"></span>
<i class="pull-right fa"
ng-style="{'visibility': sort.column === 'agenda_item.getItemNumberWithAncestors()' ? 'visible' : 'hidden'}"
ng-class="sort.reverse ? 'fa-sort-amount-desc' : 'fa-sort-amount-asc'">
</i>
</a>
</li>
<!-- all other sortOptions -->
<li ng-repeat="option in sortOptions">
<a href ng-click="sort.toggle(option.name)">
<span ng-style="{'font-weight': sort.column === option.name ? 'bold' : 'normal'}">
{{ option.display_name | translate }}
</span>
<span class="spacer-right pull-right"></span>
<i class="pull-right fa"
ng-style="{'visibility': sort.column === option.name ? 'visible' : 'hidden'}"
ng-class="sort.reverse ? 'fa-sort-amount-desc' : 'fa-sort-amount-asc'">
</i>
</a>
</li>
</ul>
</span>
<!-- search field -->
<span class="form-group">
<span class="input-group">
<span class="input-group-addon"><i class="fa fa-search"></i></span>
<input type="text" ng-model="filter.filterString" class="form-control"
placeholder="{{ 'Search' | translate}}" ng-disabled="isSelectMode"
ng-change="filter.save()">
</span>
</span>
</div>
<!-- show all selected multiselectoptions -->
<div>
<!-- clear all filters -->
<span class="spacer-left-lg pointer" ng-click="resetFilters(isSelectMode)"
ng-if="filter.areFiltersSet()" ng-disabled="isSelectMode"
ng-class="{'disabled': isSelectMode}">
<i class="fa fa-window-close"></i>
<strong translate>All Filters</strong>
</span>
<!-- state -->
<span ng-repeat="state in states" class="pointer spacer-left-lg"
ng-if="!state.workflowHeader && filter.multiselectFilters.state.indexOf(state.id) > -1"
ng-click="operateStateFilter(state.id, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<span class="nobr">
<i class="fa fa-times-circle"></i>
{{ state.name | translate }}
</span>
</span>
<span ng-if="filter.multiselectFilters.state.indexOf(-1) > -1" class="pointer spacer-left-lg"
ng-click="operateStateFilter(-1, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<i class="fa fa-times-circle"></i>
<translate>done</translate>
</span>
<span ng-if="filter.multiselectFilters.state.indexOf(-2) > -1" class="pointer spacer-left-lg"
ng-click="operateStateFilter(-2, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<i class="fa fa-times-circle"></i>
<translate>undone</translate>
</span>
<!-- category -->
<span ng-repeat="category in categories | orderBy: config('motions_export_category_sorting')"
class="pointer spacer-left-lg"
ng-if="filter.multiselectFilters.category.indexOf(category.id) > -1"
ng-click="filter.operateMultiselectFilter('category', category.id, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<span class="nobr">
<i class="fa fa-times-circle"></i>
{{ category.prefix }} &ndash; {{ category.name }}
</span>
</span>
<span ng-if="filter.multiselectFilters.category.indexOf(-1) > -1" class="pointer spacer-left-lg"
ng-click="filter.operateMultiselectFilter('category', -1, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<i class="fa fa-times-circle"></i>
<translate>No category set</translate>
</span>
<!-- motion block -->
<span ng-repeat="motionBlock in motionBlocks | orderBy: 'title'" class="pointer spacer-left-lg"
ng-if="filter.multiselectFilters.motionBlock.indexOf(motionBlock.id) > -1"
ng-click="filter.operateMultiselectFilter('motionBlock', motionBlock.id, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<span class="nobr">
<i class="fa fa-times-circle"></i>
{{ motionBlock.title }}
</span>
</span>
<!-- comment -->
<span ng-repeat="(index, commentsField) in noSpecialCommentsFields" class="pointer spacer-left-lg"
ng-if="filter.multiselectFilters.comment.indexOf(index) > -1"
ng-click="filter.operateMultiselectFilter('comment', index, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<span class="nobr">
<i class="fa fa-times-circle"></i>
{{ commentsField.name }}
</span>
</span>
<span ng-if="filter.multiselectFilters.comment.indexOf(-1) > -1" class="pointer spacer-left-lg"
ng-click="filter.operateMultiselectFilter('comment', -1, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<i class="fa fa-times-circle"></i>
<translate>No comments set</translate>
</span>
<!-- recommendation -->
<span ng-repeat="recommendation in recommendations" class="pointer spacer-left-lg"
ng-if="filter.multiselectFilters.recommendation.indexOf(recommendation.id) > -1"
ng-click="filter.operateMultiselectFilter('recommendation', recommendation.id, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<span class="nobr">
<i class="fa fa-times-circle"></i>
{{ recommendation.recommendation_label | translate }}
</span>
</span>
<span ng-if="filter.multiselectFilters.motionBlock.indexOf(-1) > -1" class="pointer spacer-left-lg"
ng-click="filter.operateMultiselectFilter('motionBlock', -1, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<i class="fa fa-times-circle"></i>
<translate>No motion block set</translate>
</span>
<!-- tags -->
<span ng-repeat="tag in tags" class="pointer spacer-left-lg"
ng-if="filter.multiselectFilters.tag.indexOf(tag.id) > -1"
ng-click="filter.operateMultiselectFilter('tag', tag.id, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<span class="nobr">
<i class="fa fa-times-circle"></i>
{{ tag.name }}
</span>
</span>
<span ng-if="filter.multiselectFilters.tag.indexOf(-1) > -1" class="pointer spacer-left-lg"
ng-click="filter.operateMultiselectFilter('tag', -1, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<i class="fa fa-times-circle"></i>
<translate>No tag set</translate>
</span>
<!-- for all boolean Filters -->
<span ng-repeat="(name, booleanFilter) in filter.booleanFilters"
ng-hide="booleanFilter.value === undefined"
class="pointer spacer-left-lg"
ng-click="booleanFilter.value = undefined; filter.save();"
ng-class="{'disabled': isSelectMode}">
<span class="nobr">
<i class="fa fa-times-circle"></i>
{{ booleanFilter.value ? booleanFilter.choiceYes : booleanFilter.choiceNo | translate }}
</span>
</span>
</div>
<ng-include src="'static/templates/motions/motion-table-filters.html'"></ng-include>
</div>
</div>
@ -541,7 +174,6 @@
| MultiselectFilter: filter.multiselectFilters.tag : getItemId.tag
| filter: {star: filter.booleanFilters.isFavorite.value}
| filter: {hasPersonalNote: filter.booleanFilters.hasPersonalNote.value}
| filter: {isAmendment: filter.booleanFilters.isAmendment.value}
| toArray
| orderByEmptyLast: sort.column : sort.reverse)
| limitTo : pagination.itemsPerPage : pagination.limitBegin">
@ -585,11 +217,11 @@
<i class="fa fa-cog pointer" uib-dropdown-toggle id="stateDropdown{{ motion.id }}"></i>
<ul class="dropdown-menu" aria-labelledby="stateDropdown{{ motion.id }}">
<li ng-repeat="state in motion.state.getNextStates()">
<a href ng-click="updateState(motion, state.id)">{{ state.action_word | translate }}</a>
<a href ng-click="motion.setState(state.id)">{{ state.action_word | translate }}</a>
</li>
<li class="divider" ng-if="motion.state.getNextStates().length"></li>
<li>
<a href ng-if="motion.isAllowed('reset_state')" ng-click="resetState(motion)">
<a href ng-if="motion.isAllowed('reset_state')" ng-click="motion.setState(null)">
<i class="fa fa-exclamation-triangle"></i>
<translate>Reset state</translate>
</a>
@ -599,7 +231,7 @@
</span>
</div>
<!-- recommendation -->
<div ng-if="motion.recommendation">
<div ng-if="motion.recommendation && config('motions_recommendations_by')">
<span ng-mouseover="motion.recommendationHover=true" ng-mouseleave="motion.recommendationHover=false"
class="dropdown-hover-space">
<span class="label" ng-class="'label-'+motion.recommendation.css_class" uib-tooltip="{{ config('motions_recommendations_by') }}">
@ -609,13 +241,13 @@
<i class="fa fa-cog pointer" uib-dropdown-toggle id="recommendationDropdown{{ motion.id }}"></i>
<ul class="dropdown-menu" aria-labelledby="recommendationDropdown{{ motion.id }}">
<li ng-repeat="recommendation in motion.state.getRecommendations()">
<a href ng-click="updateRecommendation(motion, recommendation.id)">
<a href ng-click="motion.setRecommendation(recommendation.id)">
{{ recommendation.recommendation_label | translate }}
</a>
</li>
<li class="divider" ng-if="motion.state.getRecommendations().length && motion.recommendation"></li>
<li ng-if="motion.recommendation">
<a href ng-click="resetRecommendation(motion)">
<a href ng-click="motion.setRecommendation(null)">
<i class="fa fa-exclamation-triangle"></i>
<translate>Reset recommendation</translate>
</a>
@ -657,7 +289,7 @@
</div>
<!-- additional content column -->
<div class="col-xs-4 content" ng-style="{'width': isSelectMode ? 'calc(50% - 120px)' : 'calc(50% - 70px)'}">
<div style="width: 50%;" class="optional">
<div style="width: 45%;" class="optional">
<small>
<!-- Category dropdown for manage user -->
<div os-perms="motions.can_manage" ng-show="categories.length > 0"
@ -788,7 +420,7 @@
</span>
</div>
</div>
<div style="width: 10%;" class="pull-right">
<div style="width: 15%;" class="pull-right">
<div class="centered" ng-if="(motion.agenda_item.speakers | filter: {'begin_time': null}).length"
uib-tooltip="{{ (motion.agenda_item.speakers | filter: {'begin_time': null}).length }} {{ 'speakers' | translate }}"
tooltip-class="nobr">
@ -797,6 +429,14 @@
{{ (motion.agenda_item.speakers | filter: {'begin_time': null}).length }}
</a>
</div>
<div class="centered" ng-if="motion.hasAmendments()"
uib-tooltip="{{ motion.getAmendments().length }} {{ 'amendments' | translate }}"
tooltip-class="nobr">
<a ui-sref="motions.motion.amendment-list({id: motion.id})" class="badge">
<i class="fa fa-book"></i>
{{ motion.getAmendments().length }}
</a>
</div>
</div>
<div style="width: 30%;" class="pull-right">
<div class="centered">{{ motion.agenda_item.getItemNumberWithAncestors() }}</div>

View File

@ -0,0 +1,357 @@
<div class="form-inline text-right pull-right">
<!-- State filter -->
<span uib-dropdown>
<span class="pointer" id="dropdownState" uib-dropdown-toggle
ng-class="{'bold': filter.multiselectFilters.state.length > 0, 'disabled': isSelectMode}"
ng-disabled="isSelectMode">
<translate>State</translate>
<span class="caret"></span>
</span>
<ul class="dropdown-menu dropdown-menu-left" aria-labelledby="dropdownState">
<li ng-repeat="state in states" ng-class="{'dropdown-header': state.workflowHeader, 'divider': state.divider}">
<a ng-if="state.workflowHeader">
{{ state.headername | translate }}
</a>
<a href ng-if="!state.workflowHeader && !state.divider"
ng-click="operateStateFilter(state.id, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.state.indexOf(state.id) > -1"></i>
{{ state.name | translate }}
</a>
</li>
<li class="divider"></li>
<li>
<a href ng-click="operateStateFilter(-1, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.state.indexOf(-1) > -1"></i>
<translate>done</translate>
</a>
</li>
<li>
<a href ng-click="operateStateFilter(-2, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.state.indexOf(-2) > -1"></i>
<translate>undone</translate>
</a>
</li>
</ul>
</span>
<!-- recommendation filter -->
<span uib-dropdown ng-if="config('motions_recommendations_by') != ''">
<span class="pointer" id="dropdownRecommendation" uib-dropdown-toggle
ng-class="{'bold': filter.multiselectFilters.recommendation.length > 0, 'disabled': isSelectMode}"
ng-disabled="isSelectMode">
<translate>Recommendation</translate>
<span class="caret"></span>
</span>
<ul class="dropdown-menu dropdown-menu-left" aria-labelledby="dropdownRecommentation">
<li ng-repeat="recommendation in recommendations" ng-class="recommendation.workflowHeader ? 'dropdown-header' : ''">
<a ng-if="recommendation.workflowHeader">
{{ recommendation.headername | translate }}
</a>
<a href ng-if="!recommendation.workflowHeader"
ng-click="filter.operateMultiselectFilter('recommendation', recommendation.id, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.recommendation.indexOf(recommendation.id) > -1"></i>
{{ recommendation.recommendation_label | translate }}
</a>
</li>
<li class="divider"></li>
<li>
<a href ng-click="filter.operateMultiselectFilter('recommendation', -1, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.recommendation.indexOf(-1) > -1"></i>
<translate>No recommendation set</translate>
</a>
</li>
</ul>
</span>
<!-- Category filter -->
<span uib-dropdown ng-if="categories.length > 0">
<span class="pointer" id="dropdownCategory" uib-dropdown-toggle
ng-class="{'bold': filter.multiselectFilters.category.length > 0, 'disabled': isSelectMode}"
ng-disabled="isSelectMode">
<translate>Category</translate>
<span class="caret"></span>
</span>
<ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownCategory">
<li ng-repeat="category in categories | orderBy: config('motions_export_category_sorting')">
<a href ng-click="filter.operateMultiselectFilter('category', category.id, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.category.indexOf(category.id) > -1"></i>
{{ category.prefix }} &ndash; {{ category.name }}
</a>
</li>
<li class="divider"></li>
<li>
<a href ng-click="filter.operateMultiselectFilter('category', -1, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.category.indexOf(-1) > -1"></i>
<translate>No category set</translate>
</a>
</li>
</ul>
</span>
<!-- Motion block filter -->
<span uib-dropdown ng-if="motionBlocks.length > 0">
<span class="pointer" id="dropdownBlock" uib-dropdown-toggle
ng-class="{'bold': filter.multiselectFilters.motionBlock.length > 0, 'disabled': isSelectMode}"
ng-disabled="isSelectMode">
<translate>Motion block</translate>
<span class="caret"></span>
</span>
<ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownBlock">
<li ng-repeat="block in motionBlocks | orderBy: 'title'">
<a href ng-click="filter.operateMultiselectFilter('motionBlock', block.id, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.motionBlock.indexOf(block.id) > -1"></i>
{{ block.title }}
</a>
</li>
<li class="divider"></li>
<li>
<a href ng-click="filter.operateMultiselectFilter('motionBlock', -1, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.motionBlock.indexOf(-1) > -1"></i>
<translate>No motion block set</translate>
</a>
</li>
</ul>
</span>
<!-- Comment filter -->
<span uib-dropdown ng-if="showCommentsFilter()">
<span class="pointer" id="dropdownComment" uib-dropdown-toggle
ng-class="{'bold': filter.multiselectFilters.comment.length > 0, 'disabled': isSelectMode}"
ng-disabled="isSelectMode">
<translate>Comment</translate>
<span class="caret"></span>
</span>
<ul class="dropdown-menu dropdown-menu-left" aria-labelledby="dropdownComment">
<li ng-repeat="(index, commentsField) in noSpecialCommentsFields">
<a href ng-click="filter.operateMultiselectFilter('comment', index, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.comment.indexOf(index) > -1"></i>
{{ commentsField.name }} <translate>is set</translate>
</a>
</li>
<li class="divider"></li>
<li>
<a href ng-click="filter.operateMultiselectFilter('comment', -1, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.comment.indexOf(-1) > -1"></i>
<translate>No comments set</translate>
</a>
</li>
</ul>
</span>
<!-- Tag filter -->
<span uib-dropdown ng-if="tags.length > 0">
<span class="pointer" id="dropdownTag" uib-dropdown-toggle
ng-class="{'bold': filter.multiselectFilters.tag.length > 0, 'disabled': isSelectMode}"
ng-disabled="isSelectMode">
<translate>Tag</translate>
<span class="caret"></span>
</span>
<ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownTag">
<li ng-repeat="tag in tags">
<a href ng-click="filter.operateMultiselectFilter('tag', tag.id, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.tag.indexOf(tag.id) > -1"></i>
{{ tag.name }}
</a>
</li>
<li class="divider"></li>
<li>
<a href ng-click="filter.operateMultiselectFilter('tag', -1, isSelectMode)">
<i class="fa fa-check" ng-if="filter.multiselectFilters.tag.indexOf(-1) > -1"></i>
<translate>No tag set</translate>
</a>
</li>
</ul>
</span>
<!-- boolean Filters (customized!) -->
<span ng-if="operator.user.id" uib-dropdown>
<span class="pointer" id="dropdownMisc" uib-dropdown-toggle
ng-class="{'bold': (filter.booleanFilters.isFavorite.value !== undefined) ||
(filter.booleanFilters.hasPersonalNote.value !== undefined), 'disabled': isSelectMode}"
ng-disabled="isSelectMode">
<translate>Misc</translate>
<span class="caret"></span>
</span>
<ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMisc">
<li>
<a href ng-click="filter.booleanFilters.isFavorite.value = (filter.booleanFilters.isFavorite.value ? undefined : true); filter.save();">
<i class="fa" ng-class="{'fa-check': filter.booleanFilters.isFavorite.value === true}"></i>
{{ filter.booleanFilters.isFavorite.choiceYes | translate }}
</a>
</li>
<li>
<a href ng-click="filter.booleanFilters.isFavorite.value = (filter.booleanFilters.isFavorite.value === false) ? undefined : false; filter.save();">
<i class="fa" ng-class="{'fa-check': filter.booleanFilters.isFavorite.value === false}"></i>
{{ filter.booleanFilters.isFavorite.choiceNo | translate }}
</a>
</li>
<li class="divider"></li>
<li>
<a href ng-click="filter.booleanFilters.hasPersonalNote.value = (filter.booleanFilters.hasPersonalNote.value ? undefined : true); filter.save();">
<i class="fa" ng-class="{'fa-check': filter.booleanFilters.hasPersonalNote.value === true}"></i>
{{ filter.booleanFilters.hasPersonalNote.choiceYes | translate }}
</a>
</li>
<li>
<a href ng-click="filter.booleanFilters.hasPersonalNote.value = (filter.booleanFilters.hasPersonalNote.value === false) ? undefined : false; filter.save();">
<i class="fa" ng-class="{'fa-check': filter.booleanFilters.hasPersonalNote.value === false}"></i>
{{ filter.booleanFilters.hasPersonalNote.choiceNo | translate }}
</a>
</li>
</ul>
</span>
<!-- dropdown sort -->
<span uib-dropdown>
<span class="pointer" id="dropdownSort" uib-dropdown-toggle
ng-class="{'disabled': isSelectMode}"
ng-disabled="isSelectMode">
<translate>Sort</translate>
<span class="caret"></span>
</span>
<ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownSort">
<!-- item -->
<li>
<a href ng-click="sort.toggle('agenda_item.getItemNumberWithAncestors()')">
<translate translate-comment="short form of agenda item">Item</translate>
<span class="spacer-right pull-right"></span>
<i class="pull-right fa"
ng-style="{'visibility': sort.column === 'agenda_item.getItemNumberWithAncestors()' ? 'visible' : 'hidden'}"
ng-class="sort.reverse ? 'fa-sort-desc' : 'fa-sort-asc'">
</i>
</a>
</li>
<!-- all other sortOptions -->
<li ng-repeat="option in sortOptions">
<a href ng-click="sort.toggle(option.name)">
{{ option.display_name | translate }}
<span class="spacer-right pull-right"></span>
<i class="pull-right fa"
ng-style="{'visibility': sort.column === option.name ? 'visible' : 'hidden'}"
ng-class="sort.reverse ? 'fa-sort-amount-desc' : 'fa-sort-amount-asc'">
</i>
</a>
</li>
</ul>
</span>
<!-- search field -->
<span class="form-group">
<span class="input-group">
<span class="input-group-addon"><i class="fa fa-search"></i></span>
<input type="text" ng-model="filter.filterString" class="form-control"
placeholder="{{ 'Search' | translate}}" ng-disabled="isSelectMode"
ng-change="filter.save()">
</span>
</span>
</div>
<!-- show all selected multiselectoptions -->
<div>
<!-- clear all filters -->
<span class="spacer-left-lg pointer" ng-click="resetFilters(isSelectMode)"
ng-if="filter.areFiltersSet()" ng-disabled="isSelectMode"
ng-class="{'disabled': isSelectMode}">
<i class="fa fa-window-close"></i>
<strong translate>All Filters</strong>
</span>
<!-- state -->
<span ng-repeat="state in states" class="pointer spacer-left-lg"
ng-if="!state.workflowHeader && filter.multiselectFilters.state.indexOf(state.id) > -1"
ng-click="operateStateFilter(state.id, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<span class="nobr">
<i class="fa fa-times-circle"></i>
{{ state.name | translate }}
</span>
</span>
<span ng-if="filter.multiselectFilters.state.indexOf(-1) > -1" class="pointer spacer-left-lg"
ng-click="operateStateFilter(-1, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<i class="fa fa-times-circle"></i>
<translate>done</translate>
</span>
<span ng-if="filter.multiselectFilters.state.indexOf(-2) > -1" class="pointer spacer-left-lg"
ng-click="operateStateFilter(-2, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<i class="fa fa-times-circle"></i>
<translate>undone</translate>
</span>
<!-- category -->
<span ng-repeat="category in categories | orderBy: config('motions_export_category_sorting')"
class="pointer spacer-left-lg"
ng-if="filter.multiselectFilters.category.indexOf(category.id) > -1"
ng-click="filter.operateMultiselectFilter('category', category.id, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<span class="nobr">
<i class="fa fa-times-circle"></i>
{{ category.prefix }} &ndash; {{ category.name }}
</span>
</span>
<span ng-if="filter.multiselectFilters.category.indexOf(-1) > -1" class="pointer spacer-left-lg"
ng-click="filter.operateMultiselectFilter('category', -1, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<i class="fa fa-times-circle"></i>
<translate>No category set</translate>
</span>
<!-- motion block -->
<span ng-repeat="motionBlock in motionBlocks | orderBy: 'title'" class="pointer spacer-left-lg"
ng-if="filter.multiselectFilters.motionBlock.indexOf(motionBlock.id) > -1"
ng-click="filter.operateMultiselectFilter('motionBlock', motionBlock.id, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<span class="nobr">
<i class="fa fa-times-circle"></i>
{{ motionBlock.title }}
</span>
</span>
<!-- comment -->
<span ng-repeat="(index, commentsField) in noSpecialCommentsFields" class="pointer spacer-left-lg"
ng-if="filter.multiselectFilters.comment.indexOf(index) > -1"
ng-click="filter.operateMultiselectFilter('comment', index, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<span class="nobr">
<i class="fa fa-times-circle"></i>
{{ commentsField.name }}
</span>
</span>
<span ng-if="filter.multiselectFilters.comment.indexOf(-1) > -1" class="pointer spacer-left-lg"
ng-click="filter.operateMultiselectFilter('comment', -1, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<i class="fa fa-times-circle"></i>
<translate>No comments set</translate>
</span>
<!-- recommendation -->
<span ng-repeat="recommendation in recommendations" class="pointer spacer-left-lg"
ng-if="filter.multiselectFilters.recommendation.indexOf(recommendation.id) > -1"
ng-click="filter.operateMultiselectFilter('recommendation', recommendation.id, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<span class="nobr">
<i class="fa fa-times-circle"></i>
{{ recommendation.recommendation_label | translate }}
</span>
</span>
<span ng-if="filter.multiselectFilters.motionBlock.indexOf(-1) > -1" class="pointer spacer-left-lg"
ng-click="filter.operateMultiselectFilter('motionBlock', -1, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<i class="fa fa-times-circle"></i>
<translate>No motion block set</translate>
</span>
<!-- tags -->
<span ng-repeat="tag in tags" class="pointer spacer-left-lg"
ng-if="filter.multiselectFilters.tag.indexOf(tag.id) > -1"
ng-click="filter.operateMultiselectFilter('tag', tag.id, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<span class="nobr">
<i class="fa fa-times-circle"></i>
{{ tag.name }}
</span>
</span>
<span ng-if="filter.multiselectFilters.tag.indexOf(-1) > -1" class="pointer spacer-left-lg"
ng-click="filter.operateMultiselectFilter('tag', -1, isSelectMode)"
ng-class="{'disabled': isSelectMode}">
<i class="fa fa-times-circle"></i>
<translate>No tag set</translate>
</span>
<!-- for all boolean Filters -->
<span ng-repeat="(name, booleanFilter) in filter.booleanFilters"
ng-hide="booleanFilter.value === undefined"
class="pointer spacer-left-lg"
ng-click="booleanFilter.value = undefined; filter.save();"
ng-class="{'disabled': isSelectMode}">
<span class="nobr">
<i class="fa fa-times-circle"></i>
{{ booleanFilter.value ? booleanFilter.choiceYes : booleanFilter.choiceNo | translate }}
</span>
</span>
</div>

View File

@ -73,10 +73,21 @@
<h2>
<translate>Motion</translate> {{ motion.identifier }}
<span ng-if="motion.versions.length > 1" >| Version {{ motion.getVersion().version_number }}</span>
<span ng-if="motion.isParagraphBasedAmendment()"
ng-init="paragraph = motion.getAmendmentParagraphsLinesDiff()[0]">
<span ng-if="paragraph">
<span ng-if="paragraph.diffLineTo == paragraph.diffLineFrom + 1">
(<translate>Line</translate> {{ paragraph.diffLineFrom }})
</span>
<span ng-if="paragraph.diffLineTo != paragraph.diffLineFrom + 1">
(<translate>Line</translate> {{ paragraph.diffLineFrom }}-{{ paragraph.diffLineTo }})
</span>
</span>
</span>
</h2>
</div>
<div class="zoomcontent">
<div class="zoomcontent" ng-if="!motion.isParagraphBasedAmendment()">
<!-- Preamble -->
<div><p>{{ config('motions_preamble') | translate }}</p></div><br>
@ -101,17 +112,17 @@
<!-- The actual diff view -->
<div class="motion-text-with-diffs line-numbers-{{ config('motions_default_line_numbering') }}">
<div ng-repeat="change in (changes = (change_recommendations | orderBy: 'line_from')) ">
<div ng-repeat="change in amendments_crs">
<div class="motion-text original-text line-numbers-{{ config('motions_default_line_numbering') }}"
ng-bind-html="motion.getTextBetweenChangeRecommendations(null, changes[$index - 1], change, line) | trusted">
ng-if="$index === 0 || amendments_crs[$index - 1].line_to < change.line_from"
ng-bind-html="motion.getTextBetweenChanges(version, amendments_crs[$index - 1], change, highlight) | trusted">
</div>
<div class="diff-box diff-box-{{ change.id }} clearfix motion-text motion-text-diff line-numbers-{{ config('motions_default_line_numbering') }}">
<div ng-bind-html="change.getDiff(motion, null, line) | trusted"></div>
<div ng-bind-html="change.getDiff(motion, null, highlight) | trusted"></div>
</div>
</div>
<div class="motion-text original-text line-numbers-{{ config('motions_default_line_numbering') }}"
ng-bind-html="motion.getTextRemainderAfterLastChangeRecommendation(null, changes, line) | trusted">
ng-bind-html="motion.getTextRemainderAfterLastChange(version, amendments_crs, highlight) | trusted">
</div>
</div>
@ -125,6 +136,15 @@
<!-- Agreed View -->
<div ng-if="mode == 'agreed'">
<div class="alert alert-danger" ng-if="changed_version_has_accepted_collissions">
<i class="fa fa-warning"></i>
<translate>
At least two amendments or change recommendations affecting the same line are to be integrated.
This leads to undeterministic results.
Please resolve this conflict by not accepting multiple changes affecting the same line.
</translate>
</div>
<div ng-bind-html="motion.getTextByMode('agreed', null, line) | trusted"
class="motion-text motion-text-changed line-numbers-{{ config('motions_default_line_numbering') }}"></div>
</div>
@ -135,5 +155,20 @@
<div ng-bind-html="motion.getReason() | trusted"></div>
</div>
</div>
<!-- Amendments -->
<div class="zoomcontent" ng-if="motion.isParagraphBasedAmendment()">
<div ng-repeat="paragraph in amendment_diff_paragraphs" class="motion-text motion-text-diff line-numbers-{{ lineNumberMode }}"
ng-class="{'amendment-context': showAmendmentContext}">
<div ng-bind-html="paragraph.text | trusted"></div>
</div>
<!-- Reason -->
<div ng-if="motion.getReason() && !config('motions_disable_reason_on_projector')">
<h3 translate>Reason</h3>
<div ng-bind-html="motion.getReason() | trusted"></div>
</div>
</div>
</div>
</div>

View File

@ -131,6 +131,7 @@ class MotionViewSet(ModelViewSet):
# For creating amendments.
whitelist.extend([
'parent_id',
'amendment_paragraphs',
'category_id', # This will be set to the matching
'motion_block_id', # values from parent_motion.
])

View File

@ -546,6 +546,19 @@ describe('linenumbering', function () {
expect(diff).toBe(expected);
});
it('handles inserted paragraphs (4)', function () {
var before = "<p>This is a random first line that remains unchanged.</p>",
after = "<p>This is a random first line that remains unchanged.</p>" +
'<p style="text-align: justify;"><span style="color: #000000;">Inserting this line should not make any troubles, especially not affect the first line</span></p>' +
'<p style="text-align: justify;"><span style="color: #000000;">Neither should this line</span></p>',
expected = "<p>This is a random first line that remains unchanged.</p>" +
'<p style="text-align: justify;"><ins><span style="color: #000000;">Inserting this line should not make any troubles, especially not affect the first line</span></ins></p>' +
'<p style="text-align: justify;"><ins><span style="color: #000000;">Neither should this line</span></ins></p>';
var diff = diffService.diff(before, after);
expect(diff).toBe(expected);
});
it('handles completely deleted paragraphs', function () {
var before = "<P>Ihr könnt ohne Sorge fortgehen.'Da meckerte die Alte und machte sich getrost auf den Weg.</P>",
after = "";
@ -630,6 +643,25 @@ describe('linenumbering', function () {
var diff = diffService.diff(before, after);
expect(diff).toBe('elitr<del>. einsetzt. VERSCHLUCKT noch die sog.</del><ins>, Einfügung durch Änderung der</ins> Gleichbleibend<del> (Wird gelöscht).</del><ins>, einsetzt.</ins>');
});
it('does not fall back to block level replacement when BRs are inserted/deleted', function() {
var before = '<p>Lorem ipsum dolor sit amet, consetetur <br>sadipscing elitr.<br>Bavaria ipsum dolor sit amet oha wea nia ausgähd<br>kummt nia hoam i hob di narrisch gean</p>',
after = '<p>Lorem ipsum dolor sit amet, consetetur sadipscing elitr. Sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua..<br>\n' +
'Bavaria ipsum dolor sit amet oha wea nia ausgähd<br>\n' +
'Autonomie erfährt ihre Grenzen</p>';
var diff = diffService.diff(before, after);
expect(diff).toBe('<p>Lorem ipsum dolor sit amet, consetetur <del><br></del>sadipscing elitr.<ins> Sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua..</ins><br>Bavaria ipsum dolor sit amet oha wea nia ausgähd<br><del>kummt nia hoam i hob di narrisch gean</del><ins>Autonomie erfährt ihre Grenzen</ins></p>');
});
it('does not a change in a very specific case', function() {
// See diff._fixWrongChangeDetection
var inHtml = '<p>Test 123<br>wir strikt ab. lehnen wir ' + brMarkup(1486) + 'ab.<br>' + noMarkup(1487) + 'Gegenüber</p>',
outHtml = '<p>Test 123<br>\n' +
'wir strikt ab. lehnen wir ab.<br>\n' +
'Gegenüber</p>';
var diff = diffService.diff(inHtml, outHtml);
expect(diff).toBe('<p>Test 123<br>wir strikt ab. lehnen wir ' + brMarkup(1486) + 'ab.<br>' + noMarkup(1487) + 'Gegenüber</p>')
});
});
describe('ignoring line numbers', function () {
@ -715,4 +747,53 @@ describe('linenumbering', function () {
expect(cleaned).toBe('<UL class="os-split-before os-split-after"><LI class="os-split-before"><UL class="os-split-before os-split-after"><LI class="os-split-before">...here it goes on</LI><LI>This has been added</LI></UL></LI></UL>');
});
});
describe('detecting changed line number range', function () {
it('detects changed line numbers in the middle', function () {
var before = '<p>' + noMarkup(1) + 'foo &amp; bar' + brMarkup(2) + 'Another line' +
brMarkup(3) + 'This will be changed' + brMarkup(4) + 'This, too' + brMarkup(5) + 'End</p>',
after = '<p>' + noMarkup(1) + 'foo &amp; bar' + brMarkup(2) + 'Another line' +
brMarkup(3) + 'This has been changed' + brMarkup(4) + 'End</p>';
var diff = diffService.diff(before, after);
var affected = diffService.detectAffectedLineRange(diff);
expect(affected).toEqual({"from": 3, "to": 5});
});
it('detects changed line numbers at the beginning', function () {
var before = '<p>Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat</p>',
after = '<p>sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat</p>';
before = lineNumberingService.insertLineNumbers(before, 20);
var diff = diffService.diff(before, after);
var affected = diffService.detectAffectedLineRange(diff);
expect(affected).toEqual({"from": 1, "to": 2});
});
});
describe('stripping ins/del-styles/tags', function () {
it('deletes to be deleted nodes', function () {
var inHtml = '<p>Test <span class="delete">Test 2</span> Another test <del>Test 3</del></p><p class="delete">Test 4</p>';
var stripped = diffService.diffHtmlToFinalText(inHtml);
expect(stripped).toBe('<P>Test Another test </P>');
});
it('produces empty paragraphs, if necessary', function () {
var inHtml = '<p class="delete">Test <span class="delete">Test 2</span> Another test <del>Test 3</del></p><p class="delete">Test 4</p>';
var stripped = diffService.diffHtmlToFinalText(inHtml);
expect(stripped).toBe('');
});
it('Removes INS-tags', function () {
var inHtml = '<p>Test <ins>Test <strong>2</strong></ins> Another test</p>';
var stripped = diffService.diffHtmlToFinalText(inHtml);
expect(stripped).toBe('<P>Test Test <STRONG>2</STRONG> Another test</P>');
});
it('Removes .insert-classes', function () {
var inHtml = '<p class="insert">Test <strong>1</strong></p><p class="insert anotherclass">Test <strong>2</strong></p>';
var stripped = diffService.diffHtmlToFinalText(inHtml);
expect(stripped).toBe('<P>Test <STRONG>1</STRONG></P><P class="anotherclass">Test <STRONG>2</STRONG></P>');
});
});
});

View File

@ -22,6 +22,46 @@ describe('linenumbering', function () {
lineNumberingService = _lineNumberingService_;
}));
describe('paragraph splitting', function () {
it('breaks simple DIVs', function () {
var htmlIn = '<DIV class="testclass">Test <strong>1</strong></DIV>' + "\n" + '<p>Test <em>2</em> 3</p>';
var out = lineNumberingService.splitToParagraphs(htmlIn);
expect(out.length).toBe(2);
expect(out[0]).toBe('<div class="testclass">Test <strong>1</strong></div>');
expect(out[1]).toBe('<p>Test <em>2</em> 3</p>');
});
it('ignores root-level text-nodes', function () {
var htmlIn = '<DIV class="testclass">Test <strong>3</strong></DIV>' + "\n New line";
var out = lineNumberingService.splitToParagraphs(htmlIn);
expect(out.length).toBe(1);
expect(out[0]).toBe('<div class="testclass">Test <strong>3</strong></div>');
});
it('splits UL-Lists', function () {
var htmlIn = "<UL class='testclass'>\n<li>Node 1</li>\n <li class='second'>Node <strong>2</strong></li><li><p>Node 3</p></li></UL>";
var out = lineNumberingService.splitToParagraphs(htmlIn);
expect(out.length).toBe(3);
expect(out[0]).toBe('<ul class="testclass"><li>Node 1</li></ul>');
expect(out[1]).toBe('<ul class="testclass"><li class="second">Node <strong>2</strong></li></ul>');
expect(out[2]).toBe('<ul class="testclass"><li><p>Node 3</p></li></ul>');
});
it('splits OL-Lists', function () {
var htmlIn = "<OL start='2' class='testclass'>\n<li>Node 1</li>\n <li class='second'>Node <strong>2</strong></li><li><p>Node 3</p></li></OL>";
var out = lineNumberingService.splitToParagraphs(htmlIn);
expect(out.length).toBe(3);
expect(out[0]).toBe('<ol start="2" class="testclass"><li>Node 1</li></ol>');
expect(out[1]).toBe('<ol start="3" class="testclass"><li class="second">Node <strong>2</strong></li></ol>');
expect(out[2]).toBe('<ol start="4" class="testclass"><li><p>Node 3</p></li></ol>');
});
});
describe('getting line number range', function () {
it('extracts the line number range, example 1', function () {
var html = '<p>' + noMarkup(2) + 'et accusam et justo duo dolores et ea <span style="color: #ff0000;"><strike>rebum </strike></span><span style="color: #006400;">Inserted Text</span>. Stet clita kasd ' + brMarkup(3) + 'gubergren,</p>';
var range = lineNumberingService.getLineNumberRange(html);
expect(range).toEqual({"from": 2, "to": 4});
});
});
describe('line numbering: test nodes', function () {
it('breaks very short lines', function () {
var textNode = document.createTextNode("0123");
@ -138,6 +178,12 @@ describe('linenumbering', function () {
expect(lineNumberingService.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
});
it('counts after DEL/INS-nodes', function () {
var inHtml = "<P>leo Testelefantgeweih Buchstabenwut als Achzehnzahlunginer. Hierbei <DEL>darf</DEL><INS>setzen</INS> bist der Deifi <DEL>das </DEL><INS>Dor Reh Wachtel da </INS>Subjunktivier <DEL>als Derftige Aal</DEL><INS>san</INS> Orthopädische<DEL>, der Arbeitsnachweisdiskus Bass der Tastatur </DEL><DEL>Weiter schreiben wie Tasse Wasser als</DEL><INS> dienen</INS>.</P>";
var outHtml = lineNumberingService.insertLineNumbers(inHtml, 95);
expect(outHtml).toBe('<p>' + noMarkup(1) + 'leo Testelefantgeweih Buchstabenwut als Achzehnzahlunginer. Hierbei <del>darf</del><ins>setzen</ins> bist der Deifi <del>das ' + brMarkup(2) + '</del><ins>Dor Reh Wachtel da </ins>Subjunktivier <del>als Derftige Aal</del><ins>san</ins> Orthopädische<del>, der Arbeitsnachweisdiskus Bass der Tastatur </del>' + brMarkup(3) + '<del>Weiter schreiben wie Tasse Wasser als</del><ins> dienen</ins>.</p>');
});
it('handles STRIKE-tags', function () {
var inHtml = '<p>et accusam et justo duo dolores et ea <span style="color: #ff0000;"><strike>rebum </strike></span><span style="color: #006400;">Inserted Text</span>. Stet clita kasd gubergren,</p>';
var outHtml = lineNumberingService.insertLineNumbers(inHtml, 80);
@ -408,6 +454,23 @@ describe('linenumbering', function () {
});
});
describe('document structure parsing', function () {
it('detects the line numbers of headings', function () {
var inHtml = '<p>Line 1</p>' +
'<h1>Heading 1</h1><p>Line 2</p><h2>Heading 1.1</h2><p>Line 3</p><h2>Heading 1.2</h2><p>Line 4</p>' +
'<h1>Heading 2</h1><h2>Heading 2.1</h2><p>Line 5</p>';
inHtml = lineNumberingService.insertLineNumbers(inHtml, 80);
var structure = lineNumberingService.getHeadingsWithLineNumbers(inHtml);
expect(structure).toEqual([
{lineNumber: 2, level: 1, text: 'Heading 1'},
{lineNumber: 4, level: 2, text: 'Heading 1.1'},
{lineNumber: 6, level: 2, text: 'Heading 1.2'},
{lineNumber: 8, level: 1, text: 'Heading 2'},
{lineNumber: 9, level: 2, text: 'Heading 2.1'}
]);
});
});
describe('caching', function() {
it('caches based on line length', function () {
var inHtml = '<p>' +longstr(100) + '</p>';