+
-
-
-
-
-
-
- Projector
-
-
-
+
+
+
+
-
-
+
-
-
+
+
+
+ Projector
+
+
+
+
-
-
-
- Projector
-
-
-
+
+
@@ -214,9 +216,9 @@
+
+
+
+ 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}">
@@ -228,30 +230,32 @@
-
-
-
-
-
-
- Projector
-
-
-
+ -Tags (including content)
+ * Removes the .insert-classes and the wrapping -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(/ it was not valid
+ return false;
+ }
+
+ // If there is any block element inside, we consider it as broken, as this string will be displayed
+ // inside of / tags
+ if (html.match(/<(div|p|ul|li|blockquote)\W/i)) {
+ return false;
+ }
+
+ return true;
+ };
+
+ /**
* @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 or , indicate broken HTML (if it's not a / )
- 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. "Test"), let's better be cautious
// The "!!(found=...)"-construction is only used to make jshint happy :)
var findDel = /(.*?)<\/del>/gi,
@@ -1069,7 +1243,7 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
}
while (!!(found = findIns.exec(html))) {
inner = found[1].replace(/
]*>/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('') === -1 || diffStr.indexOf('') === -1) {
+ return diffStr;
+ }
+
+ var findDelGroupFinder = /(?:.*?<\/del>)+/gi,
+ found,
+ returnStr = diffStr;
+
+ while (!!(found = findDelGroupFinder.exec(diffStr))) {
+ var del = found[0],
+ split = returnStr.split(del);
+
+ var findInsGroupFinder = /^(?:.*?<\/ins>)+/gi,
+ foundIns = findInsGroupFinder.exec(split[1]);
+ if (foundIns) {
+ var ins = foundIns[0];
+
+ var delShortened = del.replace(
+ /((
<\/del>)?(]+os-line-number[^>]+?>)(\s|<\/?del>)*<\/span>)<\/del>/gi,
+ ''
+ ).replace(/<\/del>/g, '');
+ var insConv = ins.replace(//g, '').replace(/<\/ins>/g, '').replace(/<\/del>/g, '');
+ if (delShortened.indexOf(insConv) !== -1) {
+ delShortened = delShortened.replace(insConv, '');
+ if (delShortened === '') {
+ returnStr = returnStr.replace(del + ins, del.replace(//g, '').replace(/<\/del>/g, ''));
+ }
+ }
+ }
+ }
+ return returnStr;
+ };
+
/**
* 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 tags that only delete line numbers
// We need to do this before removing 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);
diff --git a/openslides/motions/static/js/motions/linenumbering.js b/openslides/motions/static/js/motions/linenumbering.js
index 964198dd9..d978e2d01 100644
--- a/openslides/motions/static/js/motions/linenumbering.js
+++ b/openslides/motions/static/js/motions/linenumbering.js
@@ -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
+
').replace(/\s+<\/LI>/gi, '');
@@ -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
+
+
-
-
diff --git a/openslides/motions/config_variables.py b/openslides/motions/config_variables.py
index e933504d3..63f6e4ebd 100644
--- a/openslides/motions/config_variables.py
+++ b/openslides/motions/config_variables.py
@@ -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')
diff --git a/openslides/motions/migrations/0007_motionversion_amendment_data.py b/openslides/motions/migrations/0007_motionversion_amendment_data.py
new file mode 100644
index 000000000..d552d89f7
--- /dev/null
+++ b/openslides/motions/migrations/0007_motionversion_amendment_data.py
@@ -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),
+ ),
+ ]
diff --git a/openslides/motions/models.py b/openslides/motions/models.py
index afca64661..b8308bc9c 100644
--- a/openslides/motions/models.py
+++ b/openslides/motions/models.py
@@ -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."""
diff --git a/openslides/motions/projector.py b/openslides/motions/projector.py
index 68d9e9073..f8decaab8 100644
--- a/openslides/motions/projector.py
+++ b/openslides/motions/projector.py
@@ -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):
"""
diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py
index da929765b..ebb99fe85 100644
--- a/openslides/motions/serializers.py
+++ b/openslides/motions/serializers.py
@@ -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])
diff --git a/openslides/motions/static/css/motions/_amendments.scss b/openslides/motions/static/css/motions/_amendments.scss
new file mode 100644
index 000000000..0368198c2
--- /dev/null
+++ b/openslides/motions/static/css/motions/_amendments.scss
@@ -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;
+ }
+
+ }
+ }
+}
diff --git a/openslides/motions/static/css/motions/_diff.scss b/openslides/motions/static/css/motions/_diff.scss
index 8dfb40fc2..db0cb60f5 100644
--- a/openslides/motions/static/css/motions/_diff.scss
+++ b/openslides/motions/static/css/motions/_diff.scss
@@ -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;
+ }
}
diff --git a/openslides/motions/static/css/motions/_line-numbering.scss b/openslides/motions/static/css/motions/_line-numbering.scss
index 1dbc0a86c..5317ee7b9 100644
--- a/openslides/motions/static/css/motions/_line-numbering.scss
+++ b/openslides/motions/static/css/motions/_line-numbering.scss
@@ -14,8 +14,9 @@
padding-bottom: 10px;
}
- ul {
- margin-bottom: 0px;
+ ol, ul {
+ margin-left: 15px;
+ margin-bottom: 0;
}
.highlight {
diff --git a/openslides/motions/static/css/motions/_site.scss b/openslides/motions/static/css/motions/_site.scss
index 8acd730b3..6325e76fd 100644
--- a/openslides/motions/static/css/motions/_site.scss
+++ b/openslides/motions/static/css/motions/_site.scss
@@ -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;
diff --git a/openslides/motions/static/js/motions/base.js b/openslides/motions/static/js/motions/base.js
index 93de52069..f62526e82 100644
--- a/openslides/motions/static/js/motions/base.js
+++ b/openslides/motions/static/js/motions/base.js
@@ -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)) {
- html = lineNumberingService.insertLineNumbers(html, lineLength, null, null, 1);
- html = diffService.replaceLines(html, change.text, change.line_from, change.line_to);
+ 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');
+ 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 = '';
- 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);
+ 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.getTextRemainderAfterLastChangeRecommendation(versionId, changes);
+ 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',
diff --git a/openslides/motions/static/js/motions/csv.js b/openslides/motions/static/js/motions/csv.js
index 756552864..9bafe186d 100644
--- a/openslides/motions/static/js/motions/csv.js
+++ b/openslides/motions/static/js/motions/csv.js
@@ -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');
+ },
+ };
+ }
]);
}());
diff --git a/openslides/motions/static/js/motions/diff.js b/openslides/motions/static/js/motions/diff.js
index 9aefe8eb4..d6abfbf65 100644
--- a/openslides/motions/static/js/motions/diff.js
+++ b/openslides/motions/static/js/motions/diff.js
@@ -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 and
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
'Ä': 'Ä',
'Ö': 'Ö',
'Ü': 'Ü',
- 'ß': 'ß'
+ 'ß': 'ß',
+ '„': '„',
+ '“': '“',
+ '•': '•',
+ '§': '§',
+ 'é': 'é',
+ '€': '€'
};
html = html.replace(/\s+<\/P>/gi, '').replace(/\s+<\/DIV>/gi, '
+
-
-
+
+
+
+ Projector
+
+
+
+
]*>/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>
or
.
+ * - If a UL or OL is encountered, paragraphs are defined by the child-LI-elements.
+ * List items of nested lists are not considered as a paragraph of their own.
+ *
+ * @param {string} html
+ * @return {string[]}
+ */
+ 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
*
diff --git a/openslides/motions/static/js/motions/motion-services.js b/openslides/motions/static/js/motions/motion-services.js
index 4e5f9558c..953df9bc4 100644
--- a/openslides/motions/static/js/motions/motion-services.js
+++ b/openslides/motions/static/js/motions/motion-services.js
@@ -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();
diff --git a/openslides/motions/static/js/motions/pdf.js b/openslides/motions/static/js/motions/pdf.js
index 4364272e7..5ac12c5c2 100644
--- a/openslides/motions/static/js/motions/pdf.js
+++ b/openslides/motions/static/js/motions/pdf.js
@@ -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]
- };
- }
+ return {
+ text: Config.translate(Config.get('motions_preamble').value),
+ margin: [0, 10, 0, 0]
+ };
};
var escapeHtml = function(text) {
- return text.replace(/&/, "&").replace(/, "<").replace(/>/, ">");
+ return text.replace(/&/, '&').replace(/, '<').replace(/>/, '>');
};
// motion text (with line-numbers)
var motionText = function() {
+ var content = [];
if (params.include.text) {
var motionTextContent = '';
- var titleChange = motion.getTitleChangeRecommendation();
- if (params.changeRecommendationMode === 'diff' && titleChange) {
- motionTextContent += '
-
-
+
-
' + gettextCatalog.getString('New title') + ': ' + - escapeHtml(titleChange.text) + '
'; + 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 += '' + gettextCatalog.getString('New title') + ': ' + + escapeHtml(titleChange.text) + '
'; + } + motionTextContent += motion.getTextByMode(params.changeRecommendationMode, motionVersion); } - 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); + }); + }); + }, }; } ]); diff --git a/openslides/motions/static/js/motions/projector.js b/openslides/motions/static/js/motions/projector.js index d340c78c5..4b2854f30 100644 --- a/openslides/motions/static/js/motions/projector.js +++ b/openslides/motions/static/js/motions/projector.js @@ -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); } ]); diff --git a/openslides/motions/static/js/motions/site.js b/openslides/motions/static/js/motions/site.js index 14dc9786c..3a94dbdbb 100644 --- a/openslides/motions/static/js/motions/site.js +++ b/openslides/motions/static/js/motions/site.js @@ -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: '' + Config.translate(Config.get('motions_preamble').value) + '
' @@ -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,12 +1719,19 @@ 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(); } - dialog.scope = $scope; - ngDialog.open(dialog); }; // follow recommendation $scope.followRecommendation = function () { @@ -1654,14 +1739,6 @@ angular.module('OpenSlidesApp.motions.site', [ '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; - } + $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'); diff --git a/openslides/motions/static/templates/motions/amendment-paragraph-choose-form.html b/openslides/motions/static/templates/motions/amendment-paragraph-choose-form.html new file mode 100644 index 000000000..5e273a825 --- /dev/null +++ b/openslides/motions/static/templates/motions/amendment-paragraph-choose-form.html @@ -0,0 +1,27 @@ +Choose the paragraph to amend
+ +
+ {{ alert.msg }}
+
+
+
diff --git a/openslides/motions/static/templates/motions/motion-amendment-list.html b/openslides/motions/static/templates/motions/motion-amendment-list.html
new file mode 100644
index 000000000..b9482f382
--- /dev/null
+++ b/openslides/motions/static/templates/motions/motion-amendment-list.html
@@ -0,0 +1,408 @@
+
+
diff --git a/openslides/motions/static/templates/motions/motion-comment-form.html b/openslides/motions/static/templates/motions/motion-comment-form.html
new file mode 100644
index 000000000..8f65843db
--- /dev/null
+++ b/openslides/motions/static/templates/motions/motion-comment-form.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+Amendments
+
+
+ + + + {{ leadMotion.identifier }} — + + {{ leadMotion.getTitle() }} + + + All motions + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
-
+
+
+
Export dialog + +
+ - + + + PDF + + + +
-
+
+
+
Amendment list PDF + +
+
+ - + + + CSV + + +
+
+
+
+
+
+
+
+
+
+ Set status
+
+
+
+
+
+ Set category
+
+
+
+
+
+ Set motion block
+
+
+
+
+ Delete selected amendments
+
+
+
+
+
+
+ {{ amendmentsFiltered.length }} /
+ {{ amendments.length }}
+ amendments ,
+ {{(amendments|filter:{selected:true}).length}} {{ "selected" | translate }}
+
+
+
+ Page {{ pagination.currentPage }} /
+ {{ Math.ceil(amendmentsFiltered.length/pagination.itemsPerPage) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ amendment.identifier }}
+ {{ amendment.getTitle() }}
+
+
+
+ + (Line {{ paragraph.diffLineFrom }})
+
+
+ (Line {{ paragraph.diffLineFrom }}-{{ paragraph.diffLineTo }})
+
+
+
+
+
+
+
+
+
+
+
+ + + (
+
+ {{ amendment.getStateName() }}
+
+
+
+
+ {{ amendment.getStateName() }}
+
+
+
+
+ -
+
- + {{ state.action_word | translate }} + + +
-
+
+
+
Reset state + +
+
+
+
+ {{ amendment.getRecommendationName() }}
+
+
+ No recomendation set
+
+
+
+
+
+ {{ amendment.getRecommendationName() }}
+
+
+ No recomendation set
+
+
+
+
+
+
+
+
+ by
+
+ {{ submitter.get_full_name() }},,
+ ... [+{{ amendment.submitters.length - 1 }}]
+
+
+
+
+
+
+
+
+
+
+
+ No changes at the text.
+
+
+
+
+
+ {{ getTextPreview(amendment.getText(), 400) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ field.name }}
+
+
+ {{ getTextPreview(amendment.comments[id], 30) }}
+
+
+
+
+
+
+
+
+
+
+ {{ amendment.supporters.length }}
+
+
+ -
+
{{ title }}
+ +
+ {{ alert.msg }}
+
+
+
diff --git a/openslides/motions/static/templates/motions/motion-detail.html b/openslides/motions/static/templates/motions/motion-detail.html
index fab9b04e2..b6b01236c 100644
--- a/openslides/motions/static/templates/motions/motion-detail.html
+++ b/openslides/motions/static/templates/motions/motion-detail.html
@@ -1,10 +1,21 @@
-
+
Back to overview
+
+
+ Back to overview
+
+
+
+ Amendments
+
@@ -23,20 +34,20 @@
- -
-
- + ng-if="projectors.length > 1 || has_proposed_changes"> +
-
{{ mode.label | translate }}
Final version
-
+
- @@ -61,28 +72,29 @@
-
-
+
+
+
+
+
- {{ motion.getTitleWithChanges(viewChangeRecommendations.mode) }}
+
+ {{ motion.getTitleWithChanges(viewChangeRecommendations.mode) }}
+
+
+ {{ motion.getTitleWithChanges(viewChangeRecommendations.mode) }}
+
-
-
+
+
-
+
-
-
Motion {{ motion.identifier }}
-
- (Amendment of motion
- {{ parent.identifier || parent.getTitle() }})
-
| Version {{ motion.getVersion(version).version_number }}
@@ -154,15 +166,13 @@
Unsupport motion
+
+
Amendments
+
-
Amendments
- + + {{ motion.getAmendments().length }}+
+
{{ config('motions_recommendations_by') }}
@@ -222,13 +232,13 @@- - + {{ recommendation.recommendation_label | translate }} ...
-
-
+
Reset recommendation @@ -498,9 +508,10 @@-+ - ++ + +@@ -532,6 +543,15 @@ ++ ++ @@ -546,6 +566,10 @@+ 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. + ++ diff --git a/openslides/motions/static/templates/motions/motion-detail/amendment-paragraph-diff.html b/openslides/motions/static/templates/motions/motion-detail/amendment-paragraph-diff.html new file mode 100644 index 000000000..d4352239f --- /dev/null +++ b/openslides/motions/static/templates/motions/motion-detail/amendment-paragraph-diff.html @@ -0,0 +1,43 @@ +++ ++++ +++ +diff --git a/openslides/motions/static/templates/motions/motion-detail/change-summary.html b/openslides/motions/static/templates/motions/motion-detail/change-summary.html index e0ba70cc5..59491881d 100644 --- a/openslides/motions/static/templates/motions/motion-detail/change-summary.html +++ b/openslides/motions/static/templates/motions/motion-detail/change-summary.html @@ -1,17 +1,17 @@+ +++No changes at the text ++ +++ ++ ++
+Line {{ paragraph.diffLineFrom }}: ++
+ + + + + +Line {{ paragraph.diffLineFrom }} - {{ paragraph.diffLineTo - 1 }}: ++ ++- Summary of change recommendations : +Summary of changes : --
+
-
Title : @@ -21,30 +21,35 @@
- -
-
+
- + -
Line {{ change.line_from }}: +Line {{ change.line_from }} -Line {{ change.line_from }} - {{ change.line_to - 1 }}: +Line {{ change.line_from }} - + {{ change.line_to - 1 }} - -Replacement -Insertion -Deletion - + (Change recommendation ) + ({{ change.original.identifier }}) + – +Replacement +Insertion +Deletion + {{ change.other_description }}Rejected +Accepted - + -
-
+
+
+
none + +
+ -
+
+
+
inline + +
+ -
+
+
+
outside + +
+
+No change recommendations yet + + ++ +Line numbering : + + + + ++ +diff --git a/openslides/motions/static/templates/motions/motion-detail/toolbar.html b/openslides/motions/static/templates/motions/motion-detail/toolbar.html index df364b238..7520ba7cc 100644 --- a/openslides/motions/static/templates/motions/motion-detail/toolbar.html +++ b/openslides/motions/static/templates/motions/motion-detail/toolbar.html @@ -2,17 +2,17 @@-
+
- - - -