diff --git a/openslides/core/static/css/app.css b/openslides/core/static/css/app.css
index 02ce19333..31a669796 100644
--- a/openslides/core/static/css/app.css
+++ b/openslides/core/static/css/app.css
@@ -302,22 +302,25 @@ img {
border: 1px solid #d3d3d3;
}
-.col1 .details .line-number-setter {
+.col1 .details .motion-toolbar .toolbar-left {
margin-top: 0;
margin-bottom: 55px;
margin-left: 15px;
}
-.col1 .details .line-number-setter > span {
+.col1 .details .motion-toolbar .toolbar-left > * {
margin-right: 5px;
float: left;
}
-.col1 .details .line-number-setter .btn.disabled {
+.col1 .details .motion-toolbar .toolbar-left .btn.disabled {
cursor: default;
opacity: 1;
background-color: #eee;
}
+.col1 .details .motion-toolbar .toolbar-left .goto-line-number {
+ max-width: 220px;
+}
.col1 .details .inline-editing-activator {
margin-right: 13px;
@@ -395,23 +398,29 @@ img {
background-color: #ff0;
}
+.motion-text li {
+ margin-left: 30px;
+}
.motion-text.line-numbers-outside {
- padding-left: 35px;
+ padding-left: 40px;
position: relative;
}
.motion-text.line-numbers-outside .os-line-number {
display: inline-block;
font-size: 0;
line-height: 0;
- width: 0;
- height: 0;
+ width: 22px;
+ height: 22px;
+ position: absolute;
+ left: -20px;
+ padding-right: 55px;
}
.motion-text.line-numbers-outside .os-line-number:after {
content: attr(data-line-number);
position: absolute;
- left: 0;
+ left: 20px;
+ top: 12px;
vertical-align: top;
- margin-top: -5px;
color: gray;
font-family: Courier, serif;
font-size: 13px;
@@ -445,6 +454,7 @@ img {
}
.os-line-number {
+ position: relative;
user-select: none;
-moz-user-select: none;
-khtml-user-select: none;
@@ -452,6 +462,7 @@ img {
-o-user-select: none;
}
.os-line-number:after {
+ position: relative;
user-select: none;
-moz-user-select: none;
-khtml-user-select: none;
@@ -459,6 +470,174 @@ img {
-o-user-select: none;
}
+.line-numbers-outside .os-line-number.selectable:hover:before, .line-numbers-outside .os-line-number.selected:before {
+ cursor: pointer;
+ content: "\f067";
+ display: inline-block;
+ position: absolute;
+ width: 14px;
+ height: 14px;
+ border-radius: 0.25em;
+ top: 4px;
+ left: 43px;
+ cursor: pointer;
+ font-family: FontAwesome;
+ font-size: 12px;
+ color: white;
+ line-height: 16px;
+ text-align: center;
+ background-color: #337ab7;
+}
+
+
+/** Styles for annotating the original motion text with change recommendations */
+
+.motion-text-holder {
+ position: relative;
+}
+
+.motion-text-holder .change-recommendation-list {
+ position: absolute;
+ top: 0;
+ left: -10px;
+ width: 4px;
+ list-style-type: none;
+ margin: 0;
+}
+
+.motion-text-holder .change-recommendation-list > li {
+ position: absolute;
+ width: 4px;
+ cursor: pointer;
+}
+.motion-text-holder .change-recommendation-list > li.insert {
+ background-color: #00aa00;
+}
+.motion-text-holder .change-recommendation-list > li.delete {
+ background-color: #aa0000;
+}
+.motion-text-holder .change-recommendation-list > li.replace {
+ background-color: #0333ff;
+}
+
+.motion-text-holder .change-recommendation-list .tooltip {
+ display: none;
+}
+
+/** Diff view */
+
+.change-recommendation-overview {
+ background-color: #eee;
+ border: solid 1px #ddd;
+ border-radius: 3px;
+ margin-bottom: 5px;
+ margin-top: -15px;
+ padding-top: 5px;
+}
+.change-recommendation-overview {
+ margin-bottom: 50px;
+ padding: 10px;
+}
+.change-recommendation-overview h2 {
+ margin-top: 0;
+}
+.change-recommendation-overview ul {
+ list-style: none;
+ display: table;
+}
+.change-recommendation-overview li {
+ display: table-row;
+ cursor: pointer;
+}
+.change-recommendation-overview li:hover {
+ text-decoration: underline;
+}
+.change-recommendation-overview li > * {
+ display: table-cell;
+ padding: 4px;
+}
+.change-recommendation-overview .status {
+ color: gray;
+ font-style: italic;
+}
+.change-recommendation-overview .status > *:before {
+ content: '(';
+}
+.change-recommendation-overview .status > *:after {
+ content: ')';
+}
+.change-recommendation-overview .no-changes {
+ font-style: italic;
+ color: grey;
+}
+
+
+.diff-box {
+ background-color: #f9f9f9;
+ border: solid 1px #eee;
+ border-radius: 3px;
+ margin-bottom: 0;
+ margin-top: -25px;
+ padding-top: 0;
+ padding-right: 155px;
+}
+.motion-text-with-diffs .original-text {
+ min-height: 30px; // Spacer between .diff-box, in case .original-text is empty
+}
+.motion-text-with-diffs .original-text ul:last-child {
+ padding-bottom: 16px;
+}
+.motion-text-with-diffs.line-numbers-inline .diff-box, .motion-text-with-diffs.line-numbers-none .diff-box {
+ margin-right: -220px;
+}
+.diff-box:hover {
+ background-color: #f0f0f0;
+}
+.diff-box .action-row {
+ font-size: 0.8em;
+ padding-top: 5px;
+ padding-bottom: 5px;
+ float: right;
+ width: 150px;
+ text-align: right;
+ margin-right: -150px;
+ opacity: 0.5;
+}
+.diff-box:hover .action-row {
+ opacity: 1;
+}
+.diff-box .action-row .btn-delete {
+ margin-left: 10px;
+ color: red;
+}
+.diff-box .status-row {
+ font-style: italic;
+ color: gray;
+}
+.diff-box .status-row > *:after {
+ content: ':';
+}
+
+.motion-text-diff .delete {
+ color: red;
+ text-decoration: line-through;
+}
+.motion-text-diff .insert {
+ color: green;
+ text-decoration: underline;
+}
+.motion-text-diff p {
+ padding-bottom: 0;
+ margin-top: 0;
+ margin-bottom: 0;
+}
+.motion-text-diff.line-numbers-outside .insert .os-line-number {
+ display: none;
+}
+.motion-text-diff.line-numbers-inline .insert .os-line-number {
+ display: none;
+}
+
/** Projector sidebar column **/
#content .col2 {
@@ -704,13 +883,16 @@ img {
border-top: 1px solid #ddd;
background-color: #f5f5f5;
}
-.linenumber-toolbar, .speakers-toolbar {
+.motion-toolbar, .speakers-toolbar {
background-color: #f5f5f5;
border-bottom: 1px solid #ddd;
padding: 12px 0 10px 0;
height: 54px;
margin: -20px -5px 50px -5px;
}
+.motion-toolbar:first-child {
+ margin-bottom: 20px;
+}
.speakers-toolbar {
margin: -20px -20px 30px -20px;
diff --git a/openslides/motions/access_permissions.py b/openslides/motions/access_permissions.py
index 270b757d4..2ac7b4a2a 100644
--- a/openslides/motions/access_permissions.py
+++ b/openslides/motions/access_permissions.py
@@ -55,6 +55,25 @@ class MotionAccessPermissions(BaseAccessPermissions):
return data
+class MotionChangeRecommendationAccessPermissions(BaseAccessPermissions):
+ """
+ Access permissions container for MotionChangeRecommendation and MotionChangeRecommendationViewSet.
+ """
+ def check_permissions(self, user):
+ """
+ Returns True if the user has read access model instances.
+ """
+ return user.has_perm('motions.can_see')
+
+ def get_serializer_class(self, user=None):
+ """
+ Returns serializer class.
+ """
+ from .serializers import MotionChangeRecommendationSerializer
+
+ return MotionChangeRecommendationSerializer
+
+
class CategoryAccessPermissions(BaseAccessPermissions):
"""
Access permissions container for Category and CategoryViewSet.
diff --git a/openslides/motions/apps.py b/openslides/motions/apps.py
index 5e3a9b39d..413e12024 100644
--- a/openslides/motions/apps.py
+++ b/openslides/motions/apps.py
@@ -18,7 +18,7 @@ class MotionsAppConfig(AppConfig):
from openslides.utils.rest_api import router
from .config_variables import get_config_variables
from .signals import create_builtin_workflows
- from .views import CategoryViewSet, MotionViewSet, MotionPollViewSet, WorkflowViewSet
+ from .views import CategoryViewSet, MotionViewSet, MotionPollViewSet, MotionChangeRecommendationViewSet, WorkflowViewSet
# Define config variables
config.update_config_variables(get_config_variables())
@@ -30,4 +30,6 @@ class MotionsAppConfig(AppConfig):
router.register(self.get_model('Category').get_collection_string(), CategoryViewSet)
router.register(self.get_model('Motion').get_collection_string(), MotionViewSet)
router.register(self.get_model('Workflow').get_collection_string(), WorkflowViewSet)
+ router.register(self.get_model('MotionChangeRecommendation').get_collection_string(),
+ MotionChangeRecommendationViewSet)
router.register('motions/motionpoll', MotionPollViewSet)
diff --git a/openslides/motions/migrations/0005_motionchangerecommendation.py b/openslides/motions/migrations/0005_motionchangerecommendation.py
new file mode 100644
index 000000000..8ed9758c1
--- /dev/null
+++ b/openslides/motions/migrations/0005_motionchangerecommendation.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.1 on 2016-10-12 18:10
+from __future__ import unicode_literals
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+import openslides.utils.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('motions', '0004_auto_20160907_2343'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='MotionChangeRecommendation',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('status', models.PositiveIntegerField(default=0)),
+ ('line_from', models.PositiveIntegerField()),
+ ('line_to', models.PositiveIntegerField()),
+ ('text', models.TextField(blank=True)),
+ ('creation_time', models.DateTimeField(auto_now=True)),
+ ('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
+ ('motion_version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
+ related_name='change_recommendations', to='motions.MotionVersion')),
+ ],
+ options={
+ 'default_permissions': (),
+ },
+ bases=(openslides.utils.models.RESTModelMixin, models.Model),
+ ),
+ ]
diff --git a/openslides/motions/models.py b/openslides/motions/models.py
index dae32ca75..f415fc01b 100644
--- a/openslides/motions/models.py
+++ b/openslides/motions/models.py
@@ -23,6 +23,7 @@ from openslides.utils.search import user_name_helper
from .access_permissions import (
CategoryAccessPermissions,
MotionAccessPermissions,
+ MotionChangeRecommendationAccessPermissions,
WorkflowAccessPermissions,
)
from .exceptions import WorkflowError
@@ -697,6 +698,68 @@ class MotionVersion(RESTModelMixin, models.Model):
return self.motion
+class MotionChangeRecommendationManager(models.Manager):
+ """
+ Customized model manager to support our get_full_queryset method.
+ """
+ def get_full_queryset(self):
+ """
+ Returns the normal queryset with all change recommendations. In the background we
+ join and prefetch all related models.
+ """
+ return self.get_queryset()
+
+
+class MotionChangeRecommendation(RESTModelMixin, models.Model):
+ """
+ A MotionChangeRecommendation object saves change recommendations for a specific MotionVersion
+ """
+
+ access_permissions = MotionChangeRecommendationAccessPermissions()
+
+ objects = MotionChangeRecommendationManager()
+
+ motion_version = models.ForeignKey(
+ MotionVersion,
+ on_delete=models.CASCADE,
+ related_name='change_recommendations')
+ """The motion version to which the change recommendation belongs."""
+
+ status = models.PositiveIntegerField(default=0)
+ """Proposed (0), Accepted (1), Rejected (2)"""
+
+ line_from = models.PositiveIntegerField()
+ """The number or the first affected line"""
+
+ line_to = models.PositiveIntegerField()
+ """The number or the last affected line (inclusive)"""
+
+ text = models.TextField(blank=True)
+ """The replacement for the section of the original text specified by version, line_from and line_to"""
+
+ author = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.SET_NULL,
+ null=True)
+ """A user object, who created this change recommendation. Optional."""
+
+ creation_time = models.DateTimeField(auto_now=True)
+ """Time when the change recommendation was saved."""
+
+ class Meta:
+ default_permissions = ()
+
+ def __str__(self):
+ """Return a string, representing this object."""
+ return "Recommendation for Version %s, line %s - %s" % (self.motion_version_id, self.line_from, self.line_to)
+
+ def get_root_rest_element(self):
+ """
+ Returns this instance, which is the root REST element.
+ """
+ return self
+
+
class Category(RESTModelMixin, models.Model):
"""
Model for categories of motions.
diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py
index e3f458d99..c094e3b3c 100644
--- a/openslides/motions/serializers.py
+++ b/openslides/motions/serializers.py
@@ -16,6 +16,7 @@ from openslides.utils.rest_api import (
from .models import (
Category,
Motion,
+ MotionChangeRecommendation,
MotionLog,
MotionPoll,
MotionVersion,
@@ -228,6 +229,22 @@ class MotionVersionSerializer(ModelSerializer):
'reason',)
+class MotionChangeRecommendationSerializer(ModelSerializer):
+ """
+ Serializer for motion.models.MotionChangeRecommendation objects.
+ """
+ class Meta:
+ model = MotionChangeRecommendation
+ fields = (
+ 'id',
+ 'motion_version',
+ 'status',
+ 'line_from',
+ 'line_to',
+ 'text',
+ 'creation_time',)
+
+
class MotionSerializer(ModelSerializer):
"""
Serializer for motion.models.Motion objects.
diff --git a/openslides/motions/static/js/motions/base.js b/openslides/motions/static/js/motions/base.js
index e2086c58b..55a3df574 100644
--- a/openslides/motions/static/js/motions/base.js
+++ b/openslides/motions/static/js/motions/base.js
@@ -142,13 +142,16 @@ angular.module('OpenSlidesApp.motions', [
.factory('Motion', [
'DS',
'MotionPoll',
+ 'MotionChangeRecommendation',
'MotionComment',
'jsDataModel',
'gettext',
'operator',
'Config',
'lineNumberingService',
- function(DS, MotionPoll, MotionComment, jsDataModel, gettext, operator, Config, lineNumberingService) {
+ 'diffService',
+ function(DS, MotionPoll, MotionChangeRecommendation, MotionComment, jsDataModel, gettext, operator, Config,
+ lineNumberingService, diffService) {
var name = 'motions/motion';
return DS.defineResource({
name: name,
@@ -187,7 +190,98 @@ angular.module('OpenSlidesApp.motions', [
return lineNumberingService.insertLineNumbers(html, lineLength, highlight, callback);
},
- setTextStrippingLineBreaks: function (versionId, text) {
+ getTextBetweenChangeRecommendations: function (versionId, change1, change2) {
+ 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';
+ }
+ if (line_from == line_to) {
+ return '';
+ }
+
+ var lineLength = Config.get('motions_line_length').value,
+ html = lineNumberingService.insertLineNumbers(this.getVersion(versionId).text, lineLength);
+
+ var data = diffService.extractRangeByLineNumbers(html, line_from, line_to);
+
+ html = data.outerContextStart + data.innerContextStart + data.html +
+ data.innerContextEnd + data.outerContextEnd;
+ html = lineNumberingService.insertLineNumbers(html, lineLength, null, null, line_from);
+
+ return html;
+ },
+ getTextRemainderAfterLastChangeRecommendation: function(versionId, changes) {
+ var maxLine = 0;
+ for (var i = 0; i < changes.length; i++) {
+ if (changes[i].line_to > maxLine) {
+ maxLine = changes[i].line_to;
+ }
+ }
+
+ var lineLength = Config.get('motions_line_length').value,
+ html = lineNumberingService.insertLineNumbers(this.getVersion(versionId).text, lineLength);
+
+ var data = diffService.extractRangeByLineNumbers(html, maxLine, null);
+ html = data.outerContextStart + data.innerContextStart +
+ data.html +
+ data.innerContextEnd + data.outerContextEnd;
+ html = lineNumberingService.insertLineNumbers(html, lineLength, null, null, maxLine);
+
+ return html;
+ },
+ getTextWithChangeRecommendations: function (versionId, statusCompareCb) {
+ var lineLength = Config.get('motions_line_length').value,
+ html = this.getVersion(versionId).text,
+ changes = this.getChangeRecommendations(versionId, 'DESC'),
+ fragment;
+
+ for (var i = 0; i < changes.length; i++) {
+ var change = changes[i];
+ if (statusCompareCb === undefined || statusCompareCb(change.status)) {
+ html = lineNumberingService.insertLineNumbers(html, lineLength);
+ fragment = diffService.htmlToFragment(html);
+ html = diffService.replaceLines(fragment, change.text, change.line_from, change.line_to);
+ }
+ }
+
+ return lineNumberingService.insertLineNumbers(html, lineLength);
+ },
+ getTextWithAcceptedChangeRecommendations: function (versionId) {
+ return this.getTextWithChangeRecommendations(versionId, function(status) {
+ return (status == 1);
+ });
+ },
+ getTextByMode: function(mode, versionId) {
+ /*
+ * @param mode ['original', 'diff', 'changed', 'agreed']
+ * @param versionId [if undefined, active_version will be used]
+ */
+ var text;
+ switch (mode) {
+ case 'original':
+ text = this.getTextWithLineBreaks(versionId);
+ break;
+ case 'diff':
+ var changes = this.getChangeRecommendations(versionId, 'ASC');
+ text = '';
+ for (var i = 0; i < changes.length; i++) {
+ text += this.getTextBetweenChangeRecommendations(versionId, (i === 0 ? null : changes[i - 1]), changes[i]);
+ text += changes[i].format(this, versionId);
+ }
+ text += this.getTextRemainderAfterLastChangeRecommendation(versionId, changes);
+ break;
+ case 'changed':
+ text = this.getTextWithChangeRecommendations(versionId);
+ break;
+ case 'agreed':
+ text = this.getTextWithAcceptedChangeRecommendations(versionId);
+ break;
+ }
+ return text;
+ },
+ setTextStrippingLineBreaks: function (text) {
this.text = lineNumberingService.stripLineNumbers(text);
},
getReason: function (versionId) {
@@ -201,6 +295,24 @@ angular.module('OpenSlidesApp.motions', [
getSearchResultSubtitle: function () {
return "Motion";
},
+ getChangeRecommendations: function (versionId, order) {
+ /*
+ * Returns all change recommendations for this given version, sorted by line
+ * @param versionId
+ * @param order ['DESC' or 'ASC' (default)]
+ * @returns {*}
+ */
+ versionId = versionId || this.active_version;
+ order = order || 'ASC';
+ return MotionChangeRecommendation.filter({
+ where: {
+ motion_version_id: versionId
+ },
+ orderBy: [
+ ['line_from', order]
+ ]
+ });
+ },
isAllowed: function (action) {
/*
* Return true if the requested user is allowed to do the specific action.
@@ -392,11 +504,95 @@ angular.module('OpenSlidesApp.motions', [
}
])
+.factory('MotionChangeRecommendation', [
+ 'DS',
+ 'Config',
+ 'jsDataModel',
+ 'diffService',
+ 'lineNumberingService',
+ 'gettextCatalog',
+ function (DS, Config, jsDataModel, diffService, lineNumberingService, gettextCatalog) {
+ return DS.defineResource({
+ name: 'motions/motionchangerecommendation',
+ useClass: jsDataModel,
+ methods: {
+ saveStatus: function() {
+ this.DSSave();
+ },
+ format: function(motion, version) {
+ var lineLength = Config.get('motions_line_length').value,
+ html = lineNumberingService.insertLineNumbers(motion.getVersion(version).text, lineLength);
+
+ var data = diffService.extractRangeByLineNumbers(html, this.line_from, this.line_to),
+ oldText = data.outerContextStart + data.innerContextStart +
+ data.html + data.innerContextEnd + data.outerContextEnd,
+ oldTextWithBreaks = lineNumberingService.insertLineNumbersNode(oldText, lineLength, null, this.line_from),
+ newTextWithBreaks = lineNumberingService.insertLineNumbersNode(this.text, lineLength, null, this.line_from);
+
+ for (var i = 0; i < oldTextWithBreaks.childNodes.length; i++) {
+ diffService.addCSSClass(oldTextWithBreaks.childNodes[i], 'delete');
+ }
+ for (i = 0; i < newTextWithBreaks.childNodes.length; i++) {
+ diffService.addCSSClass(newTextWithBreaks.childNodes[i], 'insert');
+ }
+
+ var mergedFragment = document.createDocumentFragment(),
+ el;
+ while (oldTextWithBreaks.firstChild) {
+ el = oldTextWithBreaks.firstChild;
+ oldTextWithBreaks.removeChild(el);
+ mergedFragment.appendChild(el);
+ }
+ while (newTextWithBreaks.firstChild) {
+ el = newTextWithBreaks.firstChild;
+ newTextWithBreaks.removeChild(el);
+ mergedFragment.appendChild(el);
+ }
+
+ return diffService._serializeDom(mergedFragment);
+ },
+ getType: function(original_full_html) {
+ var lineLength = Config.get('motions_line_length').value,
+ html = lineNumberingService.insertLineNumbers(original_full_html, lineLength);
+
+ var data = diffService.extractRangeByLineNumbers(html, this.line_from, this.line_to),
+ oldText = data.outerContextStart + data.innerContextStart +
+ data.html + data.innerContextEnd + data.outerContextEnd;
+
+ return diffService.detectReplacementType(oldText, this.text);
+ },
+ getTitle: function(original_full_html) {
+ var title;
+ if (this.line_to > (this.line_from + 1)) {
+ title = gettextCatalog.getString('%TYPE% from line %FROM% to %TO%');
+ } else {
+ title = gettextCatalog.getString('%TYPE% in line %FROM%');
+ }
+ switch (this.getType(original_full_html)) {
+ case diffService.TYPE_INSERTION:
+ title = title.replace('%TYPE%', gettextCatalog.getString('Insertion'));
+ break;
+ case diffService.TYPE_DELETION:
+ title = title.replace('%TYPE%', gettextCatalog.getString('Deletion'));
+ break;
+ case diffService.TYPE_REPLACEMENT:
+ title = title.replace('%TYPE%', gettextCatalog.getString('Replacement'));
+ break;
+ }
+ title = title.replace('%FROM%', this.line_from).replace('%TO%', (this.line_to - 1));
+ return title;
+ }
+ }
+ });
+ }
+])
+
.run([
'Motion',
'Category',
'Workflow',
- function(Motion, Category, Workflow) {}
+ 'MotionChangeRecommendation',
+ function(Motion, Category, Workflow, MotionChangeRecommendation) {}
])
diff --git a/openslides/motions/static/js/motions/diff.js b/openslides/motions/static/js/motions/diff.js
index 78cd07a9d..55ce25b52 100644
--- a/openslides/motions/static/js/motions/diff.js
+++ b/openslides/motions/static/js/motions/diff.js
@@ -9,6 +9,9 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
TEXT_NODE = 3,
DOCUMENT_FRAGMENT_NODE = 11;
+ this.TYPE_REPLACEMENT = 0;
+ this.TYPE_INSERTION = 1;
+ this.TYPE_DELETION = 2;
this.getLineNumberNode = function(fragment, lineNumber) {
return fragment.querySelector('os-linebreak.os-line-number.line-number-' + lineNumber);
@@ -24,31 +27,100 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
return context;
};
+ // Adds elements like
this._insertInternalLineMarkers = function(fragment) {
if (fragment.querySelectorAll('OS-LINEBREAK').length > 0) {
// Prevent duplicate calls
return;
}
- var lineNumbers = fragment.querySelectorAll('span.os-line-number');
+ var lineNumbers = fragment.querySelectorAll('span.os-line-number'),
+ lineMarker, maxLineNumber;
+
for (var i = 0; i < lineNumbers.length; i++) {
var insertBefore = lineNumbers[i];
while (insertBefore.parentNode.nodeType != DOCUMENT_FRAGMENT_NODE && insertBefore.parentNode.childNodes[0] == insertBefore) {
insertBefore = insertBefore.parentNode;
}
- var lineMarker = document.createElement('OS-LINEBREAK');
+ lineMarker = document.createElement('OS-LINEBREAK');
lineMarker.setAttribute('data-line-number', lineNumbers[i].getAttribute('data-line-number'));
lineMarker.setAttribute('class', lineNumbers[i].getAttribute('class'));
insertBefore.parentNode.insertBefore(lineMarker, insertBefore);
+ maxLineNumber = lineNumbers[i].getAttribute('data-line-number');
+ }
+
+ // Add one more "fake" line number at the end and beginning, so we can select the last line as well
+ lineMarker = document.createElement('OS-LINEBREAK');
+ lineMarker.setAttribute('data-line-number', (parseInt(maxLineNumber) + 1));
+ lineMarker.setAttribute('class', 'os-line-number line-number-' + (parseInt(maxLineNumber) + 1));
+ fragment.appendChild(lineMarker);
+
+ lineMarker = document.createElement('OS-LINEBREAK');
+ lineMarker.setAttribute('data-line-number', '0');
+ lineMarker.setAttribute('class', 'os-line-number line-number-0');
+ fragment.insertBefore(lineMarker, fragment.firstChild);
+ };
+
+ // @TODO Check if this is actually necessary
+ this._insertInternalLiNumbers = function(fragment) {
+ if (fragment.querySelectorAll('LI[os-li-number]').length > 0) {
+ // Prevent duplicate calls
+ return;
+ }
+ var ols = fragment.querySelectorAll('OL');
+ for (var i = 0; i < ols.length; i++) {
+ var ol = ols[i],
+ liNo = 0;
+ for (var j = 0; j < ol.childNodes.length; j++) {
+ if (ol.childNodes[j].nodeName == 'LI') {
+ liNo++;
+ ol.childNodes[j].setAttribute('os-li-number', liNo);
+ }
+ }
}
};
- /*
- * Returns an array with the following values:
- * 0: the most specific DOM-node that contains both line numbers
- * 1: the context of node1 (an array of dom-elements; 0 is the document fragment)
- * 2: the context of node2 (an array of dom-elements; 0 is the document fragment)
- * 3: the index of [0] in the two arrays
- */
+ this._addStartToOlIfNecessary = function(node) {
+ var firstLiNo = null;
+ for (var i = 0; i < node.childNodes.length && firstLiNo === null; i++) {
+ if (node.childNode[i].nodeName == 'LI') {
+ var lineNo = node.childNode[i].getAttribute('ol-li-number');
+ if (lineNo) {
+ firstLiNo = parseInt(lineNo);
+ }
+ }
+ }
+ if (firstLiNo > 1) {
+ node.setAttribute('start', firstLiNo);
+ }
+ };
+
+ this._isWithinNthLIOfOL = function(olNode, descendantNode) {
+ var nthLIOfOL = null;
+ while (descendantNode.parentNode) {
+ if (descendantNode.parentNode == olNode) {
+ var lisBeforeOl = 0,
+ foundMe = false;
+ for (var i = 0; i < olNode.childNodes.length && !foundMe; i++) {
+ if (olNode.childNodes[i] == descendantNode) {
+ foundMe = true;
+ } else if (olNode.childNodes[i].nodeName == 'LI') {
+ lisBeforeOl++;
+ }
+ }
+ nthLIOfOL = lisBeforeOl + 1;
+ }
+ descendantNode = descendantNode.parentNode;
+ }
+ return nthLIOfOL;
+ };
+
+ /*
+ * Returns an array with the following values:
+ * 0: the most specific DOM-node that contains both line numbers
+ * 1: the context of node1 (an array of dom-elements; 0 is the document fragment)
+ * 2: the context of node2 (an array of dom-elements; 0 is the document fragment)
+ * 3: the index of [0] in the two arrays
+ */
this._getCommonAncestor = function(node1, node2) {
var trace1 = this._getNodeContextTrace(node1),
trace2 = this._getNodeContextTrace(node2),
@@ -84,8 +156,10 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
}
var html = '<' + node.nodeName;
for (var i = 0; i < node.attributes.length; i++) {
- var attr = node.attributes[i];
- html += " " + attr.name + "=\"" + attr.value + "\"";
+ var attr = node.attributes[i];
+ if (attr.name != 'os-li-number') {
+ html += ' ' + attr.name + '="' + attr.value + '"';
+ }
}
html += '>';
return html;
@@ -158,7 +232,7 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
}
if (!found) {
console.trace();
- throw "Inconsistency or invalid call of this function detected";
+ throw "Inconsistency or invalid call of this function detected (to)";
}
return html;
};
@@ -196,7 +270,7 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
}
if (!found) {
console.trace();
- throw "Inconsistency or invalid call of this function detected";
+ throw "Inconsistency or invalid call of this function detected (from)";
}
if (node.nodeType != DOCUMENT_FRAGMENT_NODE) {
html += '' + node.nodeName + '>';
@@ -221,6 +295,7 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
*
* Hint:
* - The last line (toLine) is not included anymore, as the number refers to the line breaking element
+ * - if toLine === null, then everything from fromLine to the end of the fragment is returned
*
* In addition to the HTML snippet, additional information is provided regarding the most specific DOM element
* that contains the whole section specified by the line numbers (like a P-element if only one paragraph is selected
@@ -246,11 +321,19 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
* - followingHtmlStartSnippet: A HTML snippet that opens all HTML tags necessary to render "followingHtml"
*
*/
- this.extractRangeByLineNumbers = function(fragment, fromLine, toLine) {
+ this.extractRangeByLineNumbers = function(fragment, fromLine, toLine, debug) {
+ if (typeof(fragment) == 'string') {
+ fragment = this.htmlToFragment(fragment);
+ }
this._insertInternalLineMarkers(fragment);
+ this._insertInternalLiNumbers(fragment);
+ if (toLine === null) {
+ var internalLineMarkers = fragment.querySelectorAll('OS-LINEBREAK');
+ toLine = parseInt(internalLineMarkers[internalLineMarkers.length - 1].getAttribute("data-line-number"));
+ }
var fromLineNode = this.getLineNumberNode(fragment, fromLine),
- toLineNode = this.getLineNumberNode(fragment, toLine),
+ toLineNode = (toLine ? this.getLineNumberNode(fragment, toLine) : null),
ancestorData = this._getCommonAncestor(fromLineNode, toLineNode);
var fromChildTraceRel = ancestorData.trace1,
@@ -264,7 +347,8 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
innerContextStart = '',
innerContextEnd = '',
previousHtmlEndSnippet = '',
- followingHtmlStartSnippet = '';
+ followingHtmlStartSnippet = '',
+ fakeOl;
fromChildTraceAbs.shift();
@@ -288,7 +372,13 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
if (fromChildTraceRel[i].nodeName == 'OS-LINEBREAK') {
found = true;
} else {
- innerContextStart += this._serializeTag(fromChildTraceRel[i]);
+ if (fromChildTraceRel[i].nodeName == 'OL') {
+ fakeOl = fromChildTraceRel[i].cloneNode(false);
+ fakeOl.setAttribute('start', this._isWithinNthLIOfOL(fromChildTraceRel[i], fromLineNode));
+ innerContextStart += this._serializeTag(fakeOl);
+ } else {
+ innerContextStart += this._serializeTag(fromChildTraceRel[i]);
+ }
}
}
found = false;
@@ -317,7 +407,13 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
currNode = ancestor;
while (currNode.parentNode) {
- outerContextStart = this._serializeTag(currNode) + outerContextStart;
+ if (currNode.nodeName == 'OL') {
+ fakeOl = currNode.cloneNode(false);
+ fakeOl.setAttribute('start', this._isWithinNthLIOfOL(currNode, fromLineNode));
+ outerContextStart = this._serializeTag(fakeOl) + outerContextStart;
+ } else {
+ outerContextStart = this._serializeTag(currNode) + outerContextStart;
+ }
outerContextEnd += '' + currNode.nodeName + '>';
currNode = currNode.parentNode;
}
@@ -334,9 +430,16 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
'followingHtml': followingHtml,
'followingHtmlStartSnippet': followingHtmlStartSnippet
};
-
};
+ /*
+ * This functions merges to arrays of nodes. The last element of nodes1 and the first element of nodes2
+ * are merged, if they are of the same type.
+ *
+ * This is done recursively until a TEMPLATE-Tag is is found, which was inserted in this.replaceLines.
+ * Using a TEMPLATE-Tag is a rather dirty hack, as it is allowed inside of any other element, including
.
+ *
+ */
this._replaceLinesMergeNodeArrays = function(nodes1, nodes2) {
if (nodes1.length === 0) {
return nodes2;
@@ -350,56 +453,179 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
out.push(nodes1[i]);
}
- out.push(nodes1[nodes1.length - 1]);
- out.push(nodes2[0]);
+ var lastNode = nodes1[nodes1.length - 1],
+ firstNode = nodes2[0];
+ if (lastNode.nodeType == TEXT_NODE && firstNode.nodeType == TEXT_NODE) {
+ var newTextNode = lastNode.ownerDocument.createTextNode(lastNode.nodeValue + firstNode.nodeValue);
+ out.push(newTextNode);
+ } else if (lastNode.nodeName == firstNode.nodeName) {
+ var newNode = lastNode.ownerDocument.createElement(lastNode.nodeName);
+ for (i = 0; i < lastNode.attributes.length; i++) {
+ var attr = lastNode.attributes[i];
+ newNode.setAttribute(attr.name, attr.value);
+ }
+
+ // Remove #text nodes inside of List elements, as they are confusing
+ var lastChildren, firstChildren;
+ if (lastNode.nodeName == 'OL' || lastNode.nodeName == 'UL') {
+ lastChildren = [];
+ firstChildren = [];
+ for (i = 0; i < firstNode.childNodes.length; i++) {
+ if (firstNode.childNodes[i].nodeType == ELEMENT_NODE) {
+ firstChildren.push(firstNode.childNodes[i]);
+ }
+ }
+ for (i = 0; i < lastNode.childNodes.length; i++) {
+ if (lastNode.childNodes[i].nodeType == ELEMENT_NODE) {
+ lastChildren.push(lastNode.childNodes[i]);
+ }
+ }
+ } else {
+ lastChildren = lastNode.childNodes;
+ firstChildren = firstNode.childNodes;
+ }
+
+ var children = this._replaceLinesMergeNodeArrays(lastChildren, firstChildren);
+ for (i = 0; i < children.length; i++) {
+ newNode.appendChild(children[i]);
+ }
+ out.push(newNode);
+ } else {
+ if (lastNode.nodeName != 'TEMPLATE') {
+ out.push(lastNode);
+ }
+ if (firstNode.nodeName != 'TEMPLATE') {
+ out.push(firstNode);
+ }
+ }
for (i = 1; i < nodes2.length; i++) {
out.push(nodes2[i]);
}
- /*
- if (node1.nodeName != node2.nodeName) {
- return null;
- }
- var newNode = node1.ownerDocument.createElement(node1.nodeName);
- for (var i = 0; i < node1.attributes.length; i++) {
- var attr = node1.attributes[i];
- newNode.setAttribute(attr.name, attr.value);
- }
- return newNode;
- */
return out;
};
+ /**
+ * @param {string} htmlOld
+ * @param {string} htmlNew
+ * @returns {number}
+ */
+ this.detectReplacementType = function (htmlOld, htmlNew) {
+ // Convert all HTML tags to uppercase, strip trailing whitespaces
+ var normalizeHtml = function(html) {
+ html = html.replace(/<[^>]+>/g, function(tag) { return tag.toUpperCase(); });
+ html = html.replace(/\s+<\/P>/gi, '').replace(/\s+<\/DIV>/gi, '').replace(/\s+<\/LI>/gi, '');
+ html = html.replace(/\s+- /gi, '
- ').replace(/<\/LI>\s+/gi, '
');
+ html = html.replace(/ /gi, ' ').replace(/\u00A0/g, ' '); // non-breaking spaces
+ return html;
+ };
+ htmlOld = normalizeHtml(htmlOld);
+ htmlNew = normalizeHtml(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]) {
+ foundDiff = true;
+ }
+ }
+
+ var remainderOld = htmlOld.substr(i - 1),
+ remainderNew = htmlNew.substr(i - 1),
+ type = this.TYPE_REPLACEMENT;
+
+ if (remainderOld.length > remainderNew.length) {
+ 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) {
+ type = this.TYPE_INSERTION;
+ }
+ }
+
+ return type;
+ };
+
this.replaceLines = function (fragment, newHTML, fromLine, toLine) {
var data = this.extractRangeByLineNumbers(fragment, fromLine, toLine),
- previousHtml = data.previousHtml + data.previousHtmlEndSnippet,
+ previousHtml = data.previousHtml + '' + data.previousHtmlEndSnippet,
previousFragment = this.htmlToFragment(previousHtml),
- followingHtml = data.followingHtmlStartSnippet + data.followingHtml,
+ followingHtml = data.followingHtmlStartSnippet + '' + data.followingHtml,
+ followingFragment = this.htmlToFragment(followingHtml),
+ newFragment = this.htmlToFragment(newHTML);
+
+ var merged = this._replaceLinesMergeNodeArrays(previousFragment.childNodes, newFragment.childNodes);
+ merged = this._replaceLinesMergeNodeArrays(merged, followingFragment.childNodes);
+
+ var mergedFragment = document.createDocumentFragment();
+ for (var i = 0; i < merged.length; i++) {
+ mergedFragment.appendChild(merged[i]);
+ }
+
+ var forgottenTemplates = mergedFragment.querySelectorAll("TEMPLATE");
+ for (i = 0; i < forgottenTemplates.length; i++) {
+ var el = forgottenTemplates[i];
+ el.parentNode.removeChild(el);
+ }
+
+ return this._serializeDom(mergedFragment, true);
+ };
+
+ this.addCSSClass = function (node, className) {
+ if (node.nodeType != ELEMENT_NODE) {
+ return;
+ }
+ var classes = node.getAttribute('class');
+ classes = (classes ? classes.split(' ') : []);
+ if (classes.indexOf(className) == -1) {
+ classes.push(className);
+ }
+ node.setAttribute('class', classes);
+ };
+
+ this.addDiffMarkup = function (fragment, newHTML, fromLine, toLine, diffFormatterCb) {
+ var data = this.extractRangeByLineNumbers(fragment, fromLine, toLine),
+ previousHtml = data.previousHtml + '' + data.previousHtmlEndSnippet,
+ previousFragment = this.htmlToFragment(previousHtml),
+ followingHtml = data.followingHtmlStartSnippet + '' + data.followingHtml,
followingFragment = this.htmlToFragment(followingHtml),
newFragment = this.htmlToFragment(newHTML),
- child;
+ oldHTML = data.outerContextStart + data.innerContextStart + data.html +
+ data.innerContextEnd + data.outerContextEnd,
+ oldFragment = this.htmlToFragment(oldHTML),
+ el;
- var merged = document.createDocumentFragment();
+ var diffFragment = diffFormatterCb(oldFragment, newFragment);
- while (previousFragment.children.length > 0) {
- child = previousFragment.children[0];
- previousFragment.removeChild(child);
- merged.appendChild(child);
+ var mergedFragment = document.createDocumentFragment();
+ while (previousFragment.firstChild) {
+ el = previousFragment.firstChild;
+ previousFragment.removeChild(el);
+ mergedFragment.appendChild(el);
}
- while (newFragment.children.length > 0) {
- child = newFragment.children[0];
- newFragment.removeChild(child);
- merged.appendChild(child);
+ while (diffFragment.firstChild) {
+ el = diffFragment.firstChild;
+ diffFragment.removeChild(el);
+ mergedFragment.appendChild(el);
}
- while (followingFragment.children.length > 0) {
- child = followingFragment.children[0];
- followingFragment.removeChild(child);
- merged.appendChild(child);
+ while (followingFragment.firstChild) {
+ el = followingFragment.firstChild;
+ followingFragment.removeChild(el);
+ mergedFragment.appendChild(el);
}
- //var merged = this._replaceLinesAttemptMerge(lastOfPrevious, firstOfReplaced);
- return this._serializeDom(merged, true);
+ var forgottenTemplates = mergedFragment.querySelectorAll("TEMPLATE");
+ for (var i = 0; i < forgottenTemplates.length; i++) {
+ el = forgottenTemplates[i];
+ el.parentNode.removeChild(el);
+ }
+
+ return this._serializeDom(mergedFragment, true);
};
});
diff --git a/openslides/motions/static/js/motions/linenumbering.js b/openslides/motions/static/js/motions/linenumbering.js
index 4b97c615c..36eb9da66 100644
--- a/openslides/motions/static/js/motions/linenumbering.js
+++ b/openslides/motions/static/js/motions/linenumbering.js
@@ -76,6 +76,7 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
*
* @param node
* @param length
+ * @param highlight
* @returns Array
* @private
*/
@@ -89,7 +90,7 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
var addLine = function (text, highlight) {
var node;
if (typeof highlight === 'undefined') {
- highlight = 0;
+ highlight = -1;
}
if (firstTextNode) {
if (highlight == service._currentLineNumber - 1) {
@@ -344,19 +345,23 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
return root.innerHTML;
};
- this.insertLineNumbersNode = function (html, lineLength, highlight) {
+ this.insertLineNumbersNode = function (html, lineLength, highlight, firstLine) {
var root = document.createElement('div');
root.innerHTML = html;
this._currentInlineOffset = 0;
- this._currentLineNumber = 1;
+ if (firstLine) {
+ this._currentLineNumber = firstLine;
+ } else {
+ this._currentLineNumber = 1;
+ }
this._prependLineNumberToFirstText = true;
return this._insertLineNumbersToNode(root, lineLength, highlight);
};
- this.insertLineNumbers = function (html, lineLength, highlight, callback) {
- var newRoot = this.insertLineNumbersNode(html, lineLength, highlight);
+ this.insertLineNumbers = function (html, lineLength, highlight, callback, firstLine) {
+ var newRoot = this.insertLineNumbersNode(html, lineLength, highlight, firstLine);
if (callback) {
callback();
diff --git a/openslides/motions/static/js/motions/motion-services.js b/openslides/motions/static/js/motions/motion-services.js
index 585fc635f..ea439a8ee 100644
--- a/openslides/motions/static/js/motions/motion-services.js
+++ b/openslides/motions/static/js/motions/motion-services.js
@@ -4,6 +4,57 @@
angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions', 'OpenSlidesApp.motions.lineNumbering'])
+.factory('MotionPDFExport', [
+ 'HTMLValidizer',
+ 'Motion',
+ 'User',
+ 'PdfMakeConverter',
+ 'PdfMakeDocumentProvider',
+ 'MotionContentProvider',
+ 'PollContentProvider',
+ 'gettextCatalog',
+ '$http',
+ function (HTMLValidizer, Motion, User, PdfMakeConverter, PdfMakeDocumentProvider, MotionContentProvider,
+ PollContentProvider, gettextCatalog, $http) {
+ var obj = {};
+
+ var $scope;
+
+ obj.createMotion = function() {
+ var text = $scope.motion.getTextByMode($scope.viewChangeRecommendations.mode, $scope.version);
+ var content = HTMLValidizer.validize(text) + HTMLValidizer.validize($scope.motion.getReason($scope.version));
+ var map = Function.prototype.call.bind([].map);
+ var image_sources = map($(content).find("img"), function(element) {
+ return element.getAttribute("src");
+ });
+
+ $http.post('/core/encode_media/', JSON.stringify(image_sources)).success(function(data) {
+ var converter = PdfMakeConverter.createInstance(data.images, data.fonts, pdfMake);
+ var motionContentProvider = MotionContentProvider.createInstance(converter, $scope.motion, $scope, User, $http);
+ var documentProvider = PdfMakeDocumentProvider.createInstance(motionContentProvider, data.defaultFont);
+ var filename = gettextCatalog.getString("Motion") + "-" + $scope.motion.identifier + ".pdf";
+ pdfMake.createPdf(documentProvider.getDocument()).download(filename);
+ });
+ };
+
+ //make PDF for polls
+ obj.createPoll = function() {
+ var id = $scope.motion.identifier.replace(" ", ""),
+ title = $scope.motion.getTitle($scope.version),
+ filename = gettextCatalog.getString("Motion") + "-" + id + "-" + gettextCatalog.getString("ballot-paper") + ".pdf",
+ content = PollContentProvider.createInstance(title, id, gettextCatalog);
+
+ pdfMake.createPdf(content).download(filename);
+ };
+
+ obj.init = function (_scope) {
+ $scope = _scope;
+ };
+
+ return obj;
+ }
+])
+
.factory('MotionInlineEditing', [
'Editor',
'Motion',
@@ -37,29 +88,28 @@ angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions',
obj.tinymceOptions.readonly = 1;
obj.tinymceOptions.setup = function (editor) {
obj.editor = editor;
- editor.on("init", function () {
+ editor.on('init', function () {
obj.lineBrokenText = motion.getTextWithLineBreaks($scope.version);
obj.editor.setContent(obj.lineBrokenText);
obj.originalHtml = obj.editor.getContent();
obj.changed = false;
});
- editor.on("change", function () {
+ editor.on('change', function () {
obj.changed = (editor.getContent() != obj.originalHtml);
});
- editor.on("undo", function () {
+ editor.on('undo', function () {
obj.changed = (editor.getContent() != obj.originalHtml);
});
};
obj.setVersion = function (_motion, versionId) {
motion = _motion; // If this is not updated,
- console.log(versionId, motion.getTextWithLineBreaks(versionId));
obj.lineBrokenText = motion.getTextWithLineBreaks(versionId);
obj.changed = false;
obj.active = false;
if (obj.editor) {
obj.editor.setContent(obj.lineBrokenText);
- obj.editor.setMode("readonly");
+ obj.editor.setMode('readonly');
obj.originalHtml = obj.editor.getContent();
} else {
obj.originalHtml = obj.lineBrokenText;
@@ -67,7 +117,7 @@ angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions',
};
obj.enable = function () {
- obj.editor.setMode("design");
+ obj.editor.setMode('design');
obj.active = true;
obj.changed = false;
@@ -80,7 +130,7 @@ angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions',
};
obj.disable = function () {
- obj.editor.setMode("readonly");
+ obj.editor.setMode('readonly');
obj.active = false;
obj.changed = false;
obj.lineBrokenText = obj.originalHtml;
@@ -89,10 +139,10 @@ angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions',
obj.save = function () {
if (!motion.isAllowed('update')) {
- throw "No permission to update motion";
+ throw 'No permission to update motion';
}
- motion.setTextStrippingLineBreaks(motion.active_version, obj.editor.getContent());
+ motion.setTextStrippingLineBreaks(obj.editor.getContent());
motion.disable_versioning = (obj.trivialChange && Config.get('motions_allow_disable_versioning').value);
Motion.inject(motion);
@@ -114,6 +164,321 @@ angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions',
);
};
+ return obj;
+ }
+])
+
+.factory('ChangeRecommmendationCreate', [
+ 'ngDialog',
+ 'ChangeRecommendationForm',
+ function(ngDialog, ChangeRecommendationForm) {
+ var MODE_INACTIVE = 0,
+ MODE_SELECTING_FROM = 1,
+ MODE_SELECTING_TO = 2;
+
+ var obj = {
+ mode: MODE_INACTIVE,
+ lineFrom: 1,
+ lineTo: 2,
+ html: '',
+ reviewingHtml: ''
+ };
+
+ var $scope, motion, version;
+
+ obj._getAffectedLineNumbers = function () {
+ var changeRecommendations = motion.getChangeRecommendations(version),
+ affectedLines = [];
+ for (var i = 0; i < changeRecommendations.length; i++) {
+ var change = changeRecommendations[i];
+ for (var j = change.line_from; j < change.line_to; j++) {
+ affectedLines.push(j);
+ }
+ }
+ return affectedLines;
+ };
+
+ obj.startCreating = function () {
+ if (obj.mode > MODE_SELECTING_FROM || !motion.isAllowed('can_manage')) {
+ return;
+ }
+
+ var $lineNumbers = $(".motion-text-original .os-line-number");
+ if ($lineNumbers.filter(".selectable").length === 0) {
+ obj.mode = MODE_SELECTING_FROM;
+ var alreadyAffectedLines = obj._getAffectedLineNumbers();
+ $lineNumbers.each(function () {
+ var $this = $(this),
+ lineNumber = $this.data("line-number");
+ if (alreadyAffectedLines.indexOf(lineNumber) == -1) {
+ $(this).addClass("selectable");
+ }
+ });
+ }
+ };
+
+ obj.cancelCreating = function (ev) {
+ var $target = $(ev.target),
+ query = ".line-numbers-outside .os-line-number.selectable";
+ if (!$target.is(query) && $target.parents(query).length === 0) {
+ obj.mode = MODE_INACTIVE;
+ obj.lineFrom = 0;
+ obj.lineTo = 0;
+ $(".motion-text-original .os-line-number").removeClass("selected selectable");
+ obj.startCreating();
+ }
+ };
+
+ obj.setFromLine = function (line) {
+ obj.mode = MODE_SELECTING_TO;
+ obj.lineFrom = line;
+
+ var alreadyAffectedLines = obj._getAffectedLineNumbers(),
+ foundCollission = false;
+
+ $(".motion-text-original .os-line-number").each(function () {
+
+ var $this = $(this);
+ if ($this.data("line-number") >= line && !foundCollission) {
+ if (alreadyAffectedLines.indexOf($this.data("line-number")) == -1) {
+ $(this).addClass("selectable");
+ } else {
+ $(this).removeClass("selectable");
+ foundCollission = true;
+ }
+ } else {
+ $(this).removeClass("selectable");
+ }
+ });
+ };
+
+ obj.setToLine = function (line) {
+ if (line < obj.lineFrom) {
+ return;
+ }
+ obj.mode = MODE_INACTIVE;
+ obj.lineTo = line + 1;
+ ngDialog.open(ChangeRecommendationForm.getCreateDialog(
+ motion,
+ version,
+ obj.lineFrom,
+ obj.lineTo
+ ));
+
+ obj.lineFrom = 0;
+ obj.lineTo = 0;
+ $(".motion-text-original .os-line-number").removeClass("selected selectable");
+ obj.startCreating();
+ };
+
+ obj.lineClicked = function (ev) {
+ if (obj.mode == MODE_INACTIVE) {
+ return;
+ }
+ if (obj.mode == MODE_SELECTING_FROM) {
+ obj.setFromLine($(ev.target).data("line-number"));
+ $(ev.target).addClass("selected");
+ } else if (obj.mode == MODE_SELECTING_TO) {
+ obj.setToLine($(ev.target).data("line-number"));
+ }
+ };
+
+ obj.mouseOver = function (ev) {
+ if (obj.mode != MODE_SELECTING_TO) {
+ return;
+ }
+ var hoverLine = $(ev.target).data("line-number");
+ $(".motion-text-original .os-line-number").each(function () {
+ var line = $(this).data("line-number");
+ if (line >= obj.lineFrom && line <= hoverLine) {
+ $(this).addClass("selected");
+ } else {
+ $(this).removeClass("selected");
+ }
+ });
+ };
+
+ obj.setVersion = function (_motion, _version) {
+ motion = _motion;
+ version = _version;
+ };
+
+ obj.init = function (_scope, _motion) {
+ $scope = _scope;
+ motion = _motion;
+ version = $scope.version;
+
+ var $content = $("#content");
+ $content.on("click", ".line-numbers-outside .os-line-number.selectable", obj.lineClicked);
+ $content.on("click", obj.cancelCreating);
+ $content.on("mouseover", ".line-numbers-outside .os-line-number.selectable", obj.mouseOver);
+ $content.on("mouseover", ".motion-text-original", obj.startCreating);
+
+ $scope.$on("$destroy", function () {
+ obj.destroy();
+ });
+ };
+
+ obj.destroy = function () {
+ var $content = $("#content");
+ $content.off("click", ".line-numbers-outside .os-line-number.selectable", obj.lineClicked);
+ $content.off("click", obj.cancelCreating);
+ $content.off("mouseover", ".line-numbers-outside .os-line-number.selectable", obj.mouseOver);
+ $content.off("mouseover", ".motion-text-original", obj.startCreating);
+ };
+
+ return obj;
+ }
+])
+
+.factory('ChangeRecommmendationView', [
+ 'Motion',
+ 'MotionChangeRecommendation',
+ 'Config',
+ 'lineNumberingService',
+ 'diffService',
+ '$interval',
+ '$timeout',
+ function (Motion, MotionChangeRecommendation, Config, lineNumberingService, diffService, $interval, $timeout) {
+ var $scope;
+
+ var obj = {
+ mode: 'original'
+ };
+
+ obj.diffFormatterCb = function (change, oldFragment, newFragment) {
+ for (var i = 0; i < oldFragment.childNodes.length; i++) {
+ diffService.addCSSClass(oldFragment.childNodes[i], 'delete');
+ }
+ for (i = 0; i < newFragment.childNodes.length; i++) {
+ diffService.addCSSClass(newFragment.childNodes[i], 'insert');
+ }
+ var mergedFragment = document.createDocumentFragment(),
+ diffSection = document.createElement('SECTION'),
+ el;
+
+ mergedFragment.appendChild(diffSection);
+ diffSection.setAttribute('class', 'diff');
+ diffSection.setAttribute('data-change-id', change.id);
+
+ while (oldFragment.firstChild) {
+ el = oldFragment.firstChild;
+ oldFragment.removeChild(el);
+ diffSection.appendChild(el);
+ }
+ while (newFragment.firstChild) {
+ el = newFragment.firstChild;
+ newFragment.removeChild(el);
+ diffSection.appendChild(el);
+ }
+
+ return mergedFragment;
+ };
+
+ obj.delete = function (changeId) {
+ MotionChangeRecommendation.destroy(changeId);
+ };
+
+ obj.repositionOriginalAnnotations = function () {
+ var $changeRecommendationList = $('.change-recommendation-list'),
+ $lineNumberReference = $('.motion-text-original');
+
+ $changeRecommendationList.children().each(function() {
+ var $this = $(this),
+ lineFrom = $this.data('line-from'),
+ lineTo = ($this.data('line-to') - 1),
+ $lineFrom = $lineNumberReference.find('.line-number-' + lineFrom),
+ $lineTo = $lineNumberReference.find('.line-number-' + lineTo),
+ fromTop = $lineFrom.position().top + 3,
+ toTop = $lineTo.position().top + 20,
+ height = (toTop - fromTop);
+
+ if (height < 10) {
+ height = 10;
+ }
+
+ // $lineFrom.position().top seems to depend on the scrolling position when the line numbers
+ // have position: absolute. Maybe a bug in the used version of jQuery?
+ // This cancels the effect.
+ /*
+ if ($lineNumberReference.hasClass('line-numbers-outside')) {
+ fromTop += window.scrollY;
+ }
+ */
+
+ $this.css({ 'top': fromTop, 'height': height });
+ });
+ };
+
+ obj.newVersionIncludingChanges = function (motion, version, includeProposed) {
+ if (!motion.isAllowed('update')) {
+ throw 'No permission to update motion';
+ }
+
+ var newHtml = (
+ includeProposed ?
+ motion.getTextWithoutRejectedChangeRecommendations(version) :
+ motion.getTextWithAcceptedChangeRecommendations(version)
+ );
+
+ motion.setTextStrippingLineBreaks(newHtml);
+
+ Motion.inject(motion);
+ // save change motion object on server
+ Motion.save(motion, {method: 'PATCH'}).then(
+ function (success) {
+ $scope.showVersion(motion.getVersion(-1));
+ },
+ function (error) {
+ // save error: revert all changes by restore
+ // (refresh) original motion object from server
+ Motion.refresh(motion);
+ var message = '';
+ for (var e in error.data) {
+ message += e + ': ' + error.data[e] + ' ';
+ }
+ $scope.alert = {type: 'danger', msg: message, show: true};
+ }
+ );
+ };
+
+ obj.scrollToDiffBox = function (changeId) {
+ obj.mode = 'diff';
+ $timeout(function() {
+ var $diffBox = $('.diff-box-' + changeId);
+ $('html, body').animate({
+ scrollTop: $diffBox.offset().top - 50
+ }, 300);
+ }, 0, false);
+ };
+
+ obj.init = function (_scope) {
+ $scope = _scope;
+ $scope.$evalAsync(function() {
+ obj.repositionOriginalAnnotations();
+ });
+ $scope.$watch(function() {
+ return $('.change-recommendation-list').children().length;
+ }, obj.repositionOriginalAnnotations);
+
+ var sizeCheckerLastSize = null,
+ sizeCheckerLastClass = null,
+ sizeChecker = $interval(function() {
+ var $holder = $(".motion-text-original"),
+ newHeight = $holder.height(),
+ classes = $holder.attr("class");
+ if (newHeight != sizeCheckerLastSize || sizeCheckerLastClass != classes) {
+ sizeCheckerLastSize = newHeight;
+ sizeCheckerLastClass = classes;
+ obj.repositionOriginalAnnotations();
+ }
+ }, 100, 0, false);
+
+ $scope.$on('$destroy', function() {
+ $interval.cancel(sizeChecker);
+ });
+ };
+
return obj;
}
]);
diff --git a/openslides/motions/static/js/motions/pdf.js b/openslides/motions/static/js/motions/pdf.js
index 0bfb71a78..6f23a158a 100644
--- a/openslides/motions/static/js/motions/pdf.js
+++ b/openslides/motions/static/js/motions/pdf.js
@@ -27,7 +27,8 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf'])
* other project that might want to use this HTML to PDF parser.
* https://github.com/OpenSlides/OpenSlides/issues/2361
*/
- return converter.convertHTML(motion.getTextWithLineBreaks($scope.version), $scope);
+ var text = motion.getTextByMode($scope.viewChangeRecommendations.mode, $scope.version);
+ return converter.convertHTML(text, $scope);
} else {
return converter.convertHTML(motion.getText($scope.version), $scope);
}
diff --git a/openslides/motions/static/js/motions/site.js b/openslides/motions/static/js/motions/site.js
index 902db5685..44aacb6a8 100644
--- a/openslides/motions/static/js/motions/site.js
+++ b/openslides/motions/static/js/motions/site.js
@@ -104,6 +104,9 @@ angular.module('OpenSlidesApp.motions.site', [
},
tags: function(Tag) {
return Tag.findAll();
+ },
+ change_recommendations: function(MotionChangeRecommendation, motion) {
+ return MotionChangeRecommendation.findAll({'where': {'motion_version_id': {'==': motion.active_version}}});
}
}
})
@@ -207,6 +210,93 @@ angular.module('OpenSlidesApp.motions.site', [
}
])
+.factory('ChangeRecommendationForm', [
+ 'gettextCatalog',
+ 'Editor',
+ 'Config',
+ function(gettextCatalog, Editor, Config) {
+ return {
+ // ngDialog for motion form
+ getCreateDialog: function (motion, version, lineFrom, lineTo) {
+ return {
+ template: 'static/templates/motions/change-recommendation-form.html',
+ controller: 'ChangeRecommendationCreateCtrl',
+ className: 'ngdialog-theme-default wide-form',
+ closeByEscape: false,
+ closeByDocument: false,
+ resolve: {
+ motion: function() {
+ return motion;
+ },
+ version: function() {
+ return version;
+ },
+ lineFrom: function() {
+ return lineFrom;
+ },
+ lineTo: function() {
+ return lineTo;
+ }
+ }
+ };
+ },
+ // angular-formly fields for motion form
+ getFormFields: function (line_from, line_to) {
+ return [
+ {
+ key: 'identifier',
+ type: 'input',
+ templateOptions: {
+ label: gettextCatalog.getString('Identifier')
+ },
+ hide: true
+ },
+ {
+ key: 'motion_version_id',
+ type: 'input',
+ templateOptions: {
+ label: gettextCatalog.getString('Motion')
+ },
+ hide: true
+ },
+ {
+ key: 'line_from',
+ type: 'input',
+ templateOptions: {
+ label: gettextCatalog.getString('From Line')
+ },
+ hide: true
+ },
+ {
+ key: 'line_to',
+ type: 'input',
+ templateOptions: {
+ label: gettextCatalog.getString('To Line')
+ },
+ hide: true
+ },
+ {
+ key: 'text',
+ type: 'editor',
+ templateOptions: {
+ label: (
+ line_from == line_to - 1 ?
+ gettextCatalog.getString('Text in line %from%').replace(/%from%/, line_from) :
+ gettextCatalog.getString('Text from line %from% to %to%')
+ .replace(/%from%/, line_from).replace(/%to%/, line_to - 1)
+ ),
+ required: false
+ },
+ data: {
+ tinymceOption: Editor.getOptions()
+ }
+ }
+ ];
+ }
+ };
+ }
+])
+
// Service for generic motion form (create and update)
.factory('MotionForm', [
'gettextCatalog',
@@ -818,6 +908,10 @@ angular.module('OpenSlidesApp.motions.site', [
'operator',
'ngDialog',
'MotionForm',
+ 'ChangeRecommmendationCreate',
+ 'ChangeRecommmendationView',
+ 'MotionChangeRecommendation',
+ 'MotionPDFExport',
'Motion',
'Category',
'Mediafile',
@@ -826,24 +920,21 @@ angular.module('OpenSlidesApp.motions.site', [
'Workflow',
'Config',
'motion',
- 'MotionContentProvider',
- 'PollContentProvider',
- 'PdfMakeConverter',
- 'PdfMakeDocumentProvider',
'MotionInlineEditing',
'gettextCatalog',
'Projector',
- 'HTMLValidizer',
'ProjectionDefault',
- function($scope, $http, operator, ngDialog, MotionForm, Motion, Category, Mediafile, Tag, User, Workflow, Config,
- motion, MotionContentProvider, PollContentProvider, PdfMakeConverter, PdfMakeDocumentProvider,
- MotionInlineEditing, gettextCatalog, Projector, HTMLValidizer, ProjectionDefault) {
+ function($scope, $http, operator, ngDialog, MotionForm,
+ ChangeRecommmendationCreate, ChangeRecommmendationView, MotionChangeRecommendation, MotionPDFExport,
+ Motion, Category, Mediafile, Tag, User, Workflow, Config, motion, MotionInlineEditing, gettextCatalog,
+ Projector, ProjectionDefault) {
Motion.bindOne(motion.id, $scope, 'motion');
Category.bindAll({}, $scope, 'categories');
Mediafile.bindAll({}, $scope, 'mediafiles');
Tag.bindAll({}, $scope, 'tags');
User.bindAll({}, $scope, 'users');
Workflow.bindAll({}, $scope, 'workflows');
+ MotionChangeRecommendation.bindAll({'where': {'motion_version_id': {'==': motion.active_version}}}, $scope, 'change_recommendations');
Motion.loadRelations(motion, 'agenda_item');
$scope.$watch(function () {
return Projector.lastModified();
@@ -853,7 +944,12 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.version = motion.active_version;
$scope.isCollapsed = true;
$scope.commentsFields = Config.get('motions_comments').value;
+
$scope.lineNumberMode = Config.get('motions_default_line_numbering').value;
+ $scope.setLineNumberMode = function(mode) {
+ $scope.lineNumberMode = mode;
+ };
+
if (motion.parent_id) {
Motion.bindOne(motion.parent_id, $scope, 'parent');
}
@@ -891,32 +987,6 @@ angular.module('OpenSlidesApp.motions.site', [
setHighlightOnProjector($scope.linesForProjector ? $scope.highlight : 0);
};
- $scope.makePDF = function() {
- var content = HTMLValidizer.validize(motion.getText($scope.version)) + HTMLValidizer.validize(motion.getReason($scope.version));
- var map = Function.prototype.call.bind([].map);
- var image_sources = map($(content).find("img"), function(element) {
- return element.getAttribute("src");
- });
-
- $http.post('/core/encode_media/', JSON.stringify(image_sources)).success(function(data) {
- var converter = PdfMakeConverter.createInstance(data.images, data.fonts, pdfMake);
- var motionContentProvider = MotionContentProvider.createInstance(converter, motion, $scope, User, $http);
- var documentProvider = PdfMakeDocumentProvider.createInstance(motionContentProvider, data.defaultFont);
- var filename = gettextCatalog.getString("Motion") + "-" + motion.identifier + ".pdf";
- pdfMake.createPdf(documentProvider.getDocument()).download(filename);
- });
-
- };
-
- //make PDF for polls
- $scope.makePollPDF = function() {
- var id = motion.identifier.replace(" ", ""),
- title = motion.getTitle($scope.version),
- filename = gettextCatalog.getString("Motion") + "-" + id + "-" + gettextCatalog.getString("ballot-paper") + ".pdf",
- content = PollContentProvider.createInstance(title, id, gettextCatalog);
- pdfMake.createPdf(content).download(filename);
- };
-
// open edit dialog
$scope.openDialog = function (motion) {
if ($scope.inlineEditing.active) {
@@ -987,6 +1057,7 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.showVersion = function (version) {
$scope.version = version.id;
$scope.inlineEditing.setVersion(motion, version.id);
+ $scope.createChangeRecommendation.setVersion(motion, version.id);
};
// permit specific version
$scope.permitVersion = function (version) {
@@ -1022,6 +1093,59 @@ angular.module('OpenSlidesApp.motions.site', [
// Inline editing functions
$scope.inlineEditing = MotionInlineEditing;
$scope.inlineEditing.init($scope, motion);
+
+ // Change recommendation creation functions
+ $scope.createChangeRecommendation = ChangeRecommmendationCreate;
+ $scope.createChangeRecommendation.init($scope, motion);
+
+ // Change recommendation viewing
+ $scope.viewChangeRecommendations = ChangeRecommmendationView;
+ $scope.viewChangeRecommendations.init($scope);
+
+ // PDF creating functions
+ $scope.pdfExport = MotionPDFExport;
+ $scope.pdfExport.init($scope);
+ }
+])
+
+.controller('ChangeRecommendationCreateCtrl', [
+ '$scope',
+ 'Motion',
+ 'MotionChangeRecommendation',
+ 'ChangeRecommendationForm',
+ 'Config',
+ 'diffService',
+ 'motion',
+ 'version',
+ 'lineFrom',
+ 'lineTo',
+ function($scope, Motion, MotionChangeRecommendation, ChangeRecommendationForm, Config, diffService, motion,
+ version, lineFrom, lineTo) {
+ $scope.alert = {};
+
+ var html = motion.getTextWithLineBreaks(version),
+ fragment = diffService.htmlToFragment(html),
+ lineData = diffService.extractRangeByLineNumbers(fragment, lineFrom, lineTo);
+
+ $scope.model = {
+ text: lineData.outerContextStart + lineData.innerContextStart +
+ lineData.html + lineData.innerContextEnd + lineData.outerContextEnd,
+ line_from: lineFrom,
+ line_to: lineTo,
+ motion_version_id: version,
+ type: 0
+ };
+
+ // get all form fields
+ $scope.formFields = ChangeRecommendationForm.getFormFields(lineFrom, lineTo);
+ // save motion
+ $scope.save = function (motion) {
+ MotionChangeRecommendation.create(motion).then(
+ function(success) {
+ $scope.closeThisDialog();
+ }
+ );
+ };
}
])
diff --git a/openslides/motions/static/templates/motions/change-recommendation-form.html b/openslides/motions/static/templates/motions/change-recommendation-form.html
new file mode 100644
index 000000000..543f824c6
--- /dev/null
+++ b/openslides/motions/static/templates/motions/change-recommendation-form.html
@@ -0,0 +1,17 @@
+Edit change recommendation
+New change recommendation
+
+
+ {{ alert.msg }}
+
+
+
diff --git a/openslides/motions/static/templates/motions/motion-detail.html b/openslides/motions/static/templates/motions/motion-detail.html
index 337b06c32..e622df746 100644
--- a/openslides/motions/static/templates/motions/motion-detail.html
+++ b/openslides/motions/static/templates/motions/motion-detail.html
@@ -21,7 +21,7 @@
-
+
PDF
@@ -182,7 +182,7 @@
-
@@ -289,95 +289,49 @@