diff --git a/openslides/core/static/css/app.css b/openslides/core/static/css/app.css index 02ce19333..31a669796 100644 --- a/openslides/core/static/css/app.css +++ b/openslides/core/static/css/app.css @@ -302,22 +302,25 @@ img { border: 1px solid #d3d3d3; } -.col1 .details .line-number-setter { +.col1 .details .motion-toolbar .toolbar-left { margin-top: 0; margin-bottom: 55px; margin-left: 15px; } -.col1 .details .line-number-setter > span { +.col1 .details .motion-toolbar .toolbar-left > * { margin-right: 5px; float: left; } -.col1 .details .line-number-setter .btn.disabled { +.col1 .details .motion-toolbar .toolbar-left .btn.disabled { cursor: default; opacity: 1; background-color: #eee; } +.col1 .details .motion-toolbar .toolbar-left .goto-line-number { + max-width: 220px; +} .col1 .details .inline-editing-activator { margin-right: 13px; @@ -395,23 +398,29 @@ img { background-color: #ff0; } +.motion-text li { + margin-left: 30px; +} .motion-text.line-numbers-outside { - padding-left: 35px; + padding-left: 40px; position: relative; } .motion-text.line-numbers-outside .os-line-number { display: inline-block; font-size: 0; line-height: 0; - width: 0; - height: 0; + width: 22px; + height: 22px; + position: absolute; + left: -20px; + padding-right: 55px; } .motion-text.line-numbers-outside .os-line-number:after { content: attr(data-line-number); position: absolute; - left: 0; + left: 20px; + top: 12px; vertical-align: top; - margin-top: -5px; color: gray; font-family: Courier, serif; font-size: 13px; @@ -445,6 +454,7 @@ img { } .os-line-number { + position: relative; user-select: none; -moz-user-select: none; -khtml-user-select: none; @@ -452,6 +462,7 @@ img { -o-user-select: none; } .os-line-number:after { + position: relative; user-select: none; -moz-user-select: none; -khtml-user-select: none; @@ -459,6 +470,174 @@ img { -o-user-select: none; } +.line-numbers-outside .os-line-number.selectable:hover:before, .line-numbers-outside .os-line-number.selected:before { + cursor: pointer; + content: "\f067"; + display: inline-block; + position: absolute; + width: 14px; + height: 14px; + border-radius: 0.25em; + top: 4px; + left: 43px; + cursor: pointer; + font-family: FontAwesome; + font-size: 12px; + color: white; + line-height: 16px; + text-align: center; + background-color: #337ab7; +} + + +/** Styles for annotating the original motion text with change recommendations */ + +.motion-text-holder { + position: relative; +} + +.motion-text-holder .change-recommendation-list { + position: absolute; + top: 0; + left: -10px; + width: 4px; + list-style-type: none; + margin: 0; +} + +.motion-text-holder .change-recommendation-list > li { + position: absolute; + width: 4px; + cursor: pointer; +} +.motion-text-holder .change-recommendation-list > li.insert { + background-color: #00aa00; +} +.motion-text-holder .change-recommendation-list > li.delete { + background-color: #aa0000; +} +.motion-text-holder .change-recommendation-list > li.replace { + background-color: #0333ff; +} + +.motion-text-holder .change-recommendation-list .tooltip { + display: none; +} + +/** Diff view */ + +.change-recommendation-overview { + background-color: #eee; + border: solid 1px #ddd; + border-radius: 3px; + margin-bottom: 5px; + margin-top: -15px; + padding-top: 5px; +} +.change-recommendation-overview { + margin-bottom: 50px; + padding: 10px; +} +.change-recommendation-overview h2 { + margin-top: 0; +} +.change-recommendation-overview ul { + list-style: none; + display: table; +} +.change-recommendation-overview li { + display: table-row; + cursor: pointer; +} +.change-recommendation-overview li:hover { + text-decoration: underline; +} +.change-recommendation-overview li > * { + display: table-cell; + padding: 4px; +} +.change-recommendation-overview .status { + color: gray; + font-style: italic; +} +.change-recommendation-overview .status > *:before { + content: '('; +} +.change-recommendation-overview .status > *:after { + content: ')'; +} +.change-recommendation-overview .no-changes { + font-style: italic; + color: grey; +} + + +.diff-box { + background-color: #f9f9f9; + border: solid 1px #eee; + border-radius: 3px; + margin-bottom: 0; + margin-top: -25px; + padding-top: 0; + padding-right: 155px; +} +.motion-text-with-diffs .original-text { + min-height: 30px; // Spacer between .diff-box, in case .original-text is empty +} +.motion-text-with-diffs .original-text ul:last-child { + padding-bottom: 16px; +} +.motion-text-with-diffs.line-numbers-inline .diff-box, .motion-text-with-diffs.line-numbers-none .diff-box { + margin-right: -220px; +} +.diff-box:hover { + background-color: #f0f0f0; +} +.diff-box .action-row { + font-size: 0.8em; + padding-top: 5px; + padding-bottom: 5px; + float: right; + width: 150px; + text-align: right; + margin-right: -150px; + opacity: 0.5; +} +.diff-box:hover .action-row { + opacity: 1; +} +.diff-box .action-row .btn-delete { + margin-left: 10px; + color: red; +} +.diff-box .status-row { + font-style: italic; + color: gray; +} +.diff-box .status-row > *:after { + content: ':'; +} + +.motion-text-diff .delete { + color: red; + text-decoration: line-through; +} +.motion-text-diff .insert { + color: green; + text-decoration: underline; +} +.motion-text-diff p { + padding-bottom: 0; + margin-top: 0; + margin-bottom: 0; +} +.motion-text-diff.line-numbers-outside .insert .os-line-number { + display: none; +} +.motion-text-diff.line-numbers-inline .insert .os-line-number { + display: none; +} + /** Projector sidebar column **/ #content .col2 { @@ -704,13 +883,16 @@ img { border-top: 1px solid #ddd; background-color: #f5f5f5; } -.linenumber-toolbar, .speakers-toolbar { +.motion-toolbar, .speakers-toolbar { background-color: #f5f5f5; border-bottom: 1px solid #ddd; padding: 12px 0 10px 0; height: 54px; margin: -20px -5px 50px -5px; } +.motion-toolbar:first-child { + margin-bottom: 20px; +} .speakers-toolbar { margin: -20px -20px 30px -20px; diff --git a/openslides/motions/access_permissions.py b/openslides/motions/access_permissions.py index 270b757d4..2ac7b4a2a 100644 --- a/openslides/motions/access_permissions.py +++ b/openslides/motions/access_permissions.py @@ -55,6 +55,25 @@ class MotionAccessPermissions(BaseAccessPermissions): return data +class MotionChangeRecommendationAccessPermissions(BaseAccessPermissions): + """ + Access permissions container for MotionChangeRecommendation and MotionChangeRecommendationViewSet. + """ + def check_permissions(self, user): + """ + Returns True if the user has read access model instances. + """ + return user.has_perm('motions.can_see') + + def get_serializer_class(self, user=None): + """ + Returns serializer class. + """ + from .serializers import MotionChangeRecommendationSerializer + + return MotionChangeRecommendationSerializer + + class CategoryAccessPermissions(BaseAccessPermissions): """ Access permissions container for Category and CategoryViewSet. diff --git a/openslides/motions/apps.py b/openslides/motions/apps.py index 5e3a9b39d..413e12024 100644 --- a/openslides/motions/apps.py +++ b/openslides/motions/apps.py @@ -18,7 +18,7 @@ class MotionsAppConfig(AppConfig): from openslides.utils.rest_api import router from .config_variables import get_config_variables from .signals import create_builtin_workflows - from .views import CategoryViewSet, MotionViewSet, MotionPollViewSet, WorkflowViewSet + from .views import CategoryViewSet, MotionViewSet, MotionPollViewSet, MotionChangeRecommendationViewSet, WorkflowViewSet # Define config variables config.update_config_variables(get_config_variables()) @@ -30,4 +30,6 @@ class MotionsAppConfig(AppConfig): router.register(self.get_model('Category').get_collection_string(), CategoryViewSet) router.register(self.get_model('Motion').get_collection_string(), MotionViewSet) router.register(self.get_model('Workflow').get_collection_string(), WorkflowViewSet) + router.register(self.get_model('MotionChangeRecommendation').get_collection_string(), + MotionChangeRecommendationViewSet) router.register('motions/motionpoll', MotionPollViewSet) diff --git a/openslides/motions/migrations/0005_motionchangerecommendation.py b/openslides/motions/migrations/0005_motionchangerecommendation.py new file mode 100644 index 000000000..8ed9758c1 --- /dev/null +++ b/openslides/motions/migrations/0005_motionchangerecommendation.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.1 on 2016-10-12 18:10 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models +import openslides.utils.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('motions', '0004_auto_20160907_2343'), + ] + + operations = [ + migrations.CreateModel( + name='MotionChangeRecommendation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.PositiveIntegerField(default=0)), + ('line_from', models.PositiveIntegerField()), + ('line_to', models.PositiveIntegerField()), + ('text', models.TextField(blank=True)), + ('creation_time', models.DateTimeField(auto_now=True)), + ('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('motion_version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='change_recommendations', to='motions.MotionVersion')), + ], + options={ + 'default_permissions': (), + }, + bases=(openslides.utils.models.RESTModelMixin, models.Model), + ), + ] diff --git a/openslides/motions/models.py b/openslides/motions/models.py index dae32ca75..f415fc01b 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -23,6 +23,7 @@ from openslides.utils.search import user_name_helper from .access_permissions import ( CategoryAccessPermissions, MotionAccessPermissions, + MotionChangeRecommendationAccessPermissions, WorkflowAccessPermissions, ) from .exceptions import WorkflowError @@ -697,6 +698,68 @@ class MotionVersion(RESTModelMixin, models.Model): return self.motion +class MotionChangeRecommendationManager(models.Manager): + """ + Customized model manager to support our get_full_queryset method. + """ + def get_full_queryset(self): + """ + Returns the normal queryset with all change recommendations. In the background we + join and prefetch all related models. + """ + return self.get_queryset() + + +class MotionChangeRecommendation(RESTModelMixin, models.Model): + """ + A MotionChangeRecommendation object saves change recommendations for a specific MotionVersion + """ + + access_permissions = MotionChangeRecommendationAccessPermissions() + + objects = MotionChangeRecommendationManager() + + motion_version = models.ForeignKey( + MotionVersion, + on_delete=models.CASCADE, + related_name='change_recommendations') + """The motion version to which the change recommendation belongs.""" + + status = models.PositiveIntegerField(default=0) + """Proposed (0), Accepted (1), Rejected (2)""" + + line_from = models.PositiveIntegerField() + """The number or the first affected line""" + + line_to = models.PositiveIntegerField() + """The number or the last affected line (inclusive)""" + + text = models.TextField(blank=True) + """The replacement for the section of the original text specified by version, line_from and line_to""" + + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True) + """A user object, who created this change recommendation. Optional.""" + + creation_time = models.DateTimeField(auto_now=True) + """Time when the change recommendation was saved.""" + + class Meta: + default_permissions = () + + def __str__(self): + """Return a string, representing this object.""" + return "Recommendation for Version %s, line %s - %s" % (self.motion_version_id, self.line_from, self.line_to) + + def get_root_rest_element(self): + """ + Returns this instance, which is the root REST element. + """ + return self + + class Category(RESTModelMixin, models.Model): """ Model for categories of motions. diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py index e3f458d99..c094e3b3c 100644 --- a/openslides/motions/serializers.py +++ b/openslides/motions/serializers.py @@ -16,6 +16,7 @@ from openslides.utils.rest_api import ( from .models import ( Category, Motion, + MotionChangeRecommendation, MotionLog, MotionPoll, MotionVersion, @@ -228,6 +229,22 @@ class MotionVersionSerializer(ModelSerializer): 'reason',) +class MotionChangeRecommendationSerializer(ModelSerializer): + """ + Serializer for motion.models.MotionChangeRecommendation objects. + """ + class Meta: + model = MotionChangeRecommendation + fields = ( + 'id', + 'motion_version', + 'status', + 'line_from', + 'line_to', + 'text', + 'creation_time',) + + class MotionSerializer(ModelSerializer): """ Serializer for motion.models.Motion objects. diff --git a/openslides/motions/static/js/motions/base.js b/openslides/motions/static/js/motions/base.js index e2086c58b..55a3df574 100644 --- a/openslides/motions/static/js/motions/base.js +++ b/openslides/motions/static/js/motions/base.js @@ -142,13 +142,16 @@ angular.module('OpenSlidesApp.motions', [ .factory('Motion', [ 'DS', 'MotionPoll', + 'MotionChangeRecommendation', 'MotionComment', 'jsDataModel', 'gettext', 'operator', 'Config', 'lineNumberingService', - function(DS, MotionPoll, MotionComment, jsDataModel, gettext, operator, Config, lineNumberingService) { + 'diffService', + function(DS, MotionPoll, MotionChangeRecommendation, MotionComment, jsDataModel, gettext, operator, Config, + lineNumberingService, diffService) { var name = 'motions/motion'; return DS.defineResource({ name: name, @@ -187,7 +190,98 @@ angular.module('OpenSlidesApp.motions', [ return lineNumberingService.insertLineNumbers(html, lineLength, highlight, callback); }, - setTextStrippingLineBreaks: function (versionId, text) { + getTextBetweenChangeRecommendations: function (versionId, change1, change2) { + var line_from = (change1 ? change1.line_to : 1), + line_to = (change2 ? change2.line_from : null); + + if (line_from > line_to) { + throw 'Invalid call of getTextBetweenChangeRecommendations: change1 needs to be before change2'; + } + if (line_from == line_to) { + return ''; + } + + var lineLength = Config.get('motions_line_length').value, + html = lineNumberingService.insertLineNumbers(this.getVersion(versionId).text, lineLength); + + var data = diffService.extractRangeByLineNumbers(html, line_from, line_to); + + html = data.outerContextStart + data.innerContextStart + data.html + + data.innerContextEnd + data.outerContextEnd; + html = lineNumberingService.insertLineNumbers(html, lineLength, null, null, line_from); + + return html; + }, + getTextRemainderAfterLastChangeRecommendation: function(versionId, changes) { + var maxLine = 0; + for (var i = 0; i < changes.length; i++) { + if (changes[i].line_to > maxLine) { + maxLine = changes[i].line_to; + } + } + + var lineLength = Config.get('motions_line_length').value, + html = lineNumberingService.insertLineNumbers(this.getVersion(versionId).text, lineLength); + + var data = diffService.extractRangeByLineNumbers(html, maxLine, null); + html = data.outerContextStart + data.innerContextStart + + data.html + + data.innerContextEnd + data.outerContextEnd; + html = lineNumberingService.insertLineNumbers(html, lineLength, null, null, maxLine); + + return html; + }, + getTextWithChangeRecommendations: function (versionId, statusCompareCb) { + var lineLength = Config.get('motions_line_length').value, + html = this.getVersion(versionId).text, + changes = this.getChangeRecommendations(versionId, 'DESC'), + fragment; + + for (var i = 0; i < changes.length; i++) { + var change = changes[i]; + if (statusCompareCb === undefined || statusCompareCb(change.status)) { + html = lineNumberingService.insertLineNumbers(html, lineLength); + fragment = diffService.htmlToFragment(html); + html = diffService.replaceLines(fragment, change.text, change.line_from, change.line_to); + } + } + + return lineNumberingService.insertLineNumbers(html, lineLength); + }, + getTextWithAcceptedChangeRecommendations: function (versionId) { + return this.getTextWithChangeRecommendations(versionId, function(status) { + return (status == 1); + }); + }, + getTextByMode: function(mode, versionId) { + /* + * @param mode ['original', 'diff', 'changed', 'agreed'] + * @param versionId [if undefined, active_version will be used] + */ + var text; + switch (mode) { + case 'original': + text = this.getTextWithLineBreaks(versionId); + break; + case 'diff': + var changes = this.getChangeRecommendations(versionId, 'ASC'); + text = ''; + for (var i = 0; i < changes.length; i++) { + text += this.getTextBetweenChangeRecommendations(versionId, (i === 0 ? null : changes[i - 1]), changes[i]); + text += changes[i].format(this, versionId); + } + text += this.getTextRemainderAfterLastChangeRecommendation(versionId, changes); + break; + case 'changed': + text = this.getTextWithChangeRecommendations(versionId); + break; + case 'agreed': + text = this.getTextWithAcceptedChangeRecommendations(versionId); + break; + } + return text; + }, + setTextStrippingLineBreaks: function (text) { this.text = lineNumberingService.stripLineNumbers(text); }, getReason: function (versionId) { @@ -201,6 +295,24 @@ angular.module('OpenSlidesApp.motions', [ getSearchResultSubtitle: function () { return "Motion"; }, + getChangeRecommendations: function (versionId, order) { + /* + * Returns all change recommendations for this given version, sorted by line + * @param versionId + * @param order ['DESC' or 'ASC' (default)] + * @returns {*} + */ + versionId = versionId || this.active_version; + order = order || 'ASC'; + return MotionChangeRecommendation.filter({ + where: { + motion_version_id: versionId + }, + orderBy: [ + ['line_from', order] + ] + }); + }, isAllowed: function (action) { /* * Return true if the requested user is allowed to do the specific action. @@ -392,11 +504,95 @@ angular.module('OpenSlidesApp.motions', [ } ]) +.factory('MotionChangeRecommendation', [ + 'DS', + 'Config', + 'jsDataModel', + 'diffService', + 'lineNumberingService', + 'gettextCatalog', + function (DS, Config, jsDataModel, diffService, lineNumberingService, gettextCatalog) { + return DS.defineResource({ + name: 'motions/motionchangerecommendation', + useClass: jsDataModel, + methods: { + saveStatus: function() { + this.DSSave(); + }, + format: function(motion, version) { + var lineLength = Config.get('motions_line_length').value, + html = lineNumberingService.insertLineNumbers(motion.getVersion(version).text, lineLength); + + var data = diffService.extractRangeByLineNumbers(html, this.line_from, this.line_to), + oldText = data.outerContextStart + data.innerContextStart + + data.html + data.innerContextEnd + data.outerContextEnd, + oldTextWithBreaks = lineNumberingService.insertLineNumbersNode(oldText, lineLength, null, this.line_from), + newTextWithBreaks = lineNumberingService.insertLineNumbersNode(this.text, lineLength, null, this.line_from); + + for (var i = 0; i < oldTextWithBreaks.childNodes.length; i++) { + diffService.addCSSClass(oldTextWithBreaks.childNodes[i], 'delete'); + } + for (i = 0; i < newTextWithBreaks.childNodes.length; i++) { + diffService.addCSSClass(newTextWithBreaks.childNodes[i], 'insert'); + } + + var mergedFragment = document.createDocumentFragment(), + el; + while (oldTextWithBreaks.firstChild) { + el = oldTextWithBreaks.firstChild; + oldTextWithBreaks.removeChild(el); + mergedFragment.appendChild(el); + } + while (newTextWithBreaks.firstChild) { + el = newTextWithBreaks.firstChild; + newTextWithBreaks.removeChild(el); + mergedFragment.appendChild(el); + } + + return diffService._serializeDom(mergedFragment); + }, + getType: function(original_full_html) { + var lineLength = Config.get('motions_line_length').value, + html = lineNumberingService.insertLineNumbers(original_full_html, lineLength); + + var data = diffService.extractRangeByLineNumbers(html, this.line_from, this.line_to), + oldText = data.outerContextStart + data.innerContextStart + + data.html + data.innerContextEnd + data.outerContextEnd; + + return diffService.detectReplacementType(oldText, this.text); + }, + getTitle: function(original_full_html) { + var title; + if (this.line_to > (this.line_from + 1)) { + title = gettextCatalog.getString('%TYPE% from line %FROM% to %TO%'); + } else { + title = gettextCatalog.getString('%TYPE% in line %FROM%'); + } + switch (this.getType(original_full_html)) { + case diffService.TYPE_INSERTION: + title = title.replace('%TYPE%', gettextCatalog.getString('Insertion')); + break; + case diffService.TYPE_DELETION: + title = title.replace('%TYPE%', gettextCatalog.getString('Deletion')); + break; + case diffService.TYPE_REPLACEMENT: + title = title.replace('%TYPE%', gettextCatalog.getString('Replacement')); + break; + } + title = title.replace('%FROM%', this.line_from).replace('%TO%', (this.line_to - 1)); + return title; + } + } + }); + } +]) + .run([ 'Motion', 'Category', 'Workflow', - function(Motion, Category, Workflow) {} + 'MotionChangeRecommendation', + function(Motion, Category, Workflow, MotionChangeRecommendation) {} ]) diff --git a/openslides/motions/static/js/motions/diff.js b/openslides/motions/static/js/motions/diff.js index 78cd07a9d..55ce25b52 100644 --- a/openslides/motions/static/js/motions/diff.js +++ b/openslides/motions/static/js/motions/diff.js @@ -9,6 +9,9 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi TEXT_NODE = 3, DOCUMENT_FRAGMENT_NODE = 11; + this.TYPE_REPLACEMENT = 0; + this.TYPE_INSERTION = 1; + this.TYPE_DELETION = 2; this.getLineNumberNode = function(fragment, lineNumber) { return fragment.querySelector('os-linebreak.os-line-number.line-number-' + lineNumber); @@ -24,31 +27,100 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi return context; }; + // Adds elements like this._insertInternalLineMarkers = function(fragment) { if (fragment.querySelectorAll('OS-LINEBREAK').length > 0) { // Prevent duplicate calls return; } - var lineNumbers = fragment.querySelectorAll('span.os-line-number'); + var lineNumbers = fragment.querySelectorAll('span.os-line-number'), + lineMarker, maxLineNumber; + for (var i = 0; i < lineNumbers.length; i++) { var insertBefore = lineNumbers[i]; while (insertBefore.parentNode.nodeType != DOCUMENT_FRAGMENT_NODE && insertBefore.parentNode.childNodes[0] == insertBefore) { insertBefore = insertBefore.parentNode; } - var lineMarker = document.createElement('OS-LINEBREAK'); + lineMarker = document.createElement('OS-LINEBREAK'); lineMarker.setAttribute('data-line-number', lineNumbers[i].getAttribute('data-line-number')); lineMarker.setAttribute('class', lineNumbers[i].getAttribute('class')); insertBefore.parentNode.insertBefore(lineMarker, insertBefore); + maxLineNumber = lineNumbers[i].getAttribute('data-line-number'); + } + + // Add one more "fake" line number at the end and beginning, so we can select the last line as well + lineMarker = document.createElement('OS-LINEBREAK'); + lineMarker.setAttribute('data-line-number', (parseInt(maxLineNumber) + 1)); + lineMarker.setAttribute('class', 'os-line-number line-number-' + (parseInt(maxLineNumber) + 1)); + fragment.appendChild(lineMarker); + + lineMarker = document.createElement('OS-LINEBREAK'); + lineMarker.setAttribute('data-line-number', '0'); + lineMarker.setAttribute('class', 'os-line-number line-number-0'); + fragment.insertBefore(lineMarker, fragment.firstChild); + }; + + // @TODO Check if this is actually necessary + this._insertInternalLiNumbers = function(fragment) { + if (fragment.querySelectorAll('LI[os-li-number]').length > 0) { + // Prevent duplicate calls + return; + } + var ols = fragment.querySelectorAll('OL'); + for (var i = 0; i < ols.length; i++) { + var ol = ols[i], + liNo = 0; + for (var j = 0; j < ol.childNodes.length; j++) { + if (ol.childNodes[j].nodeName == 'LI') { + liNo++; + ol.childNodes[j].setAttribute('os-li-number', liNo); + } + } } }; - /* - * Returns an array with the following values: - * 0: the most specific DOM-node that contains both line numbers - * 1: the context of node1 (an array of dom-elements; 0 is the document fragment) - * 2: the context of node2 (an array of dom-elements; 0 is the document fragment) - * 3: the index of [0] in the two arrays - */ + this._addStartToOlIfNecessary = function(node) { + var firstLiNo = null; + for (var i = 0; i < node.childNodes.length && firstLiNo === null; i++) { + if (node.childNode[i].nodeName == 'LI') { + var lineNo = node.childNode[i].getAttribute('ol-li-number'); + if (lineNo) { + firstLiNo = parseInt(lineNo); + } + } + } + if (firstLiNo > 1) { + node.setAttribute('start', firstLiNo); + } + }; + + this._isWithinNthLIOfOL = function(olNode, descendantNode) { + var nthLIOfOL = null; + while (descendantNode.parentNode) { + if (descendantNode.parentNode == olNode) { + var lisBeforeOl = 0, + foundMe = false; + for (var i = 0; i < olNode.childNodes.length && !foundMe; i++) { + if (olNode.childNodes[i] == descendantNode) { + foundMe = true; + } else if (olNode.childNodes[i].nodeName == 'LI') { + lisBeforeOl++; + } + } + nthLIOfOL = lisBeforeOl + 1; + } + descendantNode = descendantNode.parentNode; + } + return nthLIOfOL; + }; + + /* + * Returns an array with the following values: + * 0: the most specific DOM-node that contains both line numbers + * 1: the context of node1 (an array of dom-elements; 0 is the document fragment) + * 2: the context of node2 (an array of dom-elements; 0 is the document fragment) + * 3: the index of [0] in the two arrays + */ this._getCommonAncestor = function(node1, node2) { var trace1 = this._getNodeContextTrace(node1), trace2 = this._getNodeContextTrace(node2), @@ -84,8 +156,10 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi } var html = '<' + node.nodeName; for (var i = 0; i < node.attributes.length; i++) { - var attr = node.attributes[i]; - html += " " + attr.name + "=\"" + attr.value + "\""; + var attr = node.attributes[i]; + if (attr.name != 'os-li-number') { + html += ' ' + attr.name + '="' + attr.value + '"'; + } } html += '>'; return html; @@ -158,7 +232,7 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi } if (!found) { console.trace(); - throw "Inconsistency or invalid call of this function detected"; + throw "Inconsistency or invalid call of this function detected (to)"; } return html; }; @@ -196,7 +270,7 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi } if (!found) { console.trace(); - throw "Inconsistency or invalid call of this function detected"; + throw "Inconsistency or invalid call of this function detected (from)"; } if (node.nodeType != DOCUMENT_FRAGMENT_NODE) { html += ''; @@ -221,6 +295,7 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi * * Hint: * - The last line (toLine) is not included anymore, as the number refers to the line breaking element + * - if toLine === null, then everything from fromLine to the end of the fragment is returned * * In addition to the HTML snippet, additional information is provided regarding the most specific DOM element * that contains the whole section specified by the line numbers (like a P-element if only one paragraph is selected @@ -246,11 +321,19 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi * - followingHtmlStartSnippet: A HTML snippet that opens all HTML tags necessary to render "followingHtml" * */ - this.extractRangeByLineNumbers = function(fragment, fromLine, toLine) { + this.extractRangeByLineNumbers = function(fragment, fromLine, toLine, debug) { + if (typeof(fragment) == 'string') { + fragment = this.htmlToFragment(fragment); + } this._insertInternalLineMarkers(fragment); + this._insertInternalLiNumbers(fragment); + if (toLine === null) { + var internalLineMarkers = fragment.querySelectorAll('OS-LINEBREAK'); + toLine = parseInt(internalLineMarkers[internalLineMarkers.length - 1].getAttribute("data-line-number")); + } var fromLineNode = this.getLineNumberNode(fragment, fromLine), - toLineNode = this.getLineNumberNode(fragment, toLine), + toLineNode = (toLine ? this.getLineNumberNode(fragment, toLine) : null), ancestorData = this._getCommonAncestor(fromLineNode, toLineNode); var fromChildTraceRel = ancestorData.trace1, @@ -264,7 +347,8 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi innerContextStart = '', innerContextEnd = '', previousHtmlEndSnippet = '', - followingHtmlStartSnippet = ''; + followingHtmlStartSnippet = '', + fakeOl; fromChildTraceAbs.shift(); @@ -288,7 +372,13 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi if (fromChildTraceRel[i].nodeName == 'OS-LINEBREAK') { found = true; } else { - innerContextStart += this._serializeTag(fromChildTraceRel[i]); + if (fromChildTraceRel[i].nodeName == 'OL') { + fakeOl = fromChildTraceRel[i].cloneNode(false); + fakeOl.setAttribute('start', this._isWithinNthLIOfOL(fromChildTraceRel[i], fromLineNode)); + innerContextStart += this._serializeTag(fakeOl); + } else { + innerContextStart += this._serializeTag(fromChildTraceRel[i]); + } } } found = false; @@ -317,7 +407,13 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi currNode = ancestor; while (currNode.parentNode) { - outerContextStart = this._serializeTag(currNode) + outerContextStart; + if (currNode.nodeName == 'OL') { + fakeOl = currNode.cloneNode(false); + fakeOl.setAttribute('start', this._isWithinNthLIOfOL(currNode, fromLineNode)); + outerContextStart = this._serializeTag(fakeOl) + outerContextStart; + } else { + outerContextStart = this._serializeTag(currNode) + outerContextStart; + } outerContextEnd += ''; currNode = currNode.parentNode; } @@ -334,9 +430,16 @@ angular.module('OpenSlidesApp.motions.diff', ['OpenSlidesApp.motions.lineNumberi 'followingHtml': followingHtml, 'followingHtmlStartSnippet': followingHtmlStartSnippet }; - }; + /* + * This functions merges to arrays of nodes. The last element of nodes1 and the first element of nodes2 + * are merged, if they are of the same type. + * + * This is done recursively until a TEMPLATE-Tag is is found, which was inserted in this.replaceLines. + * Using a TEMPLATE-Tag is a rather dirty hack, as it is allowed inside of any other element, including