Merge pull request #2501 from CatoTH/change-recommendations-pr

Change recommendations (WIP)
This commit is contained in:
Emanuel Schütze 2016-10-17 20:06:01 +02:00 committed by GitHub
commit 34b074faec
21 changed files with 1829 additions and 222 deletions

View File

@ -302,22 +302,25 @@ img {
border: 1px solid #d3d3d3; border: 1px solid #d3d3d3;
} }
.col1 .details .line-number-setter { .col1 .details .motion-toolbar .toolbar-left {
margin-top: 0; margin-top: 0;
margin-bottom: 55px; margin-bottom: 55px;
margin-left: 15px; margin-left: 15px;
} }
.col1 .details .line-number-setter > span { .col1 .details .motion-toolbar .toolbar-left > * {
margin-right: 5px; margin-right: 5px;
float: left; float: left;
} }
.col1 .details .line-number-setter .btn.disabled { .col1 .details .motion-toolbar .toolbar-left .btn.disabled {
cursor: default; cursor: default;
opacity: 1; opacity: 1;
background-color: #eee; background-color: #eee;
} }
.col1 .details .motion-toolbar .toolbar-left .goto-line-number {
max-width: 220px;
}
.col1 .details .inline-editing-activator { .col1 .details .inline-editing-activator {
margin-right: 13px; margin-right: 13px;
@ -395,23 +398,29 @@ img {
background-color: #ff0; background-color: #ff0;
} }
.motion-text li {
margin-left: 30px;
}
.motion-text.line-numbers-outside { .motion-text.line-numbers-outside {
padding-left: 35px; padding-left: 40px;
position: relative; position: relative;
} }
.motion-text.line-numbers-outside .os-line-number { .motion-text.line-numbers-outside .os-line-number {
display: inline-block; display: inline-block;
font-size: 0; font-size: 0;
line-height: 0; line-height: 0;
width: 0; width: 22px;
height: 0; height: 22px;
position: absolute;
left: -20px;
padding-right: 55px;
} }
.motion-text.line-numbers-outside .os-line-number:after { .motion-text.line-numbers-outside .os-line-number:after {
content: attr(data-line-number); content: attr(data-line-number);
position: absolute; position: absolute;
left: 0; left: 20px;
top: 12px;
vertical-align: top; vertical-align: top;
margin-top: -5px;
color: gray; color: gray;
font-family: Courier, serif; font-family: Courier, serif;
font-size: 13px; font-size: 13px;
@ -445,6 +454,7 @@ img {
} }
.os-line-number { .os-line-number {
position: relative;
user-select: none; user-select: none;
-moz-user-select: none; -moz-user-select: none;
-khtml-user-select: none; -khtml-user-select: none;
@ -452,6 +462,7 @@ img {
-o-user-select: none; -o-user-select: none;
} }
.os-line-number:after { .os-line-number:after {
position: relative;
user-select: none; user-select: none;
-moz-user-select: none; -moz-user-select: none;
-khtml-user-select: none; -khtml-user-select: none;
@ -459,6 +470,174 @@ img {
-o-user-select: none; -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 **/ /** Projector sidebar column **/
#content .col2 { #content .col2 {
@ -704,13 +883,16 @@ img {
border-top: 1px solid #ddd; border-top: 1px solid #ddd;
background-color: #f5f5f5; background-color: #f5f5f5;
} }
.linenumber-toolbar, .speakers-toolbar { .motion-toolbar, .speakers-toolbar {
background-color: #f5f5f5; background-color: #f5f5f5;
border-bottom: 1px solid #ddd; border-bottom: 1px solid #ddd;
padding: 12px 0 10px 0; padding: 12px 0 10px 0;
height: 54px; height: 54px;
margin: -20px -5px 50px -5px; margin: -20px -5px 50px -5px;
} }
.motion-toolbar:first-child {
margin-bottom: 20px;
}
.speakers-toolbar { .speakers-toolbar {
margin: -20px -20px 30px -20px; margin: -20px -20px 30px -20px;

View File

@ -55,6 +55,25 @@ class MotionAccessPermissions(BaseAccessPermissions):
return data 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): class CategoryAccessPermissions(BaseAccessPermissions):
""" """
Access permissions container for Category and CategoryViewSet. Access permissions container for Category and CategoryViewSet.

View File

@ -18,7 +18,7 @@ class MotionsAppConfig(AppConfig):
from openslides.utils.rest_api import router from openslides.utils.rest_api import router
from .config_variables import get_config_variables from .config_variables import get_config_variables
from .signals import create_builtin_workflows from .signals import create_builtin_workflows
from .views import CategoryViewSet, MotionViewSet, MotionPollViewSet, WorkflowViewSet from .views import CategoryViewSet, MotionViewSet, MotionPollViewSet, MotionChangeRecommendationViewSet, WorkflowViewSet
# Define config variables # Define config variables
config.update_config_variables(get_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('Category').get_collection_string(), CategoryViewSet)
router.register(self.get_model('Motion').get_collection_string(), MotionViewSet) 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('Workflow').get_collection_string(), WorkflowViewSet)
router.register(self.get_model('MotionChangeRecommendation').get_collection_string(),
MotionChangeRecommendationViewSet)
router.register('motions/motionpoll', MotionPollViewSet) router.register('motions/motionpoll', MotionPollViewSet)

View File

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

View File

@ -23,6 +23,7 @@ from openslides.utils.search import user_name_helper
from .access_permissions import ( from .access_permissions import (
CategoryAccessPermissions, CategoryAccessPermissions,
MotionAccessPermissions, MotionAccessPermissions,
MotionChangeRecommendationAccessPermissions,
WorkflowAccessPermissions, WorkflowAccessPermissions,
) )
from .exceptions import WorkflowError from .exceptions import WorkflowError
@ -697,6 +698,68 @@ class MotionVersion(RESTModelMixin, models.Model):
return self.motion 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): class Category(RESTModelMixin, models.Model):
""" """
Model for categories of motions. Model for categories of motions.

View File

@ -16,6 +16,7 @@ from openslides.utils.rest_api import (
from .models import ( from .models import (
Category, Category,
Motion, Motion,
MotionChangeRecommendation,
MotionLog, MotionLog,
MotionPoll, MotionPoll,
MotionVersion, MotionVersion,
@ -228,6 +229,22 @@ class MotionVersionSerializer(ModelSerializer):
'reason',) '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): class MotionSerializer(ModelSerializer):
""" """
Serializer for motion.models.Motion objects. Serializer for motion.models.Motion objects.

View File

@ -142,13 +142,16 @@ angular.module('OpenSlidesApp.motions', [
.factory('Motion', [ .factory('Motion', [
'DS', 'DS',
'MotionPoll', 'MotionPoll',
'MotionChangeRecommendation',
'MotionComment', 'MotionComment',
'jsDataModel', 'jsDataModel',
'gettext', 'gettext',
'operator', 'operator',
'Config', 'Config',
'lineNumberingService', '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'; var name = 'motions/motion';
return DS.defineResource({ return DS.defineResource({
name: name, name: name,
@ -187,7 +190,98 @@ angular.module('OpenSlidesApp.motions', [
return lineNumberingService.insertLineNumbers(html, lineLength, highlight, callback); 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); this.text = lineNumberingService.stripLineNumbers(text);
}, },
getReason: function (versionId) { getReason: function (versionId) {
@ -201,6 +295,24 @@ angular.module('OpenSlidesApp.motions', [
getSearchResultSubtitle: function () { getSearchResultSubtitle: function () {
return "Motion"; 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) { isAllowed: function (action) {
/* /*
* Return true if the requested user is allowed to do the specific 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([ .run([
'Motion', 'Motion',
'Category', 'Category',
'Workflow', 'Workflow',
function(Motion, Category, Workflow) {} 'MotionChangeRecommendation',
function(Motion, Category, Workflow, MotionChangeRecommendation) {}
]) ])

View File

@ -9,6 +9,9 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
TEXT_NODE = 3, TEXT_NODE = 3,
DOCUMENT_FRAGMENT_NODE = 11; DOCUMENT_FRAGMENT_NODE = 11;
this.TYPE_REPLACEMENT = 0;
this.TYPE_INSERTION = 1;
this.TYPE_DELETION = 2;
this.getLineNumberNode = function(fragment, lineNumber) { this.getLineNumberNode = function(fragment, lineNumber) {
return fragment.querySelector('os-linebreak.os-line-number.line-number-' + lineNumber); return fragment.querySelector('os-linebreak.os-line-number.line-number-' + lineNumber);
@ -24,22 +27,91 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
return context; return context;
}; };
// Adds elements like <OS-LINEBREAK class="os-line-number line-number-23" data-line-number="23"/>
this._insertInternalLineMarkers = function(fragment) { this._insertInternalLineMarkers = function(fragment) {
if (fragment.querySelectorAll('OS-LINEBREAK').length > 0) { if (fragment.querySelectorAll('OS-LINEBREAK').length > 0) {
// Prevent duplicate calls // Prevent duplicate calls
return; 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++) { for (var i = 0; i < lineNumbers.length; i++) {
var insertBefore = lineNumbers[i]; var insertBefore = lineNumbers[i];
while (insertBefore.parentNode.nodeType != DOCUMENT_FRAGMENT_NODE && insertBefore.parentNode.childNodes[0] == insertBefore) { while (insertBefore.parentNode.nodeType != DOCUMENT_FRAGMENT_NODE && insertBefore.parentNode.childNodes[0] == insertBefore) {
insertBefore = insertBefore.parentNode; 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('data-line-number', lineNumbers[i].getAttribute('data-line-number'));
lineMarker.setAttribute('class', lineNumbers[i].getAttribute('class')); lineMarker.setAttribute('class', lineNumbers[i].getAttribute('class'));
insertBefore.parentNode.insertBefore(lineMarker, insertBefore); 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);
}
}
}
};
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;
}; };
/* /*
@ -85,7 +157,9 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
var html = '<' + node.nodeName; var html = '<' + node.nodeName;
for (var i = 0; i < node.attributes.length; i++) { for (var i = 0; i < node.attributes.length; i++) {
var attr = node.attributes[i]; var attr = node.attributes[i];
html += " " + attr.name + "=\"" + attr.value + "\""; if (attr.name != 'os-li-number') {
html += ' ' + attr.name + '="' + attr.value + '"';
}
} }
html += '>'; html += '>';
return html; return html;
@ -158,7 +232,7 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
} }
if (!found) { if (!found) {
console.trace(); console.trace();
throw "Inconsistency or invalid call of this function detected"; throw "Inconsistency or invalid call of this function detected (to)";
} }
return html; return html;
}; };
@ -196,7 +270,7 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
} }
if (!found) { if (!found) {
console.trace(); 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) { if (node.nodeType != DOCUMENT_FRAGMENT_NODE) {
html += '</' + node.nodeName + '>'; html += '</' + node.nodeName + '>';
@ -221,6 +295,7 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
* *
* Hint: * 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
* - 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 * 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 * 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" * - 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._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), var fromLineNode = this.getLineNumberNode(fragment, fromLine),
toLineNode = this.getLineNumberNode(fragment, toLine), toLineNode = (toLine ? this.getLineNumberNode(fragment, toLine) : null),
ancestorData = this._getCommonAncestor(fromLineNode, toLineNode); ancestorData = this._getCommonAncestor(fromLineNode, toLineNode);
var fromChildTraceRel = ancestorData.trace1, var fromChildTraceRel = ancestorData.trace1,
@ -264,7 +347,8 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
innerContextStart = '', innerContextStart = '',
innerContextEnd = '', innerContextEnd = '',
previousHtmlEndSnippet = '', previousHtmlEndSnippet = '',
followingHtmlStartSnippet = ''; followingHtmlStartSnippet = '',
fakeOl;
fromChildTraceAbs.shift(); fromChildTraceAbs.shift();
@ -287,10 +371,16 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
for (var i = 0; i < fromChildTraceRel.length && !found; i++) { for (var i = 0; i < fromChildTraceRel.length && !found; i++) {
if (fromChildTraceRel[i].nodeName == 'OS-LINEBREAK') { if (fromChildTraceRel[i].nodeName == 'OS-LINEBREAK') {
found = true; found = true;
} else {
if (fromChildTraceRel[i].nodeName == 'OL') {
fakeOl = fromChildTraceRel[i].cloneNode(false);
fakeOl.setAttribute('start', this._isWithinNthLIOfOL(fromChildTraceRel[i], fromLineNode));
innerContextStart += this._serializeTag(fakeOl);
} else { } else {
innerContextStart += this._serializeTag(fromChildTraceRel[i]); innerContextStart += this._serializeTag(fromChildTraceRel[i]);
} }
} }
}
found = false; found = false;
for (i = 0; i < toChildTraceRel.length && !found; i++) { for (i = 0; i < toChildTraceRel.length && !found; i++) {
if (toChildTraceRel[i].nodeName == 'OS-LINEBREAK') { if (toChildTraceRel[i].nodeName == 'OS-LINEBREAK') {
@ -317,7 +407,13 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
currNode = ancestor; currNode = ancestor;
while (currNode.parentNode) { while (currNode.parentNode) {
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; outerContextStart = this._serializeTag(currNode) + outerContextStart;
}
outerContextEnd += '</' + currNode.nodeName + '>'; outerContextEnd += '</' + currNode.nodeName + '>';
currNode = currNode.parentNode; currNode = currNode.parentNode;
} }
@ -334,9 +430,16 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
'followingHtml': followingHtml, 'followingHtml': followingHtml,
'followingHtmlStartSnippet': followingHtmlStartSnippet '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 <ul>.
*
*/
this._replaceLinesMergeNodeArrays = function(nodes1, nodes2) { this._replaceLinesMergeNodeArrays = function(nodes1, nodes2) {
if (nodes1.length === 0) { if (nodes1.length === 0) {
return nodes2; return nodes2;
@ -350,56 +453,179 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi
out.push(nodes1[i]); out.push(nodes1[i]);
} }
out.push(nodes1[nodes1.length - 1]); var lastNode = nodes1[nodes1.length - 1],
out.push(nodes2[0]); 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++) { for (i = 1; i < nodes2.length; i++) {
out.push(nodes2[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; 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, '</P>').replace(/\s+<\/DIV>/gi, '</DIV>').replace(/\s+<\/LI>/gi, '</LI>');
html = html.replace(/\s+<LI>/gi, '<LI>').replace(/<\/LI>\s+/gi, '</LI>');
html = html.replace(/&nbsp;/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) { this.replaceLines = function (fragment, newHTML, fromLine, toLine) {
var data = this.extractRangeByLineNumbers(fragment, fromLine, toLine), var data = this.extractRangeByLineNumbers(fragment, fromLine, toLine),
previousHtml = data.previousHtml + data.previousHtmlEndSnippet, previousHtml = data.previousHtml + '<TEMPLATE></TEMPLATE>' + data.previousHtmlEndSnippet,
previousFragment = this.htmlToFragment(previousHtml), previousFragment = this.htmlToFragment(previousHtml),
followingHtml = data.followingHtmlStartSnippet + data.followingHtml, followingHtml = data.followingHtmlStartSnippet + '<TEMPLATE></TEMPLATE>' + 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 + '<TEMPLATE></TEMPLATE>' + data.previousHtmlEndSnippet,
previousFragment = this.htmlToFragment(previousHtml),
followingHtml = data.followingHtmlStartSnippet + '<TEMPLATE></TEMPLATE>' + data.followingHtml,
followingFragment = this.htmlToFragment(followingHtml), followingFragment = this.htmlToFragment(followingHtml),
newFragment = this.htmlToFragment(newHTML), 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) { var mergedFragment = document.createDocumentFragment();
child = previousFragment.children[0]; while (previousFragment.firstChild) {
previousFragment.removeChild(child); el = previousFragment.firstChild;
merged.appendChild(child); previousFragment.removeChild(el);
mergedFragment.appendChild(el);
} }
while (newFragment.children.length > 0) { while (diffFragment.firstChild) {
child = newFragment.children[0]; el = diffFragment.firstChild;
newFragment.removeChild(child); diffFragment.removeChild(el);
merged.appendChild(child); mergedFragment.appendChild(el);
} }
while (followingFragment.children.length > 0) { while (followingFragment.firstChild) {
child = followingFragment.children[0]; el = followingFragment.firstChild;
followingFragment.removeChild(child); followingFragment.removeChild(el);
merged.appendChild(child); 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);
}; };
}); });

View File

@ -76,6 +76,7 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
* *
* @param node * @param node
* @param length * @param length
* @param highlight
* @returns Array * @returns Array
* @private * @private
*/ */
@ -89,7 +90,7 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
var addLine = function (text, highlight) { var addLine = function (text, highlight) {
var node; var node;
if (typeof highlight === 'undefined') { if (typeof highlight === 'undefined') {
highlight = 0; highlight = -1;
} }
if (firstTextNode) { if (firstTextNode) {
if (highlight == service._currentLineNumber - 1) { if (highlight == service._currentLineNumber - 1) {
@ -344,19 +345,23 @@ angular.module('OpenSlidesApp.motions.lineNumbering', [])
return root.innerHTML; return root.innerHTML;
}; };
this.insertLineNumbersNode = function (html, lineLength, highlight) { this.insertLineNumbersNode = function (html, lineLength, highlight, firstLine) {
var root = document.createElement('div'); var root = document.createElement('div');
root.innerHTML = html; root.innerHTML = html;
this._currentInlineOffset = 0; this._currentInlineOffset = 0;
if (firstLine) {
this._currentLineNumber = firstLine;
} else {
this._currentLineNumber = 1; this._currentLineNumber = 1;
}
this._prependLineNumberToFirstText = true; this._prependLineNumberToFirstText = true;
return this._insertLineNumbersToNode(root, lineLength, highlight); return this._insertLineNumbersToNode(root, lineLength, highlight);
}; };
this.insertLineNumbers = function (html, lineLength, highlight, callback) { this.insertLineNumbers = function (html, lineLength, highlight, callback, firstLine) {
var newRoot = this.insertLineNumbersNode(html, lineLength, highlight); var newRoot = this.insertLineNumbersNode(html, lineLength, highlight, firstLine);
if (callback) { if (callback) {
callback(); callback();

View File

@ -4,6 +4,57 @@
angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions', 'OpenSlidesApp.motions.lineNumbering']) 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', [ .factory('MotionInlineEditing', [
'Editor', 'Editor',
'Motion', 'Motion',
@ -37,29 +88,28 @@ angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions',
obj.tinymceOptions.readonly = 1; obj.tinymceOptions.readonly = 1;
obj.tinymceOptions.setup = function (editor) { obj.tinymceOptions.setup = function (editor) {
obj.editor = editor; obj.editor = editor;
editor.on("init", function () { editor.on('init', function () {
obj.lineBrokenText = motion.getTextWithLineBreaks($scope.version); obj.lineBrokenText = motion.getTextWithLineBreaks($scope.version);
obj.editor.setContent(obj.lineBrokenText); obj.editor.setContent(obj.lineBrokenText);
obj.originalHtml = obj.editor.getContent(); obj.originalHtml = obj.editor.getContent();
obj.changed = false; obj.changed = false;
}); });
editor.on("change", function () { editor.on('change', function () {
obj.changed = (editor.getContent() != obj.originalHtml); obj.changed = (editor.getContent() != obj.originalHtml);
}); });
editor.on("undo", function () { editor.on('undo', function () {
obj.changed = (editor.getContent() != obj.originalHtml); obj.changed = (editor.getContent() != obj.originalHtml);
}); });
}; };
obj.setVersion = function (_motion, versionId) { obj.setVersion = function (_motion, versionId) {
motion = _motion; // If this is not updated, motion = _motion; // If this is not updated,
console.log(versionId, motion.getTextWithLineBreaks(versionId));
obj.lineBrokenText = motion.getTextWithLineBreaks(versionId); obj.lineBrokenText = motion.getTextWithLineBreaks(versionId);
obj.changed = false; obj.changed = false;
obj.active = false; obj.active = false;
if (obj.editor) { if (obj.editor) {
obj.editor.setContent(obj.lineBrokenText); obj.editor.setContent(obj.lineBrokenText);
obj.editor.setMode("readonly"); obj.editor.setMode('readonly');
obj.originalHtml = obj.editor.getContent(); obj.originalHtml = obj.editor.getContent();
} else { } else {
obj.originalHtml = obj.lineBrokenText; obj.originalHtml = obj.lineBrokenText;
@ -67,7 +117,7 @@ angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions',
}; };
obj.enable = function () { obj.enable = function () {
obj.editor.setMode("design"); obj.editor.setMode('design');
obj.active = true; obj.active = true;
obj.changed = false; obj.changed = false;
@ -80,7 +130,7 @@ angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions',
}; };
obj.disable = function () { obj.disable = function () {
obj.editor.setMode("readonly"); obj.editor.setMode('readonly');
obj.active = false; obj.active = false;
obj.changed = false; obj.changed = false;
obj.lineBrokenText = obj.originalHtml; obj.lineBrokenText = obj.originalHtml;
@ -89,10 +139,10 @@ angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions',
obj.save = function () { obj.save = function () {
if (!motion.isAllowed('update')) { 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.disable_versioning = (obj.trivialChange && Config.get('motions_allow_disable_versioning').value);
Motion.inject(motion); 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; return obj;
} }
]); ]);

View File

@ -27,7 +27,8 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf'])
* other project that might want to use this HTML to PDF parser. * other project that might want to use this HTML to PDF parser.
* https://github.com/OpenSlides/OpenSlides/issues/2361 * 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 { } else {
return converter.convertHTML(motion.getText($scope.version), $scope); return converter.convertHTML(motion.getText($scope.version), $scope);
} }

View File

@ -104,6 +104,9 @@ angular.module('OpenSlidesApp.motions.site', [
}, },
tags: function(Tag) { tags: function(Tag) {
return Tag.findAll(); 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) // Service for generic motion form (create and update)
.factory('MotionForm', [ .factory('MotionForm', [
'gettextCatalog', 'gettextCatalog',
@ -818,6 +908,10 @@ angular.module('OpenSlidesApp.motions.site', [
'operator', 'operator',
'ngDialog', 'ngDialog',
'MotionForm', 'MotionForm',
'ChangeRecommmendationCreate',
'ChangeRecommmendationView',
'MotionChangeRecommendation',
'MotionPDFExport',
'Motion', 'Motion',
'Category', 'Category',
'Mediafile', 'Mediafile',
@ -826,24 +920,21 @@ angular.module('OpenSlidesApp.motions.site', [
'Workflow', 'Workflow',
'Config', 'Config',
'motion', 'motion',
'MotionContentProvider',
'PollContentProvider',
'PdfMakeConverter',
'PdfMakeDocumentProvider',
'MotionInlineEditing', 'MotionInlineEditing',
'gettextCatalog', 'gettextCatalog',
'Projector', 'Projector',
'HTMLValidizer',
'ProjectionDefault', 'ProjectionDefault',
function($scope, $http, operator, ngDialog, MotionForm, Motion, Category, Mediafile, Tag, User, Workflow, Config, function($scope, $http, operator, ngDialog, MotionForm,
motion, MotionContentProvider, PollContentProvider, PdfMakeConverter, PdfMakeDocumentProvider, ChangeRecommmendationCreate, ChangeRecommmendationView, MotionChangeRecommendation, MotionPDFExport,
MotionInlineEditing, gettextCatalog, Projector, HTMLValidizer, ProjectionDefault) { Motion, Category, Mediafile, Tag, User, Workflow, Config, motion, MotionInlineEditing, gettextCatalog,
Projector, ProjectionDefault) {
Motion.bindOne(motion.id, $scope, 'motion'); Motion.bindOne(motion.id, $scope, 'motion');
Category.bindAll({}, $scope, 'categories'); Category.bindAll({}, $scope, 'categories');
Mediafile.bindAll({}, $scope, 'mediafiles'); Mediafile.bindAll({}, $scope, 'mediafiles');
Tag.bindAll({}, $scope, 'tags'); Tag.bindAll({}, $scope, 'tags');
User.bindAll({}, $scope, 'users'); User.bindAll({}, $scope, 'users');
Workflow.bindAll({}, $scope, 'workflows'); Workflow.bindAll({}, $scope, 'workflows');
MotionChangeRecommendation.bindAll({'where': {'motion_version_id': {'==': motion.active_version}}}, $scope, 'change_recommendations');
Motion.loadRelations(motion, 'agenda_item'); Motion.loadRelations(motion, 'agenda_item');
$scope.$watch(function () { $scope.$watch(function () {
return Projector.lastModified(); return Projector.lastModified();
@ -853,7 +944,12 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.version = motion.active_version; $scope.version = motion.active_version;
$scope.isCollapsed = true; $scope.isCollapsed = true;
$scope.commentsFields = Config.get('motions_comments').value; $scope.commentsFields = Config.get('motions_comments').value;
$scope.lineNumberMode = Config.get('motions_default_line_numbering').value; $scope.lineNumberMode = Config.get('motions_default_line_numbering').value;
$scope.setLineNumberMode = function(mode) {
$scope.lineNumberMode = mode;
};
if (motion.parent_id) { if (motion.parent_id) {
Motion.bindOne(motion.parent_id, $scope, 'parent'); Motion.bindOne(motion.parent_id, $scope, 'parent');
} }
@ -891,32 +987,6 @@ angular.module('OpenSlidesApp.motions.site', [
setHighlightOnProjector($scope.linesForProjector ? $scope.highlight : 0); 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 // open edit dialog
$scope.openDialog = function (motion) { $scope.openDialog = function (motion) {
if ($scope.inlineEditing.active) { if ($scope.inlineEditing.active) {
@ -987,6 +1057,7 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.showVersion = function (version) { $scope.showVersion = function (version) {
$scope.version = version.id; $scope.version = version.id;
$scope.inlineEditing.setVersion(motion, version.id); $scope.inlineEditing.setVersion(motion, version.id);
$scope.createChangeRecommendation.setVersion(motion, version.id);
}; };
// permit specific version // permit specific version
$scope.permitVersion = function (version) { $scope.permitVersion = function (version) {
@ -1022,6 +1093,59 @@ angular.module('OpenSlidesApp.motions.site', [
// Inline editing functions // Inline editing functions
$scope.inlineEditing = MotionInlineEditing; $scope.inlineEditing = MotionInlineEditing;
$scope.inlineEditing.init($scope, motion); $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();
}
);
};
} }
]) ])

View File

@ -0,0 +1,17 @@
<h1 ng-if="model.id" translate>Edit change recommendation</h1>
<h1 ng-if="!model.id" translate>New change recommendation</h1>
<uib-alert ng-show="alert.show" type="{{ alert.type }}" ng-click="alert={}" close="alert={}">
{{ alert.msg }}
</uib-alert>
<form name="changeRecommendationForm" ng-submit="save(model)">
<formly-form model="model" fields="formFields">
<button type="submit" ng-disabled="changeRecommendation.$invalid" class="btn btn-primary" translate>
Save
</button>
<button ng-click="closeThisDialog()" class="btn btn-default" translate>
Cancel
</button>
</formly-form>
</form>

View File

@ -21,7 +21,7 @@
<i class="fa fa-pencil"></i> <i class="fa fa-pencil"></i>
</a> </a>
<!-- pdf --> <!-- pdf -->
<a ng-click="makePDF()" class="btn btn-default btn-sm"> <a ng-click="pdfExport.createMotion()" class="btn btn-default btn-sm">
<i class="fa fa-file-pdf-o fa-lg"></i> <i class="fa fa-file-pdf-o fa-lg"></i>
<translate>PDF</translate> <translate>PDF</translate>
</a> </a>
@ -182,7 +182,7 @@
</button> </button>
<!-- Print poll PDF --> <!-- Print poll PDF -->
<a os-perms="motions.can_manage" ng-click="makePollPDF()" class="btn btn-default btn-xs" <a os-perms="motions.can_manage" ng-click="pdfExport.createPoll()" class="btn btn-default btn-xs"
title="{{ 'Print ballot paper' | translate }}"> title="{{ 'Print ballot paper' | translate }}">
<i class="fa fa-file-pdf-o"></i> <i class="fa fa-file-pdf-o"></i>
</a> </a>
@ -289,94 +289,48 @@
<div class="details"> <div class="details">
<div class="row"> <div class="row">
<div class="linenumber-toolbar">
<!-- inline editing -->
<div class="pull-right inline-editing-activator"
ng-if="motion.isAllowed('update') && version == motion.getVersion(-1).id">
<button ng-if="!inlineEditing.active" ng-click="inlineEditing.enable()" class="btn btn-sm btn-default">
<i class="fa fa-pencil-square-o"></i>
<translate>Inline editing</translate>
</button>
<button ng-if="inlineEditing.active" ng-click="inlineEditing.disable()" class="btn btn-sm btn-default">
<i class="fa fa-times-circle"></i>
<translate>Inline editing</translate>
</button>
</div>
<!-- line number mode --> <!-- Motion toolbar -->
<div class="line-number-setter {{ lineNumberMode }}"> <ng-include src="'static/templates/motions/motion-detail/toolbar.html'"></ng-include>
<span class="btn-group" data-toggle="buttons">
<div class="btn btn-sm btn-default disabled">
<i class="fa fa-list-ol" aria-hidden="true"></i>
<translate>Line numbering</translate>:
</div>
<label class="btn btn-sm btn-default" ng-class="{active: (lineNumberMode == 'none')}"
ng-click="lineNumberMode = 'none';">
<input type="radio" name="lineNumberMode" value="none" ng-model="lineNumberMode"
ng-checked="lineNumberMode == 'none'">
<translate>none</translate>
</label>
<label class="btn btn-sm btn-default" ng-class="{active: (lineNumberMode == 'inline')}"
ng-click="lineNumberMode = 'inline'">
<input type="radio" name="lineNumberMode" value="inline" ng-model="lineNumberMode"
ng-checked="lineNumberMode == 'inline'">
<translate>inline</translate>
</label>
<label class="btn btn-sm btn-default" ng-class="{active: (lineNumberMode == 'outside')}"
ng-click="lineNumberMode = 'outside'">
<input type="radio" name="lineNumberMode" value="outside" ng-model="lineNumberMode"
ng-checked="lineNumberMode == 'outside'">
<translate>outside</translate>
</label>
</span>
<!-- go to line number -->
<span>
<form class="input-group" style="max-width: 220px;" ng-if="lineNumberMode != 'none'" ng-submit="scrollToAndHighlight(gotoLinenumber)">
<input type="number" class="form-control input-sm" ng-model="gotoLinenumber" placeholder="{{ 'Line' | translate }}"></input>
<span class="input-group-btn">
<button type="button" class="btn btn-sm btn-default btn-slim" ng-show="gotoLinenumber"
ng-click="gotoLinenumber = ''; scrollToAndHighlight(0);">
<i class="fa fa-times text-danger"></i>
</button>
<button type="submit" class="btn btn-sm btn-default">
<i class="fa fa-share"></i>
<translate>go</translate>
</button>
<button type="button" class="btn btn-sm btn-default" os-perms="core.can_manage_projector"
ng-show="lineNumberMode != 'none' && motion.isProjected()" ng-click="toggleLinesForProjector()"
uib-tooltip="{{ 'Show highlighted line also on projector.' | translate }}">
<i class="fa" ng-class="linesForProjector ? 'fa-check-square-o' : 'fa-square-o'"></i>&nbsp;
<i class="fa fa-video-camera"></i>
</button>
</span>
</form>
</span>
</div>
</div>
<div ng-class="{'col-sm-8': (lineNumberMode != 'outside'), 'col-sm-12': (lineNumberMode == 'outside')}"> <div ng-class="{'col-sm-8': (lineNumberMode != 'outside'), 'col-sm-12': (lineNumberMode == 'outside')}">
<div class="motion-text-holder">
<div ng-if="motion.isAllowed('update') && version == motion.getVersion(-1).id"> <!-- Original view -->
<div ng-show="inlineEditing.active"> <ng-include src="'static/templates/motions/motion-detail/view-original.html'"></ng-include>
<div ui-tinymce="inlineEditing.tinymceOptions" ng-model="inlineEditing.lineBrokenText"
class="motion-text line-numbers-{{ lineNumberMode }}"></div>
</div>
<div ng-show="!inlineEditing.active" ng-bind-html="motion.getTextWithLineBreaks(version, highlight) | trusted"
class="motion-text line-numbers-{{ lineNumberMode }}"></div>
<div class="motion-save-toolbar" ng-class="{ 'visible': (inlineEditing.changed && inlineEditing.active) }"> <!-- Diff View -->
<div class="changed-hint" translate>The text has been changed.</div> <ng-include src="'static/templates/motions/motion-detail/view-diff.html'"></ng-include>
<button type="button" ng-click="inlineEditing.save()" class="btn btn-primary">Save</button>
<label ng-if="motion.state.versioning && config('motions_allow_disable_versioning')"> <!-- Changed View -->
<input type="checkbox" ng-model="inlineEditing.trivialChange" value="1"> <div ng-if="viewChangeRecommendations.mode == 'changed'">
<span translate>Trivial change</span> <div ng-bind-html="motion.getTextWithChangeRecommendations(version) | trusted"
</label> class="motion-text motion-text-changed line-numbers-{{ lineNumberMode }}"></div>
<div style="text-align: right;" ng-if="motion.state.versioning && (change_recommendations | filter:{motion_version_id:version}:true).length > 0">
<button class="btn btn-default"
ng-bootbox-confirm="{{ 'Do you want to create a new version of this motion based on this changes?' | translate }}"
ng-bootbox-confirm-action="viewChangeRecommendations.newVersionIncludingChanges(motion, version, true)">
<i class="fa fa-file-text"></i>
<translate>New version on these changes</translate>
</button>
</div>
</div>
<!-- Agreed View -->
<div ng-if="viewChangeRecommendations.mode == 'agreed'">
<div ng-bind-html="motion.getTextWithAcceptedChangeRecommendations(version) | trusted"
class="motion-text motion-text-changed line-numbers-{{ lineNumberMode }}"></div>
<div style="text-align: right;" ng-if="motion.state.versioning && (change_recommendations | filter:{motion_version_id:version}:true).length > 0">
<button class="btn btn-default"
ng-bootbox-confirm="{{ 'Do you want to create a new version of this motion based on this changes?' | translate }}"
ng-bootbox-confirm-action="viewChangeRecommendations.newVersionIncludingChanges(motion, version, false)">
<i class="fa fa-file-text"></i>
<translate>New version on these changes</translate>
</button>
</div> </div>
</div> </div>
<div ng-if="!(motion.isAllowed('update') && version == motion.getVersion(-1).id)">
<div ng-bind-html="motion.getTextWithLineBreaks(version, highlight) | trusted"
class="motion-text line-numbers-{{ lineNumberMode }}"></div>
</div> </div>
<!-- reason --> <!-- reason -->

View File

@ -0,0 +1,30 @@
<!-- A summary of all changes -->
<section class="change-recommendation-overview">
<h2>
<translate>Change recommendation summary</translate>
</h2>
<ul ng-if="change_recommendations.length > 0">
<li ng-repeat="change in (changes = (change_recommendations | filter:{motion_version_id:version}:true | orderBy: 'line_from')) "
ng-click="viewChangeRecommendations.scrollToDiffBox(change.id)">
<span ng-if="change.line_from >= change.line_to - 1" class="line-number">
Line {{ change.line_from }}:
</span>
<span ng-if="change.line_from < change.line_to - 1" class="line-number">
Line {{ change.line_from }} - {{ change.line_to - 1 }}:
</span>
<span class="operation">
<translate ng-if="change.getType(motion.getVersion(version).text) == 0">Replacement</translate>
<translate ng-if="change.getType(motion.getVersion(version).text) == 1">Insertion</translate>
<translate ng-if="change.getType(motion.getVersion(version).text) == 2">Deletion</translate>
</span>
<span class="status">
<translate ng-if="change.status == 0">Suggested</translate>
<translate ng-if="change.status == 1">Accepted</translate>
<translate ng-if="change.status == 2">Rejected</translate>
</span>
</li>
</ul>
<div ng-if="change_recommendations.length == 0" class="no-changes">
<translate>No change recommendations yet</translate>
</div>
</section>

View File

@ -0,0 +1,116 @@
<div class="motion-toolbar">
<!-- inline editing -->
<div class="pull-right inline-editing-activator"
ng-if="motion.isAllowed('update') && version == motion.getVersion(-1).id && viewChangeRecommendations.mode == 'original'">
<button ng-if="!inlineEditing.active && change_recommendations.length == 0" ng-click="inlineEditing.enable()"
class="btn btn-sm btn-default">
<i class="fa fa-pencil-square-o"></i>
<translate>Inline editing</translate>
</button>
<button ng-if="inlineEditing.active && change_recommendations.length == 0" ng-click="inlineEditing.disable()"
class="btn btn-sm btn-default">
<i class="fa fa-times-circle"></i>
<translate>Inline editing</translate>
</button>
<button ng-if="change_recommendations.length > 0" class="btn btn-sm btn-default" disabled
title="{{ 'Editing the text is not possible anymore once there are change recommendations.' | translate }}">
<i class="fa fa-pencil-square-o"></i>
<translate>Inline editing</translate>
</button>
</div>
<div class="toolbar-left {{ lineNumberMode }}">
<!-- line number mode -->
<div class="btn-group" data-toggle="buttons">
<span class="btn btn-sm btn-default disabled">
<i class="fa fa-list-ol" aria-hidden="true"></i>
<translate>Line numbering</translate>:
</span>
<label class="btn btn-sm btn-default" ng-class="{active: (lineNumberMode == 'none')}"
ng-click="setLineNumberMode('none')">
<input type="radio" name="lineNumberMode" value="none" ng-model="lineNumberMode"
ng-checked="lineNumberMode == 'none'">
<translate>none</translate>
</label>
<label class="btn btn-sm btn-default" ng-class="{active: (lineNumberMode == 'inline')}"
ng-click="setLineNumberMode('inline')">
<input type="radio" name="lineNumberMode" value="inline" ng-model="lineNumberMode"
ng-checked="lineNumberMode == 'inline'">
<translate>inline</translate>
</label>
<label class="btn btn-sm btn-default" ng-class="{active: (lineNumberMode == 'outside')}"
ng-click="setLineNumberMode('outside')">
<input type="radio" name="lineNumberMode" value="outside" ng-model="lineNumberMode"
ng-checked="lineNumberMode == 'outside'">
<translate>outside</translate>
</label>
</div>
<!-- go to line number -->
<div class="goto-line-number">
<form class="input-group" ng-if="viewChangeRecommendations.mode == 'original' && lineNumberMode != 'none'"
ng-submit="scrollToAndHighlight(gotoLinenumber)">
<input type="number" class="form-control input-sm" ng-model="gotoLinenumber"
placeholder="{{ 'Line' | translate }}"/>
<div class="input-group-btn">
<button type="button" class="btn btn-sm btn-default btn-slim" ng-show="gotoLinenumber"
ng-click="gotoLinenumber = ''; scrollToAndHighlight(0);">
<i class="fa fa-times text-danger"></i>
</button>
<button type="submit" class="btn btn-sm btn-default">
<i class="fa fa-share"></i>
<translate>go</translate>
</button>
<button type="button" class="btn btn-sm btn-default" os-perms="core.can_manage_projector"
ng-show="lineNumberMode != 'none' && motion.isProjected()"
ng-click="toggleLinesForProjector()"
uib-tooltip="{{ 'Show highlighted line also on projector.' | translate }}">
<i class="fa" ng-class="linesForProjector ? 'fa-check-square-o' : 'fa-square-o'"></i>&nbsp;
<i class="fa fa-video-camera"></i>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- View Modes (Original, Diff, Changed) -->
<div class="motion-toolbar" ng-if="change_recommendations.length > 0">
<div class="toolbar-left">
<div class="btn-group" data-toggle="buttons">
<span class="btn btn-sm btn-default disabled">
<i class="fa fa-edit" aria-hidden="true"></i>
<translate>Change recommendations</translate>:
</span>
<label class="btn btn-sm btn-default" ng-class="{active: (viewChangeRecommendations.mode == 'original')}"
ng-click="viewChangeRecommendations.mode = 'original';">
<input type="radio" name="viewChangeRecommendations.mode" value="none"
ng-model="viewChangeRecommendations.mode"
ng-checked="viewChangeRecommendations.mode == 'original'">
<translate>Original</translate>
</label>
<label class="btn btn-sm btn-default" ng-class="{active: (viewChangeRecommendations.mode == 'changed')}"
ng-click="viewChangeRecommendations.mode = 'changed'">
<input type="radio" name="viewChangeRecommendations.mode" value="changed"
ng-model="viewChangeRecommendations.mode"
ng-checked="viewChangeRecommendations.mode == 'changed'">
<translate>Changed</translate>
</label>
<label class="btn btn-sm btn-default" ng-class="{active: (viewChangeRecommendations.mode == 'diff')}"
ng-click="viewChangeRecommendations.mode = 'diff'">
<input type="radio" name="viewChangeRecommendations.mode" value="diff" ng-model="lineNumberMode"
ng-checked="viewChangeRecommendations.mode == 'diff'">
<translate>Diff</translate>
</label>
<label class="btn btn-sm btn-default" ng-class="{active: (viewChangeRecommendations.mode == 'agreed')}"
ng-click="viewChangeRecommendations.mode = 'agreed'">
<input type="radio" name="viewChangeRecommendations.mode" value="agreed"
ng-model="viewChangeRecommendations.mode"
ng-checked="viewChangeRecommendations.mode == 'agreed'">
<translate>Agreed</translate>
</label>
</div>
</div>
</div>

View File

@ -0,0 +1,54 @@
<div ng-if="viewChangeRecommendations.mode == 'diff'">
<ng-include src="'static/templates/motions/motion-detail/change-summary.html'"></ng-include>
<!-- The actual diff view -->
<div class="motion-text-with-diffs line-numbers-{{ lineNumberMode }}">
<div ng-repeat="change in (changes = (change_recommendations | filter:{motion_version_id:version}:true | orderBy: 'line_from')) ">
<div class="motion-text original-text line-numbers-{{ lineNumberMode }}"
ng-bind-html="motion.getTextBetweenChangeRecommendations(version, changes[$index - 1], change) | trusted"></div>
<div class="diff-box diff-box-{{ change.id }} clearfix">
<div class="action-row" ng-if="motion.isAllowed('can_manage')">
<div class="btn-group" data-toggle="buttons">
<label class="btn btn-sm btn-default" ng-class="{active: (change.status == 0)}"
title="{{ 'Suggested' | translate }}" ng-click="change.status = 0; change.saveStatus();">
<input type="radio" name="changeRecommendationStatus[{{ change.id }}]" value="0"
ng-change="change.saveStatus()" ng-model="change.status" ng-checked="change.status == 0">
<i class="fa fa-question"></i>
</label>
<label class="btn btn-sm btn-default" ng-class="{active: (change.status == 1)}"
title="{{ 'Accepted' | translate }}" ng-click="change.status = 1; change.saveStatus();">
<input type="radio" name="changeRecommendationStatus[{{ change.id }}]" value="1"
ng-change="change.saveStatus()" ng-model="change.status" ng-checked="change.status == 1">
<i class="fa fa-thumbs-up"></i>
</label>
<label class="btn btn-sm btn-default" ng-class="{active: (change.status == 2)}"
title="{{ 'Rejected' | translate }}" ng-click="change.status = 2; change.saveStatus();">
<input type="radio" name="changeRecommendationStatus[{{ change.id }}]" value="2"
ng-change="change.saveStatus()" ng-model="change.status" ng-checked="change.status == 2">
<i class="fa fa-thumbs-down"></i>
</label>
</div>
<button class="btn btn-default btn-sm pull-right btn-delete"
ng-bootbox-confirm="{{ 'Are you sure you want to delete this change recommendation?' | translate }}"
ng-bootbox-confirm-action="viewChangeRecommendations.delete(change.id)"
title="{{ 'Delete' | translate }}">
<i class="fa fa-trash"></i>
</button>
</div>
<div class="status-row" ng-if="!motion.isAllowed('can_manage')">
<span ng-if="change.status == 0"><translate>Suggested</translate></span>
<span ng-if="change.status == 1"><translate>Accepted</translate></span>
<span ng-if="change.status == 2"><translate>Rejected</translate></span>
</div>
<div class="motion-text motion-text-diff line-numbers-{{ lineNumberMode }}"
ng-bind-html="change.format(motion, version) | trusted"></div>
</div>
</div>
<div class="motion-text original-text line-numbers-{{ lineNumberMode }}"
ng-bind-html="motion.getTextRemainderAfterLastChangeRecommendation(version, changes) | trusted"></div>
</div>
</div>

View File

@ -0,0 +1,46 @@
<!-- Original view, Inline Editing is possible -->
<div ng-if="viewChangeRecommendations.mode == 'original' && motion.isAllowed('update') && version == motion.getVersion(-1).id">
<div ng-show="inlineEditing.active">
<div ui-tinymce="inlineEditing.tinymceOptions" ng-model="inlineEditing.lineBrokenText"
class="motion-text line-numbers-{{ lineNumberMode }}"></div>
</div>
<div ng-show="!inlineEditing.active" ng-bind-html="motion.getTextWithLineBreaks(version, highlight) | trusted"
class="motion-text motion-text-original line-numbers-{{ lineNumberMode }}"></div>
<div class="motion-save-toolbar" ng-class="{ 'visible': (inlineEditing.changed && inlineEditing.active) }">
<div class="changed-hint" translate>The text has been changed.</div>
<button type="button" ng-click="inlineEditing.save()" class="btn btn-primary">Save</button>
<label ng-if="motion.state.versioning && config('motions_allow_disable_versioning')">
<input type="checkbox" ng-model="inlineEditing.trivialChange" value="1">
<span translate>Trivial change</span>
</label>
</div>
</div>
<!-- Original view, Inline Editing is NOT possible -->
<div ng-if="viewChangeRecommendations.mode == 'original' && !(motion.isAllowed('update') && version == motion.getVersion(-1).id)">
<div ng-bind-html="motion.getTextWithLineBreaks(version, highlight) | trusted"
class="motion-text motion-text-original line-numbers-{{ lineNumberMode }}"></div>
</div>
<!-- Original view, Change list -->
<ul ng-if="viewChangeRecommendations.mode == 'original'" ng-show="lineNumberMode != 'none'"
class="change-recommendation-list">
<li ng-repeat="change in change_recommendations | filter:{motion_version_id:version}:true"
ng-class="['replace', 'insert', 'delete'][change.getType(motion.getVersion(version).text)]"
ng-click="viewChangeRecommendations.scrollToDiffBox(change.id)"
data-line-from="{{ change.line_from }}" data-line-to="{{ change.line_to}}"
title="{{ change.getTitle(motion.getVersion(version).text) }}">
<div class="tooltip">
<div class="text" ng-bind-html="change.text || trusted"></div>
<!-- delete change recommendation -->
<button class="btn btn-default btn-xs"
ng-bootbox-confirm="{{ 'Are you sure you want to delete this change recommendation?' | translate }}"
ng-bootbox-confirm-action="viewChangeRecommendations.delete(change.id)"
title="{{ 'Delete' | translate }}">
<i class="fa fa-times"></i>
</button>
</div>
</li>
</ul>

View File

@ -24,12 +24,14 @@ from openslides.utils.views import APIView, PDFView, SingleObjectMixin
from .access_permissions import ( from .access_permissions import (
CategoryAccessPermissions, CategoryAccessPermissions,
MotionAccessPermissions, MotionAccessPermissions,
MotionChangeRecommendationAccessPermissions,
WorkflowAccessPermissions, WorkflowAccessPermissions,
) )
from .exceptions import WorkflowError from .exceptions import WorkflowError
from .models import ( from .models import (
Category, Category,
Motion, Motion,
MotionChangeRecommendation,
MotionPoll, MotionPoll,
MotionVersion, MotionVersion,
State, State,
@ -341,6 +343,31 @@ class MotionPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet):
self.request.user.has_perm('motions.can_manage')) self.request.user.has_perm('motions.can_manage'))
class MotionChangeRecommendationViewSet(ModelViewSet):
"""
API endpoint for motion change recommendations.
There are the following views: metadata, list, retrieve, create,
partial_update, update and destroy.
"""
access_permissions = MotionChangeRecommendationAccessPermissions()
queryset = MotionChangeRecommendation.objects.all()
def check_view_permissions(self):
"""
Returns True if the user has required permissions.
"""
if self.action in ('list', 'retrieve'):
result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action == 'metadata':
result = self.request.user.has_perm('motions.can_see')
elif self.action in ('create', 'destroy', 'partial_update', 'update'):
result = self.request.user.has_perm('motions.can_manage')
else:
result = False
return result
class CategoryViewSet(ModelViewSet): class CategoryViewSet(ModelViewSet):
""" """
API endpoint for categories. API endpoint for categories.

View File

@ -2,7 +2,7 @@ describe('linenumbering', function () {
beforeEach(module('OpenSlidesApp.motions.diff')); beforeEach(module('OpenSlidesApp.motions.diff'));
var diffService, baseHtmlDom1, baseHtmlDom2, var diffService, baseHtmlDom1, baseHtmlDom2, baseHtmlDom3,
brMarkup = function (no) { brMarkup = function (no) {
return '<br class="os-line-break">' + return '<br class="os-line-break">' +
'<span class="os-line-number line-number-' + no + '" data-line-number="' + no + '" contenteditable="false">&nbsp;</span>'; '<span class="os-line-number line-number-' + no + '" data-line-number="' + no + '" contenteditable="false">&nbsp;</span>';
@ -15,7 +15,7 @@ describe('linenumbering', function () {
diffService = _diffService_; diffService = _diffService_;
baseHtmlDom1 = diffService.htmlToFragment('<p>' + baseHtmlDom1 = diffService.htmlToFragment('<p>' +
noMarkup(1) + 'Line 1 ' + brMarkup(2) + 'Line 2' + noMarkup(1) + 'Line 1 ' + brMarkup(2) + 'Line 2 ' +
brMarkup(3) + 'Line <strong>3<br>' + noMarkup(4) + 'Line 4 ' + brMarkup(5) + 'Line</strong> 5</p>' + brMarkup(3) + 'Line <strong>3<br>' + noMarkup(4) + 'Line 4 ' + brMarkup(5) + 'Line</strong> 5</p>' +
'<ul class="ul-class">' + '<ul class="ul-class">' +
'<li class="li-class">' + noMarkup(6) + 'Line 6 ' + brMarkup(7) + 'Line 7' + '</li>' + '<li class="li-class">' + noMarkup(6) + 'Line 6 ' + brMarkup(7) + 'Line 7' + '</li>' +
@ -26,26 +26,36 @@ describe('linenumbering', function () {
'</ul>' + '</ul>' +
'<p>' + noMarkup(10) + 'Line 10 ' + brMarkup(11) + 'Line 11</p>'); '<p>' + noMarkup(10) + 'Line 10 ' + brMarkup(11) + 'Line 11</p>');
baseHtmlDom2 = diffService.htmlToFragment('<p><span class="os-line-number line-number-1" data-line-number="1" contenteditable="false">&nbsp;</span>Single text line</p>\ baseHtmlDom2 = diffService.htmlToFragment('<p>' + noMarkup(1) + 'Single text line</p>\
<p><span class="os-line-number line-number-2" data-line-number="2" contenteditable="false">&nbsp;</span>sdfsdfsdfsdf dsfsdfsdfdsflkewjrl ksjfl ksdjf&nbsp;klnlkjBavaria ipsum dolor sit amet Biazelt Auffisteign <br class="os-line-break"><span class="os-line-number line-number-3" data-line-number="3" contenteditable="false">&nbsp;</span>Schorsch mim Radl foahn Ohrwaschl Steckerleis wann griagd ma nacha wos zdringa glacht Mamalad, <br class="os-line-break">' + <p>' + noMarkup(2) + 'sdfsdfsdfsdf dsfsdfsdfdsflkewjrl ksjfl ksdjf&nbsp;klnlkjBavaria ipsum dolor sit amet Biazelt Auffisteign ' + brMarkup(3) + 'Schorsch mim Radl foahn Ohrwaschl Steckerleis wann griagd ma nacha wos zdringa glacht Mamalad, ' +
'<span class="os-line-number line-number-4" data-line-number="4" contenteditable="false">&nbsp;</span>muass? I bin a woschechta Bayer sowos oamoi und sei und glei wirds no fui lustiga: Jo mei khkhis des <br class="os-line-break"><span class="os-line-number line-number-5" data-line-number="5" contenteditable="false">&nbsp;</span>schee middn ognudelt, Trachtnhuat Biawambn gscheid: Griasd eich midnand etza nix Gwiass woass ma ned <br class="os-line-break">' + brMarkup(4) + 'muass? I bin a woschechta Bayer sowos oamoi und sei und glei wirds no fui lustiga: Jo mei khkhis des ' + brMarkup(5) + 'schee middn ognudelt, Trachtnhuat Biawambn gscheid: Griasd eich midnand etza nix Gwiass woass ma ned ' +
'<span class="os-line-number line-number-6" data-line-number="6" contenteditable="false">&nbsp;</span>owe. Dahoam gscheckate middn Spuiratz des is a gmahde Wiesn. Des is schee so Obazda san da, Haferl <br class="os-line-break"><span class="os-line-number line-number-7" data-line-number="7" contenteditable="false">&nbsp;</span>pfenningguat schoo griasd eich midnand.</p>\ brMarkup(6) + 'owe. Dahoam gscheckate middn Spuiratz des is a gmahde Wiesn. Des is schee so Obazda san da, Haferl ' + brMarkup(7) + 'pfenningguat schoo griasd eich midnand.</p>\
<ul>\ <ul>\
<li><span class="os-line-number line-number-8" data-line-number="8" contenteditable="false">&nbsp;</span>Auffi Gamsbart nimma de Sepp Ledahosn Ohrwaschl um Godds wujn Wiesn Deandlgwand Mongdratzal! Jo <br class="os-line-break"><span class="os-line-number line-number-9" data-line-number="9" contenteditable="false">&nbsp;</span>leck mi Mamalad i daad mechad?</li>\ <li>' + noMarkup(8) + 'Auffi Gamsbart nimma de Sepp Ledahosn Ohrwaschl um Godds wujn Wiesn Deandlgwand Mongdratzal! Jo ' + brMarkup(9) + 'leck mi Mamalad i daad mechad?</li>\
<li><span class="os-line-number line-number-10" data-line-number="10" contenteditable="false">&nbsp;</span>Do nackata Wurscht i hob di narrisch gean, Diandldrahn Deandlgwand vui huift vui woaß?</li>\ <li>' + noMarkup(10) + 'Do nackata Wurscht i hob di narrisch gean, Diandldrahn Deandlgwand vui huift vui woaß?</li>\
<li><span class="os-line-number line-number-11" data-line-number="11" contenteditable="false">&nbsp;</span>Ned Mamalad auffi i bin a woschechta Bayer greaßt eich nachad, umananda gwiss nia need <br class="os-line-break"><span class="os-line-number line-number-12" data-line-number="12" contenteditable="false">&nbsp;</span>Weiznglasl.</li>\ <li>' + noMarkup(11) + 'Ned Mamalad auffi i bin a woschechta Bayer greaßt eich nachad, umananda gwiss nia need ' + brMarkup(12) + 'Weiznglasl.</li>\
<li><span class="os-line-number line-number-13" data-line-number="13" contenteditable="false">&nbsp;</span>Woibbadinga noch da Giasinga Heiwog Biazelt mechad mim Spuiratz, soi zwoa.</li>\ <li>' + noMarkup(13) + 'Woibbadinga noch da Giasinga Heiwog Biazelt mechad mim Spuiratz, soi zwoa.</li>\
</ul>\ </ul>\
<p><span class="os-line-number line-number-14" data-line-number="14" contenteditable="false">&nbsp;</span>I waar soweid Blosmusi es nomoi. Broadwurschtbudn des is a gmahde Wiesn Kirwa mogsd a Bussal <br class="os-line-break"><span class="os-line-number line-number-15" data-line-number="15" contenteditable="false">&nbsp;</span>Guglhupf schüds nei. Luja i moan oiwei Baamwach Watschnbaam, wiavui baddscher! Biakriagal a fescha <br class="os-line-break">' + <p>' + noMarkup(14) + 'I waar soweid Blosmusi es nomoi. Broadwurschtbudn des is a gmahde Wiesn Kirwa mogsd a Bussal ' + brMarkup(15) + 'Guglhupf schüds nei. Luja i moan oiwei Baamwach Watschnbaam, wiavui baddscher! Biakriagal a fescha ' +
'<span class="os-line-number line-number-16" data-line-number="16" contenteditable="false">&nbsp;</span>1Bua Semmlkneedl iabaroi oba um Godds wujn Ledahosn wui Greichats. Geh um Godds wujn luja heid <br class="os-line-break"><span class="os-line-number line-number-17" data-line-number="17" contenteditable="false">&nbsp;</span>greaßt eich nachad woaß Breihaus eam! De om aufn Gipfe auf gehds beim Schichtl mehra Baamwach a <br class="os-line-break"><span class="os-line-number line-number-18" data-line-number="18" contenteditable="false">&nbsp;</span>bissal wos gehd ollaweil gscheid:</p>\ brMarkup(16) + '1Bua Semmlkneedl iabaroi oba um Godds wujn Ledahosn wui Greichats. Geh um Godds wujn luja heid ' + brMarkup(17) + 'greaßt eich nachad woaß Breihaus eam! De om aufn Gipfe auf gehds beim Schichtl mehra Baamwach a ' + brMarkup(18) + 'bissal wos gehd ollaweil gscheid:</p>\
<blockquote>\ <blockquote>\
<p><span class="os-line-number line-number-19" data-line-number="19" contenteditable="false">&nbsp;</span>Scheans Schdarmbeaga See i hob di narrisch gean i jo mei is des schee! Nia eam <br class="os-line-break"><span class="os-line-number line-number-20" data-line-number="20" contenteditable="false">&nbsp;</span>hod vasteh i sog ja nix, i red ja bloß sammawiedaguad, umma eana obandeln! Zwoa <br class="os-line-break"><span class="os-line-number line-number-21" data-line-number="21" contenteditable="false">&nbsp;</span>jo mei scheans amoi, san und hoggd Milli barfuaßat gscheit. Foidweg vui huift <br class="os-line-break">' + <p>' + noMarkup(19) + 'Scheans Schdarmbeaga See i hob di narrisch gean i jo mei is des schee! Nia eam ' + brMarkup(20) + 'hod vasteh i sog ja nix, i red ja bloß sammawiedaguad, umma eana obandeln! Zwoa ' + brMarkup(21) + 'jo mei scheans amoi, san und hoggd Milli barfuaßat gscheit. Foidweg vui huift ' +
'<span class="os-line-number line-number-22" data-line-number="22" contenteditable="false">&nbsp;</span>vui singan, mehra Biakriagal om aufn Gipfe! Ozapfa sodala Charivari greaßt eich <br class="os-line-break"><span class="os-line-number line-number-23" data-line-number="23" contenteditable="false">&nbsp;</span>nachad Broadwurschtbudn do middn liberalitas Bavariae sowos Leonhardifahrt:</p>\ brMarkup(22) + 'vui singan, mehra Biakriagal om aufn Gipfe! Ozapfa sodala Charivari greaßt eich ' + brMarkup(23) + 'nachad Broadwurschtbudn do middn liberalitas Bavariae sowos Leonhardifahrt:</p>\
</blockquote>\ </blockquote>\
<p><span class="os-line-number line-number-24" data-line-number="24" contenteditable="false">&nbsp;</span>Wui helfgod Wiesn, ognudelt schaugn: Dahoam gelbe Rüam Schneid singan wo hi sauba i moan scho aa no <br class="os-line-break"><span class="os-line-number line-number-25" data-line-number="25" contenteditable="false">&nbsp;</span>a Maß a Maß und no a Maß nimma. Is umananda a ganze Hoiwe zwoa, Schneid. Vui huift vui Brodzeid kumm <br class="os-line-break">' + <p>' + noMarkup(24) + 'Wui helfgod Wiesn, ognudelt schaugn: Dahoam gelbe Rüam Schneid singan wo hi sauba i moan scho aa no ' + brMarkup(25) + 'a Maß a Maß und no a Maß nimma. Is umananda a ganze Hoiwe zwoa, Schneid. Vui huift vui Brodzeid kumm ' +
'<span class="os-line-number line-number-26" data-line-number="26" contenteditable="false">&nbsp;</span>geh naa i daad vo de allerweil, gor. Woaß wia Gams, damischa. A ganze Hoiwe Ohrwaschl Greichats <br class="os-line-break"><span class="os-line-number line-number-27" data-line-number="27" contenteditable="false">&nbsp;</span>iabaroi Prosd Engelgwand nix Reiwadatschi.Weibaleid ognudelt Ledahosn noch da Giasinga Heiwog i daad <br class="os-line-break">' + brMarkup(26) + 'geh naa i daad vo de allerweil, gor. Woaß wia Gams, damischa. A ganze Hoiwe Ohrwaschl Greichats ' + brMarkup(27) + 'iabaroi Prosd Engelgwand nix Reiwadatschi.Weibaleid ognudelt Ledahosn noch da Giasinga Heiwog i daad ' +
'<span class="os-line-number line-number-28" data-line-number="28" contenteditable="false">&nbsp;</span>Almrausch, Ewig und drei Dog nackata wea ko, dea ko. Meidromml Graudwiggal nois dei, nackata. No <br class="os-line-break"><span class="os-line-number line-number-29" data-line-number="29" contenteditable="false">&nbsp;</span>Diandldrahn nix Gwiass woass ma ned hod boarischer: Samma sammawiedaguad wos, i hoam Brodzeid. Jo <br class="os-line-break">' + brMarkup(28) + 'Almrausch, Ewig und drei Dog nackata wea ko, dea ko. Meidromml Graudwiggal nois dei, nackata. No ' + brMarkup(29) + 'Diandldrahn nix Gwiass woass ma ned hod boarischer: Samma sammawiedaguad wos, i hoam Brodzeid. Jo ' +
'<span class="os-line-number line-number-30" data-line-number="30" contenteditable="false">&nbsp;</span>mei Sepp Gaudi, is ma Wuascht do Hendl Xaver Prosd eana an a bravs. Sauwedda an Brezn, abfieseln.</p>'); brMarkup(30) + 'mei Sepp Gaudi, is ma Wuascht do Hendl Xaver Prosd eana an a bravs. Sauwedda an Brezn, abfieseln.</p>');
baseHtmlDom3 = diffService.htmlToFragment('<ol>' +
'<li>' + noMarkup(1) + 'Line 1</li>' +
'<li>' + noMarkup(2) + 'Line 2</li>' +
'<li><ol>' +
'<li>' + noMarkup(3) + 'Line 3.1</li>' +
'<li>' + noMarkup(4) + 'Line 3.2</li>' +
'<li>' + noMarkup(5) + 'Line 3.3</li>' +
'</ol></li>' +
'<li>' + noMarkup(6) + ' Line 4</li></ol>');
diffService._insertInternalLineMarkers(baseHtmlDom1); diffService._insertInternalLineMarkers(baseHtmlDom1);
diffService._insertInternalLineMarkers(baseHtmlDom2); diffService._insertInternalLineMarkers(baseHtmlDom2);
@ -131,7 +141,7 @@ describe('linenumbering', function () {
}); });
it('extracts lines from a more complex example', function () { it('extracts lines from a more complex example', function () {
var diff = diffService.extractRangeByLineNumbers(baseHtmlDom2, 6, 11, true); var diff = diffService.extractRangeByLineNumbers(baseHtmlDom2, 6, 11);
expect(diff.html).toBe('owe. Dahoam gscheckate middn Spuiratz des is a gmahde Wiesn. Des is schee so Obazda san da, Haferl pfenningguat schoo griasd eich midnand.</P><UL><LI>Auffi Gamsbart nimma de Sepp Ledahosn Ohrwaschl um Godds wujn Wiesn Deandlgwand Mongdratzal! Jo leck mi Mamalad i daad mechad?</LI><LI>Do nackata Wurscht i hob di narrisch gean, Diandldrahn Deandlgwand vui huift vui woaß?</LI>'); expect(diff.html).toBe('owe. Dahoam gscheckate middn Spuiratz des is a gmahde Wiesn. Des is schee so Obazda san da, Haferl pfenningguat schoo griasd eich midnand.</P><UL><LI>Auffi Gamsbart nimma de Sepp Ledahosn Ohrwaschl um Godds wujn Wiesn Deandlgwand Mongdratzal! Jo leck mi Mamalad i daad mechad?</LI><LI>Do nackata Wurscht i hob di narrisch gean, Diandldrahn Deandlgwand vui huift vui woaß?</LI>');
expect(diff.ancestor.nodeName).toBe('#document-fragment'); expect(diff.ancestor.nodeName).toBe('#document-fragment');
@ -143,20 +153,135 @@ describe('linenumbering', function () {
expect(diff.followingHtmlStartSnippet).toBe('<UL>'); expect(diff.followingHtmlStartSnippet).toBe('<UL>');
}); });
it('extracts the end of a section', function () {
var diff = diffService.extractRangeByLineNumbers(baseHtmlDom2, 29, null);
expect(diff.html).toBe('Diandldrahn nix Gwiass woass ma ned hod boarischer: Samma sammawiedaguad wos, i hoam Brodzeid. Jo mei Sepp Gaudi, is ma Wuascht do Hendl Xaver Prosd eana an a bravs. Sauwedda an Brezn, abfieseln.</P>');
expect(diff.ancestor.nodeName).toBe('#document-fragment');
expect(diff.outerContextStart).toBe('');
expect(diff.outerContextEnd).toBe('');
expect(diff.innerContextStart).toBe('<P>');
expect(diff.innerContextEnd).toBe('');
expect(diff.previousHtmlEndSnippet).toBe('</P>');
expect(diff.followingHtml).toBe('');
expect(diff.followingHtmlStartSnippet).toBe('');
}); });
describe('merging lines into the original motion', function () { it('preserves the numbering of OLs (1)', function () {
var diff = diffService.extractRangeByLineNumbers(baseHtmlDom3, 5, 7, true);
expect(diff.html).toBe('<LI>Line 3.3</LI></OL></LI><LI> Line 4</LI></OL>');
expect(diff.ancestor.nodeName).toBe('#document-fragment');
expect(diff.innerContextStart).toBe('<OL start="3"><LI><OL start="3">');
expect(diff.innerContextEnd).toBe('');
expect(diff.previousHtmlEndSnippet).toBe('</OL></LI></OL>');
});
it('preserves the numbering of OLs (2)', function () {
var diff = diffService.extractRangeByLineNumbers(baseHtmlDom3, 3, 5, true);
expect(diff.html).toBe('<LI><OL><LI>Line 3.1</LI><LI>Line 3.2</LI>');
expect(diff.ancestor.nodeName).toBe('OL');
expect(diff.outerContextStart).toBe('<OL start="3">');
expect(diff.outerContextEnd).toBe('</OL>');
});
});
describe('merging two sections', function () {
it('merges OLs recursively, ignoring whitespaces between OL and LI', function () {
var node1 = document.createElement('DIV');
node1.innerHTML = '<OL><LI><OL><LI>Punkt 4.1</LI><TEMPLATE></TEMPLATE></OL></LI> </OL>';
var node2 = document.createElement('DIV');
node2.innerHTML = '<OL> <LI>\
<OL start="2">\
<LI>Punkt 4.2</LI>\
<LI>Punkt 4.3</LI>\
</OL></LI></OL>';
var out = diffService._replaceLinesMergeNodeArrays([node1.childNodes[0]], [node2.childNodes[0]]);
expect(out[0], '<ol><li><ol><li>Punkt 4.1</li><template></template><li>Punkt 4.2</li><li>Punkt 4.3</li></ol></li></ol>');
});
});
describe('replacing lines in the original motion', function () {
it('replaces LIs by a P', function () { it('replaces LIs by a P', function () {
var merged = diffService.replaceLines(baseHtmlDom1, '<p>Replaced a UL by a P</p>', 6, 9); var merged = diffService.replaceLines(baseHtmlDom1, '<p>Replaced a UL by a P</p>', 6, 9);
expect(merged).toBe('<P>Line 1 Line 2Line <STRONG>3<BR>Line 4 Line</STRONG> 5</P><P>Replaced a UL by a P</P><UL class="ul-class"><LI class="li-class"><UL><LI>Level 2 LI 9</LI></UL></LI></UL><P>Line 10 Line 11</P>'); expect(merged).toBe('<P>Line 1 Line 2 Line <STRONG>3<BR>Line 4 Line</STRONG> 5</P><P>Replaced a UL by a P</P><UL class="ul-class"><LI class="li-class"><UL><LI>Level 2 LI 9</LI></UL></LI></UL><P>Line 10 Line 11</P>');
}); });
/*
it('replaces LIs by another LI', function () { it('replaces LIs by another LI', function () {
var merged = diffService.replaceLines(baseHtmlDom1, '<UL class="ul-class"><LI>A new LI</LI></UL>', 6, 9); var merged = diffService.replaceLines(baseHtmlDom1, '<UL class="ul-class"><LI>A new LI</LI></UL>', 6, 9);
expect(merged).toBe(''); expect(merged).toBe('<P>Line 1 Line 2 Line <STRONG>3<BR>Line 4 Line</STRONG> 5</P><UL class="ul-class"><LI>A new LI<UL><LI>Level 2 LI 9</LI></UL></LI></UL><P>Line 10 Line 11</P>');
});
it('breaks up a paragraph into two', function() {
var merged = diffService.replaceLines(baseHtmlDom1, '<P>Replaced Line 10</P><P>Inserted Line 11 </P>', 10, 11);
expect(merged).toBe('<P>Line 1 Line 2 Line <STRONG>3<BR>Line 4 Line</STRONG> 5</P><UL class="ul-class"><LI class="li-class">Line 6 Line 7</LI><LI class="li-class"><UL><LI>Level 2 LI 8</LI><LI>Level 2 LI 9</LI></UL></LI></UL><P>Replaced Line 10</P><P>Inserted Line 11 Line 11</P>');
}); });
*/
}); });
describe('detecting the type of change', function() {
it('detects a simple insertion', function () {
var htmlBefore = '<p>Test 1</p>',
htmlAfter = '<p>Test 1 Test 2</p>' + "\n" + '<p>Test 3</p>';
var calculatedType = diffService.detectReplacementType(htmlBefore, htmlAfter);
expect(calculatedType).toBe(diffService.TYPE_INSERTION);
});
it('detects a simple insertion, ignoring case of tags', function () {
var htmlBefore = '<p>Test 1</p>',
htmlAfter = '<P>Test 1 Test 2</P>' + "\n" + '<P>Test 3</P>';
var calculatedType = diffService.detectReplacementType(htmlBefore, htmlAfter);
expect(calculatedType).toBe(diffService.TYPE_INSERTION);
});
it('detects a simple insertion, ignoring trailing whitespaces', function () {
var htmlBefore = '<P>Lorem ipsum dolor sit amet, sed diam voluptua. At </P>',
htmlAfter = '<P>Lorem ipsum dolor sit amet, sed diam voluptua. At2</P>';
var calculatedType = diffService.detectReplacementType(htmlBefore, htmlAfter);
expect(calculatedType).toBe(diffService.TYPE_INSERTION);
});
it('detects a simple insertion, ignoring spaces between UL and LI', function () {
var htmlBefore = '<UL><LI>accusam et justo duo dolores et ea rebum.</LI></UL>',
htmlAfter = '<UL>' + "\n" + '<LI>accusam et justo duo dolores et ea rebum 123.</LI>' + "\n" + '</UL>';
var calculatedType = diffService.detectReplacementType(htmlBefore, htmlAfter);
expect(calculatedType).toBe(diffService.TYPE_INSERTION);
});
it('detects a simple insertion, despite &nbsp; tags', function() {
var htmlBefore = '<P>dsds dsfsdfsdf sdf sdfs dds sdf dsds dsfsdfsdf</P>',
htmlAfter = '<P>dsds&nbsp;dsfsdfsdf sdf sdfs dds sd345 3453 45f dsds&nbsp;dsfsdfsdf</P>';
var calculatedType = diffService.detectReplacementType(htmlBefore, htmlAfter);
expect(calculatedType).toBe(diffService.TYPE_INSERTION);
});
it('detects a simple deletion', function () {
var htmlBefore = '<p>Test 1 Test 2</p>' + "\n" + '<p>Test 3</p>',
htmlAfter = '<p>Test 1</p>';
var calculatedType = diffService.detectReplacementType(htmlBefore, htmlAfter);
expect(calculatedType).toBe(diffService.TYPE_DELETION);
});
it('detects a simple deletion, ignoring case of tags', function () {
var htmlBefore = '<p>Test 1 Test 2</p>' + "\n" + '<p>Test 3</p>',
htmlAfter = '<P>Test 1</P>';
var calculatedType = diffService.detectReplacementType(htmlBefore, htmlAfter);
expect(calculatedType).toBe(diffService.TYPE_DELETION);
});
it('detects a simple deletion, ignoring trailing whitespaces', function () {
var htmlBefore = '<P>Lorem ipsum dolor sit amet, sed diam voluptua. At2</P>',
htmlAfter = '<P>Lorem ipsum dolor sit amet, sed diam voluptua. At </P>';
var calculatedType = diffService.detectReplacementType(htmlBefore, htmlAfter);
expect(calculatedType).toBe(diffService.TYPE_DELETION);
});
it('detects a simple replacement', function () {
var htmlBefore = '<p>Test 1 Test 2</p>' + "\n" + '<p>Test 3</p>',
htmlAfter = '<p>Test 1</p>' + "\n" + '<p>Test 2</p>' + "\n" + '<p>Test 3</p>';
var calculatedType = diffService.detectReplacementType(htmlBefore, htmlAfter);
expect(calculatedType).toBe(diffService.TYPE_REPLACEMENT);
});
});
}); });

View File

@ -5,10 +5,10 @@ describe('linenumbering', function () {
var lineNumberingService, var lineNumberingService,
brMarkup = function (no) { brMarkup = function (no) {
return '<br class="os-line-break">' + return '<br class="os-line-break">' +
'<span class="os-line-number line-number-' + no + '" data-line-number="' + no + '" contenteditable="false">&nbsp;</span>'; '<span class="os-line-number line-number-' + no + '" data-line-number="' + no + '" name="L' + no + '" contenteditable="false">&nbsp;</span>';
}, },
noMarkup = function (no) { noMarkup = function (no) {
return '<span class="os-line-number line-number-' + no + '" data-line-number="' + no + '" contenteditable="false">&nbsp;</span>'; return '<span class="os-line-number line-number-' + no + '" data-line-number="' + no + '" name="L' + no + '" contenteditable="false">&nbsp;</span>';
}, },
longstr = function (length) { longstr = function (length) {
var outstr = ''; var outstr = '';
@ -26,6 +26,7 @@ describe('linenumbering', function () {
it('breaks very short lines', function () { it('breaks very short lines', function () {
var textNode = document.createTextNode("0123"); var textNode = document.createTextNode("0123");
lineNumberingService._currentInlineOffset = 0; lineNumberingService._currentInlineOffset = 0;
lineNumberingService._currentLineNumber = 1;
var out = lineNumberingService._textNodeToLines(textNode, 5); var out = lineNumberingService._textNodeToLines(textNode, 5);
var outHtml = lineNumberingService._nodesToHtml(out); var outHtml = lineNumberingService._nodesToHtml(out);
expect(outHtml).toBe('0123'); expect(outHtml).toBe('0123');