diff --git a/bower.json b/bower.json
index 911996f26..2fd28f98c 100644
--- a/bower.json
+++ b/bower.json
@@ -9,6 +9,7 @@
"angular-bootstrap-colorpicker": "~3.0.25",
"angular-chosen-localytics": "~1.5.0",
"angular-csv-import": "~0.0.36",
+ "angular-file-saver": "~1.1.2",
"angular-formly": "~8.4.0",
"angular-formly-templates-bootstrap": "~6.2.0",
"angular-gettext": "~2.3.7",
@@ -21,6 +22,7 @@
"angular-ui-tinymce": "~0.0.17",
"angular-ui-tree": "~2.22.0",
"bootstrap-css-only": "~3.3.6",
+ "docxtemplater": "~2.1.5",
"font-awesome-bower": "~4.5.0",
"jquery.cookie": "~1.4.1",
"js-data": "~2.9.0",
diff --git a/openslides/core/static/js/core/site.js b/openslides/core/static/js/core/site.js
index d768045b9..ab615b680 100644
--- a/openslides/core/static/js/core/site.js
+++ b/openslides/core/static/js/core/site.js
@@ -13,6 +13,7 @@ angular.module('OpenSlidesApp.core.site', [
'localytics.directives',
'ngBootbox',
'ngDialog',
+ 'ngFileSaver',
'ngMessages',
'ngCsvImport',
'ui.tinymce',
diff --git a/openslides/motions/config_variables.py b/openslides/motions/config_variables.py
index 1b1dfe849..bc7590f67 100644
--- a/openslides/motions/config_variables.py
+++ b/openslides/motions/config_variables.py
@@ -211,24 +211,24 @@ def get_config_variables():
subgroup='Voting and ballot papers',
validators=(MinValueValidator(1),))
- # PDF
+ # PDF and DOCX export
yield ConfigVariable(
- name='motions_pdf_title',
+ name='motions_export_title',
default_value='Motions',
- label='Title for PDF document (all motions)',
+ label='Title for PDF and DOCX documents (all motions)',
weight=370,
group='Motions',
- subgroup='PDF',
+ subgroup='Export',
translatable=True)
yield ConfigVariable(
- name='motions_pdf_preamble',
+ name='motions_export_preamble',
default_value='',
- label='Preamble text for PDF document (all motions)',
+ label='Preamble text for PDF and DOCX documents (all motions)',
weight=375,
group='Motions',
- subgroup='PDF')
+ subgroup='Export')
yield ConfigVariable(
name='motions_pdf_paragraph_numbering',
@@ -237,4 +237,4 @@ def get_config_variables():
label='Show paragraph numbering (only in PDF)',
weight=380,
group='Motions',
- subgroup='PDF')
+ subgroup='Export')
diff --git a/openslides/motions/pdf.py b/openslides/motions/pdf.py
index 744509e64..fbbf224c3 100644
--- a/openslides/motions/pdf.py
+++ b/openslides/motions/pdf.py
@@ -308,9 +308,9 @@ def all_motion_cover(pdf, motions):
"""
Create a coverpage for all motions.
"""
- pdf.append(Paragraph(escape(config["motions_pdf_title"]), stylesheet['Heading1']))
+ pdf.append(Paragraph(escape(config["motions_export_title"]), stylesheet['Heading1']))
- preamble = escape(config["motions_pdf_preamble"])
+ preamble = escape(config["motions_export_preamble"])
if preamble:
pdf.append(Paragraph("%s" % preamble.replace('\r\n', '
'), stylesheet['Paragraph']))
diff --git a/openslides/motions/static/js/motions/base.js b/openslides/motions/static/js/motions/base.js
index 46c7134e9..a93f73be6 100644
--- a/openslides/motions/static/js/motions/base.js
+++ b/openslides/motions/static/js/motions/base.js
@@ -5,7 +5,8 @@
angular.module('OpenSlidesApp.motions', [
'OpenSlidesApp.users',
'OpenSlidesApp.motions.lineNumbering',
- 'OpenSlidesApp.motions.diff'
+ 'OpenSlidesApp.motions.diff',
+ 'OpenSlidesApp.motions.DOCX'
])
.factory('WorkflowState', [
diff --git a/openslides/motions/static/js/motions/docx.js b/openslides/motions/static/js/motions/docx.js
new file mode 100644
index 000000000..d9f7d4da7
--- /dev/null
+++ b/openslides/motions/static/js/motions/docx.js
@@ -0,0 +1,386 @@
+(function () {
+
+'use strict';
+
+angular.module('OpenSlidesApp.motions.DOCX', [])
+
+.factory('MotionDocxExport', [
+ '$http',
+ '$q',
+ 'Config',
+ 'gettextCatalog',
+ 'FileSaver',
+ function ($http, $q, Config, gettextCatalog, FileSaver) {
+
+ var PAGEBREAK = '';
+ var TAGS_NO_PARAM = ['b', 'strong', 'em', 'i'];
+
+ var images;
+ var relationships;
+ var contentTypes;
+
+ // $scope.motionsFiltered, $scope.categories
+
+ var getData = function (motions, categories) {
+ var data = {};
+ data.title = Config.get('motions_export_title').value;
+ data.preamble = Config.get('motions_export_preamble').value;
+ data.date = function () {
+ var today = new Date();
+ var d = today.getDate();
+ var m = today.getMonth()+1; //January is 0!
+ var y = today.getFullYear();
+ if (d<10) { d='0'+d; }
+ if (m<10) { m='0'+m; }
+ return d+'.'+m+'.'+y;
+ }();
+ data.pagebreak_main = motions.length === 0 ? '' : PAGEBREAK;
+ data.categories_translation = gettextCatalog.getString('Categories');
+ data.no_categories = gettextCatalog.getString('No categories available.');
+ data.no_motions = gettextCatalog.getString('No motions available.');
+ data.categories = getCategoriesData(categories);
+ data.motions_list = getMotionShortData(motions);
+ data.motions = getMotionFullData(motions);
+
+ return data;
+ };
+
+ var getCategoriesData = function (categories) {
+ return _.map(categories, function (category) {
+ return {
+ prefix: category.prefix,
+ name: category.name,
+ };
+ });
+ };
+
+ var getMotionShortData = function (motions) {
+ var translation = gettextCatalog.getString('Motion');
+ return _.map(motions, function (motion) {
+ return {
+ motion_translation: translation,
+ identifier: motion.identifier,
+ title: motion.getTitle(),
+ };
+ });
+ };
+
+ var getMotionFullData = function (motions) {
+ var translation = gettextCatalog.getString('Motion'),
+ submitters_translation = gettextCatalog.getString('Submitters'),
+ signature_translation = gettextCatalog.getString('Signature'),
+ status_translation = gettextCatalog.getString('Status'),
+ reason_translation = gettextCatalog.getString('Reason'),
+ data = _.map(motions, function (motion) {
+ return {
+ motion_translation: translation,
+ identifier: motion.identifier,
+ title: motion.getTitle(),
+ submitters_translation: submitters_translation,
+ submitters: _.map(motion.submitters, function (submitter) {
+ return submitter.get_full_name();
+ }).join(', '),
+ signature_translation: signature_translation,
+ status_translation: status_translation,
+ status: gettextCatalog.getString(motion.state.name),
+ text: html2docx(motion.getText()),
+ reason_translation: motion.getReason().length === 0 ? '' : reason_translation,
+ reason: html2docx(motion.getReason()),
+ pagebreak: PAGEBREAK,
+ };
+ });
+ if (data.length) {
+ // clear pagebreak on last element
+ data[data.length - 1].pagebreak = '';
+ }
+ return data;
+ };
+
+ var html2docx = function (html) {
+ var docx = '';
+ var stack = [];
+
+ var isTag = false; // Even if html starts with '
') {
+ docx += '';
+ skipFirstParagraphClosing = false;
+ }
+ html = html.split(/(<|>)/g);
+
+ html.forEach(function (part) {
+ if (part !== '' && part != '\n' && part != '<' && part != '>') {
+ if (isTag) {
+ if (part.startsWith('p')) { /** p **/
+ // Special: begin new paragraph (only if its the first):
+ if (hasParagraph && !skipFirstParagraphClosing) {
+ // End, if there is one
+ docx += '';
+ }
+ skipFirstParagraphClosing = false;
+ docx += '';
+ hasParagraph = true;
+ } else if (part.startsWith('/p')) {
+ // Special: end paragraph:
+ docx += '';
+ hasParagraph = false;
+
+ } else if (part.charAt(0) == "/") {
+ // remove from stack
+ stack.pop();
+ } else { // now all other tags
+ var tag = {};
+ if (_.indexOf(TAGS_NO_PARAM, part) > -1) { /** b, strong, em, i **/
+ stack.push({tag: part});
+ } else if (part.startsWith('span')) { /** span **/
+ tag = {tag: 'span', attrs: {}};
+ var rStyle = /(?:\"|\;\s?)([a-zA-z\-]+)\:\s?([a-zA-Z0-9\-\#]+)/g, matchSpan;
+ while ((matchSpan = rStyle.exec(part)) !== null) {
+ switch (matchSpan[1]) {
+ case 'color':
+ tag.attrs.color = matchSpan[2].slice(1); // cut off the #
+ break;
+ case 'background-color':
+ tag.attrs.backgroundColor = matchSpan[2].slice(1); // cut off the #
+ break;
+ case 'text-decoration':
+ if (matchSpan[2] === 'underline') {
+ tag.attrs.underline = true;
+ } else if (matchSpan[2] === 'line-through') {
+ tag.attrs.strike = true;
+ }
+ break;
+ }
+ }
+ stack.push(tag);
+ } else if (part.startsWith('a')) { /** a **/
+ var rHref = /href="([^"]+)"/g;
+ var href = rHref.exec(part)[1];
+ tag = {tag: 'a', href: href};
+ stack.push(tag);
+ } else if (part.startsWith('img')) {
+ // images has to be placed instantly, so there is no use of 'tag'.
+ var img = {}, rImg = /(\w+)=\"([^\"]*)\"/g, matchImg;
+ while ((matchImg = rImg.exec(part)) !== null) {
+ img[matchImg[1]] = matchImg[2];
+ }
+
+ // With and height and source have to be given!
+ if (img.width && img.height && img.src) {
+ var rrId = relationships.length + 1;
+ var imgId = images.length + 1;
+
+ // set name ('pic.jpg'), title, ext ('jpg'), mime ('image/jpeg')
+ img.name = img.src.split('/');
+ img.name = _.last(img.name);
+
+ var tmp = img.name.split('.');
+ // set name without extension as title if there isn't a title
+ if (!img.title) {
+ img.title = tmp[0];
+ }
+ img.ext = tmp[1];
+
+ img.mime = 'image/' + img.ext;
+ if (img.ext == 'jpe' || img.ext == 'jpg') {
+ img.mime = 'image/jpeg';
+ }
+
+ // x and y for the container and picture size in EMU (assuming 96dpi)!
+ var x = img.width * 914400 / 96;
+ var y = img.height * 914400 / 96;
+
+ // Own paragraph for the image
+ if (hasParagraph) {
+ docx += '';
+ }
+ docx += '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '';
+
+ // hasParagraph stays untouched, the documents paragraph state is restored here
+ if (hasParagraph) {
+ docx += '';
+ }
+
+ // entries in images, relationships and contentTypes
+ images.push({
+ url: img.src,
+ zipPath: 'word/media/' + img.name
+ });
+ relationships.push({
+ Id: 'rrId' + rrId,
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image',
+ Target: 'media/' + img.name
+ });
+ contentTypes.push({
+ PartName: '/word/media/' + img.name,
+ ContentType: img.mime
+ });
+ }
+ }
+ }
+ } else { /** No tag **/
+ if (!hasParagraph) {
+ docx += '';
+ hasParagraph = true;
+ }
+ var docx_part = '';
+ var hyperlink = false;
+ stack.forEach(function (tag) {
+ switch (tag.tag) {
+ case 'b': case 'strong':
+ docx_part += '';
+ break;
+ case 'em': case 'i':
+ docx_part += '';
+ break;
+ case 'span':
+ for (var key in tag.attrs) {
+ switch (key) {
+ case 'color':
+ docx_part += '';
+ break;
+ case 'backgroundColor':
+ docx_part += '';
+ break;
+ case 'underline':
+ docx_part += '';
+ break;
+ case 'strike':
+ docx_part += '';
+ break;
+ }
+ }
+ break;
+ case 'a':
+ var id = relationships.length + 1;
+ docx_part = '' + docx_part;
+ docx_part += ''; // necessary?
+ relationships.push({
+ Id: 'rrId' + id,
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink',
+ Target: tag.href,
+ TargetMode: 'External'
+ });
+ hyperlink = true;
+ break;
+ }
+ });
+ docx_part += '' + part + '';
+ if (hyperlink) {
+ docx_part += '';
+ }
+
+ // append to docx
+ docx += docx_part;
+ }
+ isTag = !isTag;
+ }
+ if (part === '' || part == '\n') {
+ // just if two tags following eachother: --> ...,'>', '', '<',...
+ // or there is a line break between: \n --> ...,'>', '\n', '<',...
+ isTag = !isTag;
+ }
+ });
+
+ // for finishing close the last paragraph (if open)
+ if (hasParagraph) {
+ docx += '';
+ }
+
+ // replacing of special symbols:
+ docx = docx.replace(new RegExp('\ä\;', 'g'), 'ä');
+ docx = docx.replace(new RegExp('\ü\;', 'g'), 'ü');
+ docx = docx.replace(new RegExp('\ö\;', 'g'), 'ö');
+ docx = docx.replace(new RegExp('\Ä\;', 'g'), 'Ä');
+ docx = docx.replace(new RegExp('\Ü\;', 'g'), 'Ü');
+ docx = docx.replace(new RegExp('\Ö\;', 'g'), 'Ö');
+ docx = docx.replace(new RegExp('\ß\;', 'g'), 'ß');
+ docx = docx.replace(new RegExp('\ \;', 'g'), ' ');
+ docx = docx.replace(new RegExp('\§\;', 'g'), '§');
+
+ // remove all entities except gt, lt and amp
+ var rEntity = /\&(?!gt|lt|amp)\w+\;/g, matchEntry, indexes = [];
+ while ((matchEntry = rEntity.exec(docx)) !== null) {
+ indexes.push({
+ startId: matchEntry.index,
+ stopId: matchEntry.index + matchEntry[0].length
+ });
+ }
+ for (var i = indexes.length - 1; i>=0; i--) {
+ docx = docx.substring(0, indexes[i].startId) + docx.substring(indexes[i].stopId, docx.length);
+ }
+
+ return docx;
+ };
+ var updateRelationships = function (oldContent) {
+ var content = oldContent.split('\n');
+ relationships.forEach(function (rel) {
+ content[1] += '';
+ });
+ return content.join('\n');
+ };
+ var updateContentTypes = function (oldContent) {
+ var content = oldContent.split('\n');
+ contentTypes.forEach(function (type) {
+ content[1] += '';
+ });
+ return content.join('\n');
+ };
+
+ return {
+ export: function (motions, categories) {
+ images = [];
+ relationships = [];
+ contentTypes = [];
+ $http.get('/motions/docxtemplate/').then(function (success) {
+ var content = window.atob(success.data);
+ var doc = new Docxgen(content);
+
+ doc.setData(getData(motions, categories));
+ doc.render();
+ var zip = doc.getZip();
+
+ // update relationships from 'relationships'
+ var rels = updateRelationships(zip.file('word/_rels/document.xml.rels').asText());
+ zip.file('word/_rels/document.xml.rels', rels);
+
+ // update content type from 'contentTypes'
+ var contentTypes = updateContentTypes(zip.file('[Content_Types].xml').asText());
+ zip.file('[Content_Types].xml', contentTypes);
+
+ var imgPromises = [];
+ images.forEach(function (img) {
+ imgPromises.push(
+ $http.get(img.url, {responseType: 'arraybuffer'}).then(function (resolvedImage) {
+ zip.file(img.zipPath, resolvedImage.data);
+ })
+ );
+ });
+ // wait for all images to be resolved
+ $q.all(imgPromises).then(function () {
+ var out = zip.generate({type: 'blob'});
+ FileSaver.saveAs(out, 'motions-export.docx');
+ });
+ });
+ },
+ };
+ }
+]);
+
+}());
diff --git a/openslides/motions/static/js/motions/site.js b/openslides/motions/static/js/motions/site.js
index 3f451bbd2..c9c88cff1 100644
--- a/openslides/motions/static/js/motions/site.js
+++ b/openslides/motions/static/js/motions/site.js
@@ -831,7 +831,8 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlid
'Workflow',
'User',
'Agenda',
- function($scope, $state, $http, ngDialog, MotionForm, Motion, Category, Tag, Workflow, User, Agenda) {
+ 'MotionDocxExport',
+ function($scope, $state, $http, ngDialog, MotionForm, Motion, Category, Tag, Workflow, User, Agenda, MotionDocxExport) {
Motion.bindAll({}, $scope, 'motions');
Category.bindAll({}, $scope, 'categories');
Tag.bindAll({}, $scope, 'tags');
@@ -989,9 +990,9 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlid
ngDialog.open(MotionForm.getDialog(motion));
};
- // Export the given motions as a csv file
+ // Export as a csv file
$scope.csv_export = function () {
- var element = document.getElementById('downloadLink');
+ var element = document.getElementById('downloadLinkCSV');
var csvRows = [
['identifier', 'title', 'text', 'reason', 'submitter', 'category', 'origin'],
];
@@ -1013,6 +1014,10 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlid
element.download = 'motions-export.csv';
element.target = '_blank';
};
+ // Export as docx file
+ $scope.docx_export = function () {
+ MotionDocxExport.export($scope.motionsFiltered, $scope.categories);
+ };
// *** delete mode functions ***
$scope.isDeleteMode = false;
diff --git a/openslides/motions/static/templates/docx/motions.docx b/openslides/motions/static/templates/docx/motions.docx
new file mode 100644
index 000000000..200f37e9c
Binary files /dev/null and b/openslides/motions/static/templates/docx/motions.docx differ
diff --git a/openslides/motions/static/templates/motions/motion-list.html b/openslides/motions/static/templates/motions/motion-list.html
index 9d2ac8812..80269eeff 100644
--- a/openslides/motions/static/templates/motions/motion-list.html
+++ b/openslides/motions/static/templates/motions/motion-list.html
@@ -45,6 +45,7 @@
diff --git a/openslides/motions/urls.py b/openslides/motions/urls.py
index 9b37d60d8..d2359a1a2 100644
--- a/openslides/motions/urls.py
+++ b/openslides/motions/urls.py
@@ -3,6 +3,10 @@ from django.conf.urls import url
from . import views
urlpatterns = [
+ url(r'^docxtemplate/$',
+ views.MotionDocxTemplateView.as_view(),
+ name='motions_docx_template'),
+
url(r'^pdf/$',
views.MotionPDFView.as_view(print_all_motions=True),
name='motions_pdf'),
diff --git a/openslides/motions/views.py b/openslides/motions/views.py
index 356241676..7678ee99d 100644
--- a/openslides/motions/views.py
+++ b/openslides/motions/views.py
@@ -1,3 +1,6 @@
+import base64
+
+from django.contrib.staticfiles import finders
from django.db import IntegrityError, transaction
from django.http import Http404
from django.utils.text import slugify
@@ -16,7 +19,7 @@ from openslides.utils.rest_api import (
ValidationError,
detail_route,
)
-from openslides.utils.views import PDFView, SingleObjectMixin
+from openslides.utils.views import APIView, PDFView, SingleObjectMixin
from .access_permissions import (
CategoryAccessPermissions,
@@ -462,7 +465,7 @@ class WorkflowViewSet(ModelViewSet):
return result
-# Views to generate PDFs
+# Views to generate PDFs and for the DOCX template
class MotionPollPDF(PDFView):
"""
@@ -555,3 +558,15 @@ class MotionPDFView(SingleObjectMixin, PDFView):
motions_to_pdf(pdf, motions)
else:
motion_to_pdf(pdf, self.get_object())
+
+
+class MotionDocxTemplateView(APIView):
+ """
+ Returns the template for motions docx export
+ """
+ http_method_names = ['get']
+
+ def get_context_data(self, **context):
+ with open(finders.find('templates/docx/motions.docx'), "rb") as file:
+ response = base64.b64encode(file.read())
+ return response