From c74232e9c589a5b26553f71a9faa22b2bdbf535f Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Fri, 14 Apr 2017 08:48:49 -0400 Subject: [PATCH 01/30] Added option to mark a list as not being allowed to be subscribed by public users using the form. The settings is a checkbox in list create/edit. --- lib/models/lists.js | 13 ++++++++----- meta.json | 2 +- routes/lists.js | 4 ++++ routes/subscription.js | 22 ++++++++++++++++------ setup/sql/upgrade-00026.sql | 11 +++++++++++ views/lists/create.hbs | 10 ++++++++++ views/lists/edit.hbs | 10 ++++++++++ 7 files changed, 60 insertions(+), 12 deletions(-) create mode 100644 setup/sql/upgrade-00026.sql diff --git a/lib/models/lists.js b/lib/models/lists.js index 02e7a4cd..def0f713 100644 --- a/lib/models/lists.js +++ b/lib/models/lists.js @@ -6,7 +6,7 @@ let shortid = require('shortid'); let segments = require('./segments'); let _ = require('../translate')._; -let allowedKeys = ['description', 'default_form']; +let allowedKeys = ['description', 'default_form', 'public_subscribe']; module.exports.list = (start, limit, callback) => { db.getConnection((err, connection) => { @@ -111,6 +111,8 @@ module.exports.get = (id, callback) => { module.exports.create = (list, callback) => { let data = tools.convertKeys(list); + data.publicSubscribe = data.publicSubscribe ? 1 : 0; + let name = (data.name || '').toString().trim(); if (!data) { @@ -120,8 +122,8 @@ module.exports.create = (list, callback) => { let keys = ['name']; let values = [name]; - Object.keys(list).forEach(key => { - let value = list[key].trim(); + Object.keys(data).forEach(key => { + let value = data[key].toString().trim(); key = tools.toDbKey(key); if (key === 'description') { value = tools.purifyHTML(value); @@ -169,6 +171,7 @@ module.exports.update = (id, updates, callback) => { id = Number(id) || 0; let data = tools.convertKeys(updates); + data.publicSubscribe = data.publicSubscribe ? 1 : 0; let name = (data.name || '').toString().trim(); let keys = ['name']; @@ -182,8 +185,8 @@ module.exports.update = (id, updates, callback) => { return callback(new Error(_('List Name must be set'))); } - Object.keys(updates).forEach(key => { - let value = updates[key].trim(); + Object.keys(data).forEach(key => { + let value = data[key].toString().trim(); key = tools.toDbKey(key); if (key === 'description') { value = tools.purifyHTML(value); diff --git a/meta.json b/meta.json index 1302fec6..8e5bc365 100644 --- a/meta.json +++ b/meta.json @@ -1,3 +1,3 @@ { - "schemaVersion": 25 + "schemaVersion": 26 } diff --git a/routes/lists.js b/routes/lists.js index c82b7c2b..198fb7df 100644 --- a/routes/lists.js +++ b/routes/lists.js @@ -82,6 +82,10 @@ router.get('/create', passport.csrfProtection, (req, res) => { data.csrfToken = req.csrfToken(); + if (!('publicSubscribe' in data)) { + data.publicSubscribe = true; + } + res.render('lists/create', data); }); diff --git a/routes/subscription.js b/routes/subscription.js index 70581457..0e91eb98 100644 --- a/routes/subscription.js +++ b/routes/subscription.js @@ -176,9 +176,14 @@ router.get('/subscribe/:cid', (req, res, next) => { router.get('/:cid', passport.csrfProtection, (req, res, next) => { lists.getByCid(req.params.cid, (err, list) => { - if (!err && !list) { - err = new Error(_('Selected list not found')); - err.status = 404; + if (!err) { + if (!list) { + err = new Error(_('Selected list not found')); + err.status = 404; + } else if (!list.publicSubscribe) { + err = new Error(_('The list does not allow public subscriptions.')); + err.status = 403; + } } if (err) { @@ -501,9 +506,14 @@ router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, r let testsPass = subTimeTest && addressTest; lists.getByCid(req.params.cid, (err, list) => { - if (!err && !list) { - err = new Error(_('Selected list not found')); - err.status = 404; + if (!err) { + if (!list) { + err = new Error(_('Selected list not found')); + err.status = 404; + } else if (!list.publicSubscribe) { + err = new Error(_('The list does not allow public subscriptions.')); + err.status = 403; + } } if (err) { diff --git a/setup/sql/upgrade-00026.sql b/setup/sql/upgrade-00026.sql new file mode 100644 index 00000000..3fac9282 --- /dev/null +++ b/setup/sql/upgrade-00026.sql @@ -0,0 +1,11 @@ +# Header section +# Define incrementing schema version number +SET @schema_version = '26'; + +# Add field +ALTER TABLE `lists` ADD COLUMN `public_subscribe` tinyint(1) unsigned DEFAULT 1 NOT NULL AFTER `created`; + +# Footer section +LOCK TABLES `settings` WRITE; +INSERT INTO `settings` (`key`, `value`) VALUES('db_schema_version', @schema_version) ON DUPLICATE KEY UPDATE `value`=@schema_version; +UNLOCK TABLES; diff --git a/views/lists/create.hbs b/views/lists/create.hbs index 1ec01821..6cfaa7b8 100644 --- a/views/lists/create.hbs +++ b/views/lists/create.hbs @@ -26,6 +26,16 @@
+
+
+ +
+
+ +
+
diff --git a/views/lists/edit.hbs b/views/lists/edit.hbs index 62d01f7f..5d206b50 100644 --- a/views/lists/edit.hbs +++ b/views/lists/edit.hbs @@ -56,6 +56,16 @@
+
+
+ +
+
+ +
+
From e5190c9b206f8108a59e854497559ffa9e961286 Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Sat, 15 Apr 2017 08:24:58 -0400 Subject: [PATCH 02/30] Code de-duplication of list and filter methods. The common functionality moved to table-helpers.js This should make developing new table-based views easier. --- lib/models/campaigns.js | 244 +++--------------------------------- lib/models/lists.js | 21 +--- lib/models/subscriptions.js | 103 ++------------- lib/models/templates.js | 21 +--- lib/models/triggers.js | 69 +--------- lib/table-helpers.js | 100 +++++++++++++++ 6 files changed, 137 insertions(+), 421 deletions(-) create mode 100644 lib/table-helpers.js diff --git a/lib/models/campaigns.js b/lib/models/campaigns.js index fca3b4c7..1bcabcc8 100644 --- a/lib/models/campaigns.js +++ b/lib/models/campaigns.js @@ -14,186 +14,40 @@ let mailer = require('../mailer'); let humanize = require('humanize'); let _ = require('../translate')._; let util = require('util'); +let tableHelpers = require('../table-helpers'); let allowedKeys = ['description', 'from', 'address', 'reply_to', 'subject', 'editor_name', 'editor_data', 'template', 'source_url', 'list', 'segment', 'html', 'text', 'tracking_disabled']; module.exports.list = (start, limit, callback) => { - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } - - connection.query('SELECT SQL_CALC_FOUND_ROWS * FROM campaigns ORDER BY scheduled DESC LIMIT ? OFFSET ?', [limit, start], (err, rows) => { - if (err) { - connection.release(); - return callback(err); - } - connection.query('SELECT FOUND_ROWS() AS total', (err, total) => { - connection.release(); - if (err) { - return callback(err); - } - return callback(null, rows, total && total[0] && total[0].total); - }); - }); - }); + tableHelpers.list('campaigns', 'scheduled', start, limit, callback); }; module.exports.filter = (request, parent, callback) => { - let columns = ['#', 'name', 'description', 'status', 'created']; - let processQuery = queryData => { - - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } - - let query = 'SELECT COUNT(id) AS total FROM `campaigns`'; - let values = []; - - if (queryData.where) { - query += ' WHERE ' + queryData.where; - values = values.concat(queryData.values || []); - } - - connection.query(query, values, (err, total) => { - if (err) { - connection.release(); - return callback(err); - } - total = total && total[0] && total[0].total || 0; - - let ordering = []; - - if (request.order && request.order.length) { - - request.order.forEach(order => { - let orderField = columns[Number(order.column)]; - let orderDirection = (order.dir || '').toString().toLowerCase() === 'desc' ? 'DESC' : 'ASC'; - if (orderField) { - ordering.push('`' + orderField + '` ' + orderDirection); - } - }); - } - - if (!ordering.length) { - ordering.push('`created` DESC'); - } - - let args = [Number(request.length) || 50, Number(request.start) || 0]; - let query; - - if (request.search && request.search.value) { - query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `campaigns` WHERE name LIKE ? ' + (queryData.where ? ' AND (' + queryData.where + ')' : '') + ' ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?'; - - let searchVal = '%' + request.search.value.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1') + '%'; - args = [searchVal].concat(queryData.values || []).concat(args); - } else { - query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `campaigns` WHERE 1 ' + (queryData.where ? ' AND (' + queryData.where + ')' : '') + ' ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?'; - args = [].concat(queryData.values || []).concat(args); - } - - connection.query(query, args, (err, rows) => { - if (err) { - connection.release(); - return callback(err); - } - connection.query('SELECT FOUND_ROWS() AS total', (err, filteredTotal) => { - connection.release(); - if (err) { - return callback(err); - } - - let subscriptions = rows.map(row => tools.convertKeys(row)); - - filteredTotal = filteredTotal && filteredTotal[0] && filteredTotal[0].total || 0; - return callback(null, subscriptions, total, filteredTotal); - }); - }); - }); - }); - }; - + let queryData; if (parent) { - processQuery({ + queryData = { // only find normal and RSS parent campaigns at this point where: '`parent`=?', values: [parent] - }); + }; } else { - - processQuery({ + queryData = { // only find normal and RSS parent campaigns at this point where: '`type` IN (?,?,?)', values: [1, 2, 4] - }); + }; } + + tableHelpers.filter('campaigns', request, ['#', 'name', 'description', 'status', 'created'], ['name'], 'created DESC', queryData, callback); }; module.exports.filterClickedSubscribers = (campaign, linkId, request, columns, callback) => { - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } + let queryData = { + where: 'campaign_tracker__' + campaign.id + '.list=? AND campaign_tracker__' + campaign.id + '.link=?', + values: [campaign.list, linkId] + }; - let query = 'SELECT COUNT(`subscription__' + campaign.list + '`.`id`) AS total FROM `subscription__' + campaign.list + '` JOIN `campaign_tracker__' + campaign.id + '` ON `campaign_tracker__' + campaign.id + '`.`list`=? AND `campaign_tracker__' + campaign.id + '`.`subscriber`=`subscription__' + campaign.list + '`.`id` AND `campaign_tracker__' + campaign.id + '`.`link`=?'; - let values = [campaign.list, linkId]; - - connection.query(query, values, (err, total) => { - if (err) { - connection.release(); - return callback(err); - } - total = total && total[0] && total[0].total || 0; - - let ordering = []; - - if (request.order && request.order.length) { - - request.order.forEach(order => { - let orderField = columns[Number(order.column)]; - let orderDirection = (order.dir || '').toString().toLowerCase() === 'desc' ? 'DESC' : 'ASC'; - if (orderField) { - ordering.push('`' + orderField + '` ' + orderDirection); - } - }); - } - - if (!ordering.length) { - ordering.push('`email` ASC'); - } - - let args = [Number(request.length) || 50, Number(request.start) || 0]; - - if (request.search && request.search.value) { - query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `subscription__' + campaign.list + '` JOIN `campaign_tracker__' + campaign.id + '` ON `campaign_tracker__' + campaign.id + '`.`list`=? AND `campaign_tracker__' + campaign.id + '`.`subscriber`=`subscription__' + campaign.list + '`.`id` AND `campaign_tracker__' + campaign.id + '`.`link`=? WHERE email LIKE ? OR first_name LIKE ? OR last_name LIKE ? ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?'; - - let searchVal = '%' + request.search.value.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1') + '%'; - args = values.concat([searchVal, searchVal, searchVal]).concat(args); - } else { - query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `subscription__' + campaign.list + '` JOIN `campaign_tracker__' + campaign.id + '` ON `campaign_tracker__' + campaign.id + '`.`list`=? AND `campaign_tracker__' + campaign.id + '`.`subscriber`=`subscription__' + campaign.list + '`.`id` AND `campaign_tracker__' + campaign.id + '`.`link`=? ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?'; - args = values.concat(args); - } - - connection.query(query, args, (err, rows) => { - if (err) { - connection.release(); - return callback(err); - } - connection.query('SELECT FOUND_ROWS() AS total', (err, filteredTotal) => { - connection.release(); - if (err) { - return callback(err); - } - - let subscriptions = rows.map(row => tools.convertKeys(row)); - - filteredTotal = filteredTotal && filteredTotal[0] && filteredTotal[0].total || 0; - return callback(null, subscriptions, total, filteredTotal); - }); - }); - }); - }); + tableHelpers.filter('subscription__' + campaign.list + ' JOIN campaign_tracker__' + campaign.id + ' ON campaign_tracker__' + campaign.id + '.subscriber=subscription__' + campaign.list + '.id', request, columns, ['email', 'first_name', 'last_name'], 'email ASC', queryData, callback); }; module.exports.statsClickedSubscribersByColumn = (campaign, linkId, request, column, limit, callback) => { @@ -233,72 +87,12 @@ module.exports.statsClickedSubscribersByColumn = (campaign, linkId, request, col }; module.exports.filterStatusSubscribers = (campaign, status, request, columns, callback) => { - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } - - status = Number(status) || 0; - let query = 'SELECT COUNT(`subscription__' + campaign.list + '`.`id`) AS total FROM `subscription__' + campaign.list + '` JOIN `campaign__' + campaign.id + '` ON `campaign__' + campaign.id + '`.`list`=? AND `campaign__' + campaign.id + '`.`segment`=? AND `campaign__' + campaign.id + '`.`subscription`=`subscription__' + campaign.list + '`.`id` WHERE `campaign__' + campaign.id + '`.`status`=?'; - let values = [campaign.list, campaign.segment && campaign.segment.id || 0, status]; - - connection.query(query, values, (err, total) => { - if (err) { - - connection.release(); - return callback(err); - } - total = total && total[0] && total[0].total || 0; - - let ordering = []; - - if (request.order && request.order.length) { - - request.order.forEach(order => { - let orderField = columns[Number(order.column)]; - let orderDirection = (order.dir || '').toString().toLowerCase() === 'desc' ? 'DESC' : 'ASC'; - if (orderField) { - ordering.push('`' + orderField + '` ' + orderDirection); - } - }); - } - - if (!ordering.length) { - ordering.push('`email` ASC'); - } - - let args = [Number(request.length) || 50, Number(request.start) || 0]; - - if (request.search && request.search.value) { - query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `subscription__' + campaign.list + '` JOIN `campaign__' + campaign.id + '` ON `campaign__' + campaign.id + '`.`list`=? AND `campaign__' + campaign.id + '`.`segment`=? AND `campaign__' + campaign.id + '`.`subscription`=`subscription__' + campaign.list + '`.`id` WHERE `campaign__' + campaign.id + '`.`status`=? AND (email LIKE ? OR first_name LIKE ? OR last_name LIKE ?) ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?'; - - let searchVal = '%' + request.search.value.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1') + '%'; - args = values.concat([searchVal, searchVal, searchVal]).concat(args); - } else { - query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `subscription__' + campaign.list + '` JOIN `campaign__' + campaign.id + '` ON `campaign__' + campaign.id + '`.`list`=? AND `campaign__' + campaign.id + '`.`segment`=? AND `campaign__' + campaign.id + '`.`subscription`=`subscription__' + campaign.list + '`.`id` WHERE `campaign__' + campaign.id + '`.`status`=? ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?'; - args = values.concat(args); - } - - connection.query(query, args, (err, rows) => { - if (err) { - connection.release(); - return callback(err); - } - connection.query('SELECT FOUND_ROWS() AS total', (err, filteredTotal) => { - connection.release(); - if (err) { - return callback(err); - } - - let subscriptions = rows.map(row => tools.convertKeys(row)); - - filteredTotal = filteredTotal && filteredTotal[0] && filteredTotal[0].total || 0; - return callback(null, subscriptions, total, filteredTotal); - }); - }); - }); - }); + let queryData = { + where: 'campaign__' + campaign.id + '.list=? AND campaign__' + campaign.id + '.segment=? AND campaign__' + campaign.id + '.status=?', + values: [campaign.list, campaign.segment && campaign.segment.id || 0, status] + }; + tableHelpers.filter('subscription__' + campaign.list + ' JOIN campaign__' + campaign.id + ' ON campaign__' + campaign.id + '.subscription=subscription__' + campaign.list + '.id', request, columns, ['email', 'first_name', 'last_name'], 'email ASC', queryData, callback); }; module.exports.getByCid = (cid, callback) => { diff --git a/lib/models/lists.js b/lib/models/lists.js index def0f713..feaadd08 100644 --- a/lib/models/lists.js +++ b/lib/models/lists.js @@ -5,29 +5,12 @@ let tools = require('../tools'); let shortid = require('shortid'); let segments = require('./segments'); let _ = require('../translate')._; +let tableHelpers = require('../table-helpers'); let allowedKeys = ['description', 'default_form', 'public_subscribe']; module.exports.list = (start, limit, callback) => { - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } - - connection.query('SELECT SQL_CALC_FOUND_ROWS * FROM lists ORDER BY name LIMIT ? OFFSET ?', [limit, start], (err, rows) => { - if (err) { - connection.release(); - return callback(err); - } - connection.query('SELECT FOUND_ROWS() AS total', (err, total) => { - connection.release(); - if (err) { - return callback(err); - } - return callback(null, rows, total && total[0] && total[0].total); - }); - }); - }); + tableHelpers.list('lists', 'name', start, limit, callback); }; module.exports.quicklist = callback => { diff --git a/lib/models/subscriptions.js b/lib/models/subscriptions.js index 33641063..7d0b83ee 100644 --- a/lib/models/subscriptions.js +++ b/lib/models/subscriptions.js @@ -13,6 +13,7 @@ let urllib = require('url'); let log = require('npmlog'); let _ = require('../translate')._; let util = require('util'); +let tableHelpers = require('../table-helpers'); module.exports.list = (listId, start, limit, callback) => { listId = Number(listId) || 0; @@ -20,26 +21,11 @@ module.exports.list = (listId, start, limit, callback) => { return callback(new Error('Missing List ID')); } - db.getConnection((err, connection) => { - if (err) { - return callback(err); + tableHelpers.list('subscription__' + listId, 'email', start, limit, (err, rows, total) => { + if (!err) { + rows = rows.map(row => tools.convertKeys(row)); } - - connection.query('SELECT SQL_CALC_FOUND_ROWS * FROM `subscription__' + listId + '` ORDER BY email LIMIT ? OFFSET ?', [limit, start], (err, rows) => { - if (err) { - connection.release(); - return callback(err); - } - connection.query('SELECT FOUND_ROWS() AS total', (err, total) => { - connection.release(); - if (err) { - return callback(err); - } - - let subscriptions = rows.map(row => tools.convertKeys(row)); - return callback(null, subscriptions, total && total[0] && total[0].total); - }); - }); + return callback(err, rows, total); }); }; @@ -80,7 +66,6 @@ module.exports.listTestUsers = (listId, callback) => { }); }; - module.exports.filter = (listId, request, columns, segmentId, callback) => { listId = Number(listId) || 0; segmentId = Number(segmentId) || 0; @@ -89,88 +74,16 @@ module.exports.filter = (listId, request, columns, segmentId, callback) => { return callback(new Error(_('Missing List ID'))); } - let processQuery = queryData => { - - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } - - let query = 'SELECT COUNT(id) AS total FROM `subscription__' + listId + '`'; - let values = []; - - if (queryData.where) { - query += ' WHERE ' + queryData.where; - values = values.concat(queryData.values || []); - } - - connection.query(query, values, (err, total) => { - if (err) { - connection.release(); - return callback(err); - } - total = total && total[0] && total[0].total || 0; - - let ordering = []; - - if (request.order && request.order.length) { - - request.order.forEach(order => { - let orderField = columns[Number(order.column)]; - let orderDirection = (order.dir || '').toString().toLowerCase() === 'desc' ? 'DESC' : 'ASC'; - if (orderField) { - ordering.push('`' + orderField + '` ' + orderDirection); - } - }); - } - - if (!ordering.length) { - ordering.push('`email` ASC'); - } - - let args = [Number(request.length) || 50, Number(request.start) || 0]; - let query; - - if (request.search && request.search.value) { - query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `subscription__' + listId + '` WHERE email LIKE ? OR first_name LIKE ? OR last_name LIKE ? ' + (queryData.where ? ' AND (' + queryData.where + ')' : '') + ' ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?'; - - let searchVal = '%' + request.search.value.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1') + '%'; - args = [searchVal, searchVal, searchVal].concat(queryData.values || []).concat(args); - } else { - query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `subscription__' + listId + '` WHERE 1 ' + (queryData.where ? ' AND (' + queryData.where + ')' : '') + ' ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?'; - args = [].concat(queryData.values || []).concat(args); - } - - connection.query(query, args, (err, rows) => { - if (err) { - connection.release(); - return callback(err); - } - connection.query('SELECT FOUND_ROWS() AS total', (err, filteredTotal) => { - connection.release(); - if (err) { - return callback(err); - } - - let subscriptions = rows.map(row => tools.convertKeys(row)); - - filteredTotal = filteredTotal && filteredTotal[0] && filteredTotal[0].total || 0; - return callback(null, subscriptions, total, filteredTotal); - }); - }); - }); - }); - }; - if (segmentId) { segments.getQuery(segmentId, false, (err, queryData) => { if (err) { return callback(err); } - processQuery(queryData); + + tableHelpers.filter('subscription__' + listId, request, columns, ['email', 'first_name', 'last_name'], 'email ASC', queryData, callback); }); } else { - processQuery(false); + tableHelpers.filter('subscription__' + listId, request, columns, ['email', 'first_name', 'last_name'], 'email ASC', null, callback); } }; diff --git a/lib/models/templates.js b/lib/models/templates.js index 4171ebb1..48fde524 100644 --- a/lib/models/templates.js +++ b/lib/models/templates.js @@ -3,29 +3,12 @@ let db = require('../db'); let tools = require('../tools'); let _ = require('../translate')._; +let tableHelpers = require('../table-helpers'); let allowedKeys = ['description', 'editor_name', 'editor_data', 'html', 'text']; module.exports.list = (start, limit, callback) => { - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } - - connection.query('SELECT SQL_CALC_FOUND_ROWS * FROM templates ORDER BY name LIMIT ? OFFSET ?', [limit, start], (err, rows) => { - if (err) { - connection.release(); - return callback(err); - } - connection.query('SELECT FOUND_ROWS() AS total', (err, total) => { - connection.release(); - if (err) { - return callback(err); - } - return callback(null, rows, total && total[0] && total[0].total); - }); - }); - }); + tableHelpers.list('templates', 'name', start, limit, callback); }; module.exports.quicklist = callback => { diff --git a/lib/models/triggers.js b/lib/models/triggers.js index cd682cda..a3658240 100644 --- a/lib/models/triggers.js +++ b/lib/models/triggers.js @@ -5,6 +5,7 @@ let db = require('../db'); let lists = require('./lists'); let util = require('util'); let _ = require('../translate')._; +let tableHelpers = require('../table-helpers'); module.exports.defaultColumns = [{ column: 'created', @@ -339,70 +340,12 @@ module.exports.delete = (id, callback) => { }; module.exports.filterSubscribers = (trigger, request, columns, callback) => { - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } - - let query = 'SELECT COUNT(`subscription__' + trigger.list + '`.`id`) AS total FROM `subscription__' + trigger.list + '` JOIN `trigger__' + trigger.id + '` ON `trigger__' + trigger.id + '`.`list`=? AND `trigger__' + trigger.id + '`.`subscription`=`subscription__' + trigger.list + '`.`id`'; - let values = [trigger.list]; - - connection.query(query, values, (err, total) => { - if (err) { - connection.release(); - return callback(err); - } - total = total && total[0] && total[0].total || 0; - - let ordering = []; - - if (request.order && request.order.length) { - - request.order.forEach(order => { - let orderField = columns[Number(order.column)]; - let orderDirection = (order.dir || '').toString().toLowerCase() === 'desc' ? 'DESC' : 'ASC'; - if (orderField) { - ordering.push('`' + orderField + '` ' + orderDirection); - } - }); - } - - if (!ordering.length) { - ordering.push('`email` ASC'); - } - - let args = [Number(request.length) || 50, Number(request.start) || 0]; - - if (request.search && request.search.value) { - query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `subscription__' + trigger.list + '` JOIN `trigger__' + trigger.id + '` ON `trigger__' + trigger.id + '`.`list`=? AND `trigger__' + trigger.id + '`.`subscription`=`subscription__' + trigger.list + '`.`id AND (email LIKE ? OR first_name LIKE ? OR last_name LIKE ?) ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?'; - - let searchVal = '%' + request.search.value.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1') + '%'; - args = values.concat([searchVal, searchVal, searchVal]).concat(args); - } else { - query = 'SELECT SQL_CALC_FOUND_ROWS * FROM `subscription__' + trigger.list + '` JOIN `trigger__' + trigger.id + '` ON `trigger__' + trigger.id + '`.`list`=? AND `trigger__' + trigger.id + '`.`subscription`=`subscription__' + trigger.list + '`.`id` ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?'; - args = values.concat(args); - } - - connection.query(query, args, (err, rows) => { - if (err) { - connection.release(); - return callback(err); - } - connection.query('SELECT FOUND_ROWS() AS total', (err, filteredTotal) => { - connection.release(); - if (err) { - return callback(err); - } - - let subscriptions = rows.map(row => tools.convertKeys(row)); - - filteredTotal = filteredTotal && filteredTotal[0] && filteredTotal[0].total || 0; - return callback(null, subscriptions, total, filteredTotal); - }); - }); - }); - }); + let queryData = { + where: 'trigger__' + trigger.id + '.list=?', + values: [trigger.list] + }; + tableHelpers.filter('subscription__' + trigger.list + ' JOIN trigger__' + trigger.id + ' ON trigger__' + trigger.id + '.subscription=subscription__' + trigger.list + '.id', request, columns, ['email', 'first_name', 'last_name'], 'email ASC', queryData, callback); }; function createTriggerTable(id, callback) { diff --git a/lib/table-helpers.js b/lib/table-helpers.js new file mode 100644 index 00000000..245e54b1 --- /dev/null +++ b/lib/table-helpers.js @@ -0,0 +1,100 @@ +'use strict'; + +let db = require('./db'); +let tools = require('./tools'); +let log = require('npmlog'); + +module.exports.list = (source, orderBy, start, limit, callback) => { + db.getConnection((err, connection) => { + if (err) { + return callback(err); + } + + connection.query('SELECT SQL_CALC_FOUND_ROWS * FROM ' + source + ' ORDER BY ' + orderBy + ' DESC LIMIT ? OFFSET ?', [limit, start], (err, rows) => { + if (err) { + connection.release(); + return callback(err); + } + connection.query('SELECT FOUND_ROWS() AS total', (err, total) => { + connection.release(); + if (err) { + return callback(err); + } + return callback(null, rows, total && total[0] && total[0].total); + }); + }); + }); +}; + + +module.exports.filter = (source, request, columns, searchFields, defaultOrdering, queryData, callback) => { + db.getConnection((err, connection) => { + if (err) { + return callback(err); + } + + let query = 'SELECT COUNT(*) AS total FROM ' + source; + let values = []; + + if (queryData) { + query += ' WHERE ' + queryData.where; + values = values.concat(queryData.values || []); + } + + connection.query(query, values, (err, total) => { + if (err) { + connection.release(); + return callback(err); + } + total = total && total[0] && total[0].total || 0; + + let ordering = []; + + if (request.order && request.order.length) { + + request.order.forEach(order => { + let orderField = columns[Number(order.column)]; + let orderDirection = (order.dir || '').toString().toLowerCase() === 'desc' ? 'DESC' : 'ASC'; + if (orderField) { + ordering.push('`' + orderField + '` ' + orderDirection); + } + }); + } + + if (!ordering.length) { + ordering.push(defaultOrdering); + } + + let searchWhere = ''; + let searchArgs = []; + + if (request.search && request.search.value) { + let searchVal = '%' + request.search.value.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1') + '%'; + + searchWhere = searchFields.map(field => field + ' LIKE ?').join(' OR '); + searchArgs = searchFields.map(field => searchVal) + } + + let query = 'SELECT SQL_CALC_FOUND_ROWS * FROM ' + source +' WHERE ' + (searchWhere ? '(' + searchWhere + ')': '1') + (queryData ? ' AND (' + queryData.where + ')' : '') + ' ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?'; + let args = searchArgs.concat(queryData ? queryData.values : []).concat([Number(request.length) || 50, Number(request.start) || 0]); + + connection.query(query, args, (err, rows) => { + if (err) { + connection.release(); + return callback(err); + } + connection.query('SELECT FOUND_ROWS() AS total', (err, filteredTotal) => { + connection.release(); + if (err) { + return callback(err); + } + + rows = rows.map(row => tools.convertKeys(row)); + + filteredTotal = filteredTotal && filteredTotal[0] && filteredTotal[0].total || 0; + return callback(null, rows, total, filteredTotal); + }); + }); + }); + }); +}; From 9fdf52674ef033c67fdf25c7f50bc5570b235965 Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Sun, 16 Apr 2017 03:22:32 -0400 Subject: [PATCH 03/30] Lists and Templates overviews refactored to use ajax. Before the refactoring, they behaved and looked a bit different to the other (Ajax) tables. The main difference in the behavior was in the row numbers (1st column) when sort order was switched. The non-ajax tables rearranged the numbers in the 1st column while the ajax-tables didn't. Some small tweaks in table-helpers to allow selecting which fields are pulled from DB (and how they are renamed). --- lib/models/campaigns.js | 8 +-- lib/models/lists.js | 6 +- lib/models/subscriptions.js | 6 +- lib/models/templates.js | 20 ++---- lib/models/triggers.js | 2 +- lib/table-helpers.js | 29 +++++++-- public/javascript/tables.js | 111 +++++++++++++++++----------------- routes/campaigns.js | 74 +++++++++++++++++++++++ routes/lists.js | 45 ++++++++------ routes/templates.js | 43 +++++++------ views/lists/lists.hbs | 69 ++++++--------------- views/templates/templates.hbs | 25 +------- 12 files changed, 246 insertions(+), 192 deletions(-) diff --git a/lib/models/campaigns.js b/lib/models/campaigns.js index 1bcabcc8..5c53f2e5 100644 --- a/lib/models/campaigns.js +++ b/lib/models/campaigns.js @@ -19,7 +19,7 @@ let tableHelpers = require('../table-helpers'); let allowedKeys = ['description', 'from', 'address', 'reply_to', 'subject', 'editor_name', 'editor_data', 'template', 'source_url', 'list', 'segment', 'html', 'text', 'tracking_disabled']; module.exports.list = (start, limit, callback) => { - tableHelpers.list('campaigns', 'scheduled', start, limit, callback); + tableHelpers.list('campaigns', ['*'], 'scheduled', start, limit, callback); }; module.exports.filter = (request, parent, callback) => { @@ -38,7 +38,7 @@ module.exports.filter = (request, parent, callback) => { }; } - tableHelpers.filter('campaigns', request, ['#', 'name', 'description', 'status', 'created'], ['name'], 'created DESC', queryData, callback); + tableHelpers.filter('campaigns', ['*'], request, ['#', 'name', 'description', 'status', 'created'], ['name'], 'created DESC', queryData, callback); }; module.exports.filterClickedSubscribers = (campaign, linkId, request, columns, callback) => { @@ -47,7 +47,7 @@ module.exports.filterClickedSubscribers = (campaign, linkId, request, columns, c values: [campaign.list, linkId] }; - tableHelpers.filter('subscription__' + campaign.list + ' JOIN campaign_tracker__' + campaign.id + ' ON campaign_tracker__' + campaign.id + '.subscriber=subscription__' + campaign.list + '.id', request, columns, ['email', 'first_name', 'last_name'], 'email ASC', queryData, callback); + tableHelpers.filter('subscription__' + campaign.list + ' JOIN campaign_tracker__' + campaign.id + ' ON campaign_tracker__' + campaign.id + '.subscriber=subscription__' + campaign.list + '.id', ['*'], request, columns, ['email', 'first_name', 'last_name'], 'email ASC', queryData, callback); }; module.exports.statsClickedSubscribersByColumn = (campaign, linkId, request, column, limit, callback) => { @@ -92,7 +92,7 @@ module.exports.filterStatusSubscribers = (campaign, status, request, columns, ca values: [campaign.list, campaign.segment && campaign.segment.id || 0, status] }; - tableHelpers.filter('subscription__' + campaign.list + ' JOIN campaign__' + campaign.id + ' ON campaign__' + campaign.id + '.subscription=subscription__' + campaign.list + '.id', request, columns, ['email', 'first_name', 'last_name'], 'email ASC', queryData, callback); + tableHelpers.filter('subscription__' + campaign.list + ' JOIN campaign__' + campaign.id + ' ON campaign__' + campaign.id + '.subscription=subscription__' + campaign.list + '.id', ['*'], request, columns, ['email', 'first_name', 'last_name'], 'email ASC', queryData, callback); }; module.exports.getByCid = (cid, callback) => { diff --git a/lib/models/lists.js b/lib/models/lists.js index feaadd08..dc1a0912 100644 --- a/lib/models/lists.js +++ b/lib/models/lists.js @@ -10,7 +10,11 @@ let tableHelpers = require('../table-helpers'); let allowedKeys = ['description', 'default_form', 'public_subscribe']; module.exports.list = (start, limit, callback) => { - tableHelpers.list('lists', 'name', start, limit, callback); + tableHelpers.list('lists', ['*'], 'name', start, limit, callback); +}; + +module.exports.filter = (request, parent, callback) => { + tableHelpers.filter('lists', ['*'], request, ['#', 'name', 'cid', 'subscribers', 'description'], ['name'], 'name ASC', null, callback); }; module.exports.quicklist = callback => { diff --git a/lib/models/subscriptions.js b/lib/models/subscriptions.js index 7d0b83ee..f5fb5f84 100644 --- a/lib/models/subscriptions.js +++ b/lib/models/subscriptions.js @@ -21,7 +21,7 @@ module.exports.list = (listId, start, limit, callback) => { return callback(new Error('Missing List ID')); } - tableHelpers.list('subscription__' + listId, 'email', start, limit, (err, rows, total) => { + tableHelpers.list('subscription__' + listId, ['*'], 'email', start, limit, (err, rows, total) => { if (!err) { rows = rows.map(row => tools.convertKeys(row)); } @@ -80,10 +80,10 @@ module.exports.filter = (listId, request, columns, segmentId, callback) => { return callback(err); } - tableHelpers.filter('subscription__' + listId, request, columns, ['email', 'first_name', 'last_name'], 'email ASC', queryData, callback); + tableHelpers.filter('subscription__' + listId, ['*'], request, columns, ['email', 'first_name', 'last_name'], 'email ASC', queryData, callback); }); } else { - tableHelpers.filter('subscription__' + listId, request, columns, ['email', 'first_name', 'last_name'], 'email ASC', null, callback); + tableHelpers.filter('subscription__' + listId, ['*'], request, columns, ['email', 'first_name', 'last_name'], 'email ASC', null, callback); } }; diff --git a/lib/models/templates.js b/lib/models/templates.js index 48fde524..09f0abb3 100644 --- a/lib/models/templates.js +++ b/lib/models/templates.js @@ -8,23 +8,15 @@ let tableHelpers = require('../table-helpers'); let allowedKeys = ['description', 'editor_name', 'editor_data', 'html', 'text']; module.exports.list = (start, limit, callback) => { - tableHelpers.list('templates', 'name', start, limit, callback); + tableHelpers.list('templates', ['*'], 'name', start, limit, callback); +}; + +module.exports.filter = (request, parent, callback) => { + tableHelpers.filter('templates', ['*'], request, ['#', 'name', 'description'], ['name'], 'name ASC', null, callback); }; module.exports.quicklist = callback => { - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } - - connection.query('SELECT id, name FROM templates ORDER BY name LIMIT 1000', (err, rows) => { - connection.release(); - if (err) { - return callback(err); - } - return callback(null, (rows || []).map(tools.convertKeys)); - }); - }); + tableHelpers.quicklist('templates', ['id', 'name'], 'name', callback); }; module.exports.get = (id, callback) => { diff --git a/lib/models/triggers.js b/lib/models/triggers.js index a3658240..35e424d3 100644 --- a/lib/models/triggers.js +++ b/lib/models/triggers.js @@ -345,7 +345,7 @@ module.exports.filterSubscribers = (trigger, request, columns, callback) => { values: [trigger.list] }; - tableHelpers.filter('subscription__' + trigger.list + ' JOIN trigger__' + trigger.id + ' ON trigger__' + trigger.id + '.subscription=subscription__' + trigger.list + '.id', request, columns, ['email', 'first_name', 'last_name'], 'email ASC', queryData, callback); + tableHelpers.filter('subscription__' + trigger.list + ' JOIN trigger__' + trigger.id + ' ON trigger__' + trigger.id + '.subscription=subscription__' + trigger.list + '.id', ['*'], request, columns, ['email', 'first_name', 'last_name'], 'email ASC', queryData, callback); }; function createTriggerTable(id, callback) { diff --git a/lib/table-helpers.js b/lib/table-helpers.js index 245e54b1..3ab373cc 100644 --- a/lib/table-helpers.js +++ b/lib/table-helpers.js @@ -4,13 +4,13 @@ let db = require('./db'); let tools = require('./tools'); let log = require('npmlog'); -module.exports.list = (source, orderBy, start, limit, callback) => { +module.exports.list = (source, fields, orderBy, start, limit, callback) => { db.getConnection((err, connection) => { if (err) { return callback(err); } - connection.query('SELECT SQL_CALC_FOUND_ROWS * FROM ' + source + ' ORDER BY ' + orderBy + ' DESC LIMIT ? OFFSET ?', [limit, start], (err, rows) => { + connection.query('SELECT SQL_CALC_FOUND_ROWS ' + fields.join(', ') + ' FROM ' + source + ' ORDER BY ' + orderBy + ' DESC LIMIT ? OFFSET ?', [limit, start], (err, rows) => { if (err) { connection.release(); return callback(err); @@ -26,8 +26,23 @@ module.exports.list = (source, orderBy, start, limit, callback) => { }); }; +module.exports.quicklist = (source, fields, orderBy, callback) => { + db.getConnection((err, connection) => { + if (err) { + return callback(err); + } -module.exports.filter = (source, request, columns, searchFields, defaultOrdering, queryData, callback) => { + connection.query('SELECT ' + fields.join(', ') + ' FROM ' + source + ' ORDER BY ' + orderBy + ' LIMIT 1000', (err, rows) => { + connection.release(); + if (err) { + return callback(err); + } + return callback(null, (rows || []).map(tools.convertKeys)); + }); + }); +}; + +module.exports.filter = (source, fields, request, columns, searchFields, defaultOrdering, queryData, callback) => { db.getConnection((err, connection) => { if (err) { return callback(err); @@ -41,6 +56,8 @@ module.exports.filter = (source, request, columns, searchFields, defaultOrdering values = values.concat(queryData.values || []); } + log.info("tableHelpers", query); + connection.query(query, values, (err, total) => { if (err) { connection.release(); @@ -56,7 +73,7 @@ module.exports.filter = (source, request, columns, searchFields, defaultOrdering let orderField = columns[Number(order.column)]; let orderDirection = (order.dir || '').toString().toLowerCase() === 'desc' ? 'DESC' : 'ASC'; if (orderField) { - ordering.push('`' + orderField + '` ' + orderDirection); + ordering.push(orderField + ' ' + orderDirection); } }); } @@ -75,9 +92,11 @@ module.exports.filter = (source, request, columns, searchFields, defaultOrdering searchArgs = searchFields.map(field => searchVal) } - let query = 'SELECT SQL_CALC_FOUND_ROWS * FROM ' + source +' WHERE ' + (searchWhere ? '(' + searchWhere + ')': '1') + (queryData ? ' AND (' + queryData.where + ')' : '') + ' ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?'; + let query = 'SELECT SQL_CALC_FOUND_ROWS ' + fields.join(', ') + ' FROM ' + source +' WHERE ' + (searchWhere ? '(' + searchWhere + ')': '1') + (queryData ? ' AND (' + queryData.where + ')' : '') + ' ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?'; let args = searchArgs.concat(queryData ? queryData.values : []).concat([Number(request.length) || 50, Number(request.start) || 0]); + log.info("tableHelpers", query); + connection.query(query, args, (err, rows) => { if (err) { connection.release(); diff --git a/public/javascript/tables.js b/public/javascript/tables.js index 7faad36b..5ed32987 100644 --- a/public/javascript/tables.js +++ b/public/javascript/tables.js @@ -4,73 +4,72 @@ 'use strict'; -$('.data-table').each(function () { - var rowSort = $(this).data('rowSort') || false; - var columns = false; +(function(){ + function getDataTableOptions(elem) { + var rowSort = $(elem).data('rowSort') || false; - if (rowSort) { - columns = rowSort.split(',').map(function (sort) { - return { - orderable: sort === '1' - }; - }); + var columns = false; + + var sortColumn = $(elem).data('sortColumn') === undefined ? 1 : Number($(elem).data('sortColumn')); + var sortOrder = ($(elem).data('sortOrder') || 'asc').toString().trim().toLowerCase(); + + var paging = $(elem).data('paging') === false ? false : true; + + // allow only asc and desc + if (sortOrder !== 'desc') { + sortOrder = 'asc'; + } + + var columnsCount = 0; + var columnsSort = [] + + if (rowSort) { + columns = rowSort.split(',').map(function (sort) { + return { + orderable: sort === '1' + }; + }); + } + + return { + scrollX: true, + order: [ + [sortColumn, sortOrder] + ], + columns: columns, + paging: paging, + info: paging, /* This controls the "Showing 1 to 16 of 16 entries" */ + pageLength: 50 + }; } - $(this).DataTable({ - scrollX: true, - order: [ - [1, 'asc'] - ], - columns: columns, - pageLength: 50 + $('.data-table').each(function () { + var opts = getDataTableOptions(this); + $(this).DataTable(opts); }); -}); -$('.data-table-ajax').each(function () { - var rowSort = $(this).data('rowSort') || false; - var columns = false; + $('.data-table-ajax').each(function () { + var topicUrl = $(this).data('topicUrl') || '/lists'; + var topicArgs = $(this).data('topicArgs') || false; + var topicId = $(this).data('topicId') || ''; - var topicUrl = $(this).data('topicUrl') || '/lists'; - var topicArgs = $(this).data('topicArgs') || false; - var topicId = $(this).data('topicId') || ''; + var ajaxUrl = topicUrl + '/ajax/' + topicId + (topicArgs ? '?' + topicArgs : ''); - var sortColumn = Number($(this).data('sortColumn')) || 1; - var sortOrder = ($(this).data('sortOrder') || 'asc').toString().trim().toLowerCase(); - - // allow only asc and desc - if (sortOrder !== 'desc') { - sortOrder = 'asc'; - } - - var ajaxUrl = topicUrl + '/ajax/' + topicId + (topicArgs ? '?' + topicArgs : ''); - - if (rowSort) { - columns = rowSort.split(',').map(function (sort) { - return { - orderable: sort === '1' - }; - }); - } - - $(this).DataTable({ - scrollX: true, - serverSide: true, - ajax: { + var opts = getDataTableOptions(this); + opts.ajax = { url: ajaxUrl, type: 'POST' - }, - order: [ - [sortColumn, sortOrder] - ], - columns: columns, - pageLength: 50, - processing: true - }).on('draw', function () { - $('.datestring').each(function () { - $(this).html(moment($(this).data('date')).fromNow()); + }; + opts.serverSide = true; + opts.processing = true; + + $(this).DataTable(opts).on('draw', function () { + $('.datestring').each(function () { + $(this).html(moment($(this).data('date')).fromNow()); + }); }); }); -}); +})(); $('.data-stats-pie-chart').each(function () { var column = $(this).data('column') || 'country'; diff --git a/routes/campaigns.js b/routes/campaigns.js index 08b3e230..18c15de9 100644 --- a/routes/campaigns.js +++ b/routes/campaigns.js @@ -673,6 +673,80 @@ router.post('/status/ajax/:id/:status', (req, res) => { }); }); +router.post('/clicked/ajax/:id/:linkId', (req, res) => { + let linkId = Number(req.params.linkId) || 0; + + campaigns.get(req.params.id, true, (err, campaign) => { + if (err || !campaign) { + return res.json({ + error: err && err.message || err || _('Campaign not found'), + data: [] + }); + } + lists.get(campaign.list, (err, list) => { + if (err) { + return res.json({ + error: err && err.message || err, + data: [] + }); + } + + let campaignCid = campaign.cid; + let listCid = list.cid; + + let columns = ['#', 'email', 'first_name', 'last_name', 'campaign_tracker__' + campaign.id + '`.`created', 'count']; + campaigns.filterClickedSubscribers(campaign, linkId, req.body, columns, (err, data, total, filteredTotal) => { + if (err) { + return res.json({ + error: err.message || err, + data: [] + }); + } + + res.json({ + draw: req.body.draw, + recordsTotal: total, + recordsFiltered: filteredTotal, + data: data.map((row, i) => [ + '' + ((Number(req.body.start) || 0) + 1 + i) + '', + htmlescape(row.email || ''), + htmlescape(row.firstName || ''), + htmlescape(row.lastName || ''), + row.created && row.created.toISOString ? '' + row.created.toISOString() + '' : 'N/A', + row.count, + '' + _('Edit') + '' + ]) + }); + }); + }); + }); +}); + +router.post('/selection/ajax', (req, res) => { + campaigns.filter(req.body, Number(req.query.parent) || false, (err, data, total, filteredTotal) => { + if (err) { + return res.json({ + error: err.message || err, + data: [] + }); + } + + res.json({ + draw: req.body.draw, + recordsTotal: total, + recordsFiltered: filteredTotal, + data: data.map((row, i) => [ + '', + (Number(req.body.start) || 0) + 1 + i, + ' ' + htmlescape(row.name || '') + '', + htmlescape(striptags(row.description) || ''), + '' + row.created.toISOString() + ''] + ) + }); + }); +}); + + router.post('/delete', passport.parseForm, passport.csrfProtection, (req, res) => { campaigns.delete(req.body.id, (err, deleted) => { if (err) { diff --git a/routes/lists.js b/routes/lists.js index 198fb7df..1ae18d7c 100644 --- a/routes/lists.js +++ b/routes/lists.js @@ -55,23 +55,8 @@ router.all('/*', (req, res, next) => { }); router.get('/', (req, res) => { - let limit = 999999999; - let start = 0; - - lists.list(start, limit, (err, rows, total) => { - if (err) { - req.flash('danger', err.message || err); - return res.redirect('/'); - } - - res.render('lists/lists', { - rows: rows.map((row, i) => { - row.index = start + i + 1; - row.description = striptags(row.description); - return row; - }), - total - }); + res.render('lists/lists', { + title: _('Lists') }); }); @@ -159,6 +144,32 @@ router.post('/delete', passport.parseForm, passport.csrfProtection, (req, res) = }); }); +router.post('/ajax', (req, res) => { + lists.filter(req.body, Number(req.query.parent) || false, (err, data, total, filteredTotal) => { + if (err) { + return res.json({ + error: err.message || err, + data: [] + }); + } + + res.json({ + draw: req.body.draw, + recordsTotal: total, + recordsFiltered: filteredTotal, + data: data.map((row, i) => [ + (Number(req.body.start) || 0) + 1 + i, + ' ' + htmlescape(row.name || '') + '', + '' + row.cid + '', + row.subscribers, + htmlescape(striptags(row.description) || ''), + '' + _('Edit') + '' ] + ) + }); + }); +}); + + router.post('/ajax/:id', (req, res) => { lists.get(req.params.id, (err, list) => { if (err || !list) { diff --git a/routes/templates.js b/routes/templates.js index 4b08ea86..3272df52 100644 --- a/routes/templates.js +++ b/routes/templates.js @@ -8,6 +8,7 @@ let settings = require('../lib/models/settings'); let tools = require('../lib/tools'); let helpers = require('../lib/helpers'); let striptags = require('striptags'); +let htmlescape = require('escape-html'); let passport = require('../lib/passport'); let mailer = require('../lib/mailer'); let _ = require('../lib/translate')._; @@ -22,23 +23,8 @@ router.all('/*', (req, res, next) => { }); router.get('/', (req, res) => { - let limit = 999999999; - let start = 0; - - templates.list(start, limit, (err, rows, total) => { - if (err) { - req.flash('danger', err.message || err); - return res.redirect('/'); - } - - res.render('templates/templates', { - rows: rows.map((row, i) => { - row.index = start + i + 1; - row.description = striptags(row.description); - return row; - }), - total - }); + res.render('templates/templates', { + title: _('Templates') }); }); @@ -164,4 +150,27 @@ router.post('/delete', passport.parseForm, passport.csrfProtection, (req, res) = }); }); +router.post('/ajax', (req, res) => { + templates.filter(req.body, Number(req.query.parent) || false, (err, data, total, filteredTotal) => { + if (err) { + return res.json({ + error: err.message || err, + data: [] + }); + } + + res.json({ + draw: req.body.draw, + recordsTotal: total, + recordsFiltered: filteredTotal, + data: data.map((row, i) => [ + (Number(req.body.start) || 0) + 1 + i, + ' ' + htmlescape(row.name || ''), + htmlescape(striptags(row.description) || ''), + '' + _('Edit') + '' ] + ) + }); + }); +}); + module.exports = router; diff --git a/views/lists/lists.hbs b/views/lists/lists.hbs index 64886833..93cc4918 100644 --- a/views/lists/lists.hbs +++ b/views/lists/lists.hbs @@ -12,57 +12,26 @@
- +
- - - - - - + + + + + + - {{#if rows}} - - {{#each rows}} - - - - - - - - - {{/each}} - - {{/if}}
- # - - {{#translate}}Name{{/translate}} - - {{#translate}}ID{{/translate}} - - {{#translate}}Subscribers{{/translate}} - - {{#translate}}Description{{/translate}} - -   - + # + + {{#translate}}Name{{/translate}} + + {{#translate}}ID{{/translate}} + + {{#translate}}Subscribers{{/translate}} + + {{#translate}}Description{{/translate}} + +   +
- {{index}} - - - - {{name}} - - - {{cid}} - - {{subscribers}} - - {{description}} - - - {{#translate}}Edit{{/translate}} - -
diff --git a/views/templates/templates.hbs b/views/templates/templates.hbs index ff751301..0314eb3e 100644 --- a/views/templates/templates.hbs +++ b/views/templates/templates.hbs @@ -12,7 +12,7 @@
- +
- {{#if rows}} - - {{#each rows}} - - - - - - - {{/each}} - - {{/if}}
# @@ -27,28 +27,5 @@  
- {{index}} - - {{name}} - -

{{description}}

-
- - - {{#translate}}Edit{{/translate}} - -
From 6ba04d7ff476a90c0479568b2fed77754b3b55a2 Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Sun, 16 Apr 2017 18:09:08 -0400 Subject: [PATCH 04/30] This is a preview of the Reports functionality. It allows defining report templates and then create reports based on the templates. A template defines: - parameters - to be set in the report (currently only selection of campaigns, in the future to be extended to selection of lists/segments, and selection from pre-defined options) - data retrieval / processing code (in Javascript) - rendering template (in Handlebars) This main functionality is accompanied by a few minor tweaks here and there. Worth notice is the ability to use server-side ajax table s for multi-selection of campaigns. This is meant for reports that compare data across multiple campaigns. This could possibly be even used for some poor man's A/B testing. Note that the execution of custom JavaScript in the data retrieval / processing code and definition of custom Handlebars templates is a security issue. This should however be OK in the general case once proper user management with granular permissions is in. This is because definition of a report template is anyway such an expert task that it would normally be performed only by admin. Instantiation of reports based on report templates can be then done by any user because this should no longer be any security problem. --- app.js | 29 ++ lib/models/campaigns.js | 4 + lib/models/report-templates.js | 161 ++++++++ lib/models/reports.js | 222 +++++++++++ lib/table-helpers.js | 2 - lib/tools.js | 4 + meta.json | 2 +- public/ace/mode-handlebars.js | 1 + public/ace/mode-javascript.js | 1 + public/ace/mode-json.js | 1 + public/ace/worker-javascript.js | 1 + public/ace/worker-json.js | 1 + public/css/mailtrain.css | 8 + public/javascript/editor.js | 6 + public/javascript/tables.js | 52 ++- routes/campaigns.js | 18 +- routes/report-templates.js | 282 ++++++++++++++ routes/reports.js | 361 ++++++++++++++++++ setup/install-centos7.sh | 210 ++++++++++ setup/sql/upgrade-00027.sql | 35 ++ views/report-templates/create.hbs | 24 ++ views/report-templates/edit.hbs | 36 ++ .../partials/report-template-fields.hbs | 59 +++ views/report-templates/report-templates.hbs | 44 +++ views/reports/create-select-template.hbs | 23 ++ views/reports/create.hbs | 23 ++ views/reports/edit.hbs | 34 ++ views/reports/partials/report-fields.hbs | 49 +++ .../partials/report-select-template.hbs | 11 + views/reports/reports.hbs | 39 ++ views/reports/view.hbs | 7 + 31 files changed, 1737 insertions(+), 13 deletions(-) create mode 100644 lib/models/report-templates.js create mode 100644 lib/models/reports.js create mode 100644 public/ace/mode-handlebars.js create mode 100644 public/ace/mode-javascript.js create mode 100644 public/ace/mode-json.js create mode 100644 public/ace/worker-javascript.js create mode 100644 public/ace/worker-json.js create mode 100644 routes/report-templates.js create mode 100644 routes/reports.js create mode 100644 setup/install-centos7.sh create mode 100644 setup/sql/upgrade-00027.sql create mode 100644 views/report-templates/create.hbs create mode 100644 views/report-templates/edit.hbs create mode 100644 views/report-templates/partials/report-template-fields.hbs create mode 100644 views/report-templates/report-templates.hbs create mode 100644 views/reports/create-select-template.hbs create mode 100644 views/reports/create.hbs create mode 100644 views/reports/edit.hbs create mode 100644 views/reports/partials/report-fields.hbs create mode 100644 views/reports/partials/report-select-template.hbs create mode 100644 views/reports/reports.hbs create mode 100644 views/reports/view.hbs diff --git a/app.js b/app.js index ab7880f2..a7082983 100644 --- a/app.js +++ b/app.js @@ -40,6 +40,8 @@ let blacklist = require('./routes/blacklist'); let editorapi = require('./routes/editorapi'); let grapejs = require('./routes/grapejs'); let mosaico = require('./routes/mosaico'); +let reports = require('./routes/reports'); +let reportsTemplates = require('./routes/report-templates'); let app = express(); @@ -57,6 +59,8 @@ app.disable('x-powered-by'); hbs.registerPartials(__dirname + '/views/partials'); hbs.registerPartials(__dirname + '/views/subscription/partials/'); +hbs.registerPartials(__dirname + '/views/report-templates/partials/'); +hbs.registerPartials(__dirname + '/views/reports/partials/'); /** * We need this helper to make sure that we consume flash messages only @@ -119,6 +123,29 @@ hbs.registerHelper('translate', function (context, options) { // eslint-disable- return new hbs.handlebars.SafeString(result); }); +/* Credits to http://chrismontrois.net/2016/01/30/handlebars-switch/ + + {{#switch letter}} + {{#case "a"}} + A is for alpaca + {{/case}} + {{#case "b"}} + B is for bluebird + {{/case}} + {{/switch}} + */ +hbs.registerHelper("switch", function(value, options) { + this._switch_value_ = value; + var html = options.fn(this); // Process the body of the switch block + delete this._switch_value_; + return html; +}); +hbs.registerHelper("case", function(value, options) { + if (value == this._switch_value_) { + return options.fn(this); + } +}); + app.use(compression()); app.use(favicon(path.join(__dirname, 'public', 'favicon.ico'))); @@ -221,6 +248,8 @@ app.use('/api', api); app.use('/editorapi', editorapi); app.use('/grapejs', grapejs); app.use('/mosaico', mosaico); +app.use('/reports', reports); +app.use('/report-templates', reportsTemplates); // catch 404 and forward to error handler app.use((req, res, next) => { diff --git a/lib/models/campaigns.js b/lib/models/campaigns.js index 5c53f2e5..e9007dcf 100644 --- a/lib/models/campaigns.js +++ b/lib/models/campaigns.js @@ -41,6 +41,10 @@ module.exports.filter = (request, parent, callback) => { tableHelpers.filter('campaigns', ['*'], request, ['#', 'name', 'description', 'status', 'created'], ['name'], 'created DESC', queryData, callback); }; +module.exports.filterQuicklist = (request, callback) => { + tableHelpers.filter('campaigns', ['id', 'name', 'description', 'created'], request, ['#', 'name', 'description', 'created'], ['name'], 'name ASC', null, callback); +}; + module.exports.filterClickedSubscribers = (campaign, linkId, request, columns, callback) => { let queryData = { where: 'campaign_tracker__' + campaign.id + '.list=? AND campaign_tracker__' + campaign.id + '.link=?', diff --git a/lib/models/report-templates.js b/lib/models/report-templates.js new file mode 100644 index 00000000..195472a3 --- /dev/null +++ b/lib/models/report-templates.js @@ -0,0 +1,161 @@ +'use strict'; + +const db = require('../db'); +const tableHelpers = require('../table-helpers'); +const tools = require('../tools'); +const _ = require('../translate')._; + +const allowedKeys = ['name', 'description', 'mime_type', 'user_fields', 'js', 'hbs']; + +module.exports.list = (start, limit, callback) => { + tableHelpers.list('report_templates', ['*'], 'name', start, limit, callback); +}; + +module.exports.quicklist = callback => { + tableHelpers.quicklist('report_templates', ['id', 'name'], 'name', callback); +}; + +module.exports.filter = (request, callback) => { + tableHelpers.filter('report_templates', ['*'], request, ['#', 'name', 'description', 'created'], ['name'], 'created DESC', null, callback); +}; + +module.exports.get = (id, callback) => { + id = Number(id) || 0; + + if (id < 1) { + return callback(new Error(_('Missing report template ID'))); + } + + db.getConnection((err, connection) => { + if (err) { + return callback(err); + } + + connection.query('SELECT * FROM report_templates WHERE id=?', [id], (err, rows) => { + connection.release(); + if (err) { + return callback(err); + } + + if (!rows || !rows.length) { + return callback(null, false); + } + + const template = tools.convertKeys(rows[0]); + + const userFields = template.userFields.trim(); + if (userFields != '') { + try { + template.userFieldsObject = JSON.parse(userFields); + } catch (err) { + // This is to handle situation when for some reason we get corrupted JSON in the DB. + template.userFieldsObject = {}; + template.userFields = "{}"; + } + } else { + template.userFieldsObject = {}; + } + + return callback(null, template); + }); + }); +}; + +module.exports.createOrUpdate = (createMode, data, callback) => { + data = data || {}; + + const id = 'id' in data ? Number(data.id) : 0; + + if (!createMode && id < 1) { + return callback(new Error(_('Missing report template ID'))); + } + + const template = tools.convertKeys(data); + const name = (template.name || '').toString().trim(); + + if (!name) { + return callback(new Error(_('Report template name must be set'))); + } + + const keys = ['name']; + const values = [name]; + + Object.keys(template).forEach(key => { + let value = typeof template[key] === 'number' ? template[key] : (template[key] || '').toString().trim(); + key = tools.toDbKey(key); + + if (key === 'description') { + value = tools.purifyHTML(value); + } + + if (key === 'user_fields') { + value = value.trim(); + + if (value != '') { + try { + JSON.parse(value); + } catch (err) { + return callback(err); + } + } + } + + if (allowedKeys.indexOf(key) >= 0 && keys.indexOf(key) < 0) { + keys.push(key); + values.push(value); + } + }); + + db.getConnection((err, connection) => { + if (err) { + return callback(err); + } + + let query; + + if (createMode) { + query = 'INSERT INTO report_templates (`' + keys.join('`, `') + '`) VALUES (' + values.map(() => '?').join(',') + ')'; + } else { + query = 'UPDATE report_templates SET ' + keys.map(key => '`' + key + '`=?').join(', ') + ' WHERE id=? LIMIT 1'; + values.push(id); + } + + connection.query(query, values, (err, result) => { + connection.release(); + if (err) { + return callback(err); + } + + if (createMode) { + return callback(null, result && result.insertId || false); + } else { + return callback(null, result && result.affectedRows || false) + } + }); + }); +}; + +module.exports.delete = (id, callback) => { + id = Number(id) || 0; + + if (id < 1) { + return callback(new Error(_('Missing report template ID'))); + } + + db.getConnection((err, connection) => { + if (err) { + return callback(err); + } + + connection.query('DELETE FROM report_templates WHERE id=? LIMIT 1', [id], (err, result) => { + connection.release(); + if (err) { + return callback(err); + } + + const affected = result && result.affectedRows || 0; + return callback(err, affected); + }); + }); +}; + diff --git a/lib/models/reports.js b/lib/models/reports.js new file mode 100644 index 00000000..57f3b1f5 --- /dev/null +++ b/lib/models/reports.js @@ -0,0 +1,222 @@ +'use strict'; + +const db = require('../db'); +const tableHelpers = require('../table-helpers'); +const fields = require('./fields'); +const reportTemplates = require('./report-templates'); +const tools = require('../tools'); +const _ = require('../translate')._; +const log = require('npmlog'); + +const allowedKeys = ['name', 'description', 'report_template', 'params']; + +module.exports.list = (start, limit, callback) => { + tableHelpers.list('reports', ['*'], 'name', start, limit, callback); +}; + +module.exports.filter = (request, callback) => { + tableHelpers.filter('reports JOIN report_templates ON reports.report_template = report_templates.id', + ['reports.id AS id', 'reports.name AS name', 'reports.description AS description', 'reports.report_template AS report_template', 'reports.params AS params', 'reports.created AS created', 'report_templates.name AS report_template_name' ], + request, ['#', 'name', 'report_templates.name', 'description', 'created'], ['name'], 'created DESC', null, callback); +}; + +module.exports.get = (id, callback) => { + id = Number(id) || 0; + + if (id < 1) { + return callback(new Error(_('Missing report ID'))); + } + + db.getConnection((err, connection) => { + if (err) { + return callback(err); + } + + connection.query('SELECT * FROM reports WHERE id=?', [id], (err, rows) => { + connection.release(); + if (err) { + return callback(err); + } + + if (!rows || !rows.length) { + return callback(null, false); + } + + const template = tools.convertKeys(rows[0]); + + const params = template.params.trim(); + if (params != '') { + try { + template.paramsObject = JSON.parse(params); + } catch (err) { + return callback(err); + } + } else { + template.params = {}; + } + + return callback(null, template); + }); + }); +}; + +module.exports.createOrUpdate = (createMode, data, callback) => { + data = data || {}; + + const id = 'id' in data ? Number(data.id) : 0; + + if (!createMode && id < 1) { + return callback(new Error(_('Missing report ID'))); + } + + const template = tools.convertKeys(data); + const name = (template.name || '').toString().trim(); + + if (!name) { + return callback(new Error(_('Report name must be set'))); + } + + const reportTemplateId = Number(template.reportTemplate); + reportTemplates.get(reportTemplateId, (err, reportTemplate) => { + if (err) { + callback(err); + } + + const params = data.paramsObject; + for (const spec of reportTemplate.userFieldsObject) { + if (params[spec.id].length < spec.minOccurences) { + return callback(new Error(_('At least ' + spec.minOccurences + ' rows in "' + spec.name + '" have to be selected.'))); + } + + if (params[spec.id].length > spec.maxOccurences) { + return callback(new Error(_('At most ' + spec.minOccurences + ' rows in "' + spec.name + '" can be selected.'))); + } + } + + const keys = ['name', 'params']; + const values = [name, JSON.stringify(params)]; + + + Object.keys(template).forEach(key => { + let value = typeof template[key] === 'number' ? template[key] : (template[key] || '').toString().trim(); + key = tools.toDbKey(key); + + if (key === 'description') { + value = tools.purifyHTML(value); + } + + if (allowedKeys.indexOf(key) >= 0 && keys.indexOf(key) < 0) { + keys.push(key); + values.push(value); + } + }); + + db.getConnection((err, connection) => { + if (err) { + return next(err); + } + + let query; + + if (createMode) { + query = 'INSERT INTO reports (`' + keys.join('`, `') + '`) VALUES (' + values.map(() => '?').join(',') + ')'; + } else { + query = 'UPDATE reports SET ' + keys.map(key => '`' + key + '`=?').join(', ') + ' WHERE id=? LIMIT 1'; + values.push(id); + } + + connection.query(query, values, (err, result) => { + connection.release(); + if (err) { + return callback(err); + } + + if (createMode) { + return callback(null, result && result.insertId || false); + } else { + return callback(null, result && result.affectedRows || false) + } + }); + }); + }); +}; + +module.exports.delete = (id, callback) => { + id = Number(id) || 0; + + if (id < 1) { + return callback(new Error(_('Missing report ID'))); + } + + db.getConnection((err, connection) => { + if (err) { + return callback(err); + } + + connection.query('DELETE FROM reports WHERE id=? LIMIT 1', [id], (err, result) => { + connection.release(); + if (err) { + return callback(err); + } + + const affected = result && result.affectedRows || 0; + return callback(err, affected); + }); + }); +}; + +const campaignFieldsMapping = { + 'tracker_count': 'tracker.count', + 'country': 'tracker.country', + 'device_type': 'tracker.device_type', + 'status': 'campaign.status', + 'first_name': 'subscribers.first_name', + 'last_name': 'subscribers.last_name', + 'email': 'subscribers.email' +}; + +module.exports.getCampaignResults = (campaign, select, clause, callback) => { + db.getConnection((err, connection) => { + if (err) { + return callback(err); + } + + fields.list(campaign.list, (err, fieldList) => { + if (err) { + return callback(err); + } + + const fieldsMapping = fieldList.reduce((map, field) => { + map[customFieldName(field.key)] = 'subscribers.' + field.column; + return map; + }, Object.assign({}, campaignFieldsMapping)); + + let selFields = []; + for (let idx = 0; idx < select.length; idx++) { + const item = select[idx]; + if (item in fieldsMapping) { + selFields.push(fieldsMapping[item] + ' AS ' + item); + } else if (item == '*') { + selFields = selFields.concat(Object.keys(fieldsMapping).map(item => fieldsMapping[item] + ' AS ' + item)); + } else { + selFields.push(item); + } + } + + const query = 'SELECT ' + selFields.join(', ') + ' FROM `subscription__' + campaign.list + '` subscribers INNER JOIN `campaign__' + campaign.id + '` campaign on subscribers.id=campaign.subscription LEFT JOIN `campaign_tracker__' + campaign.id + '` tracker on subscribers.id=tracker.subscriber ' + clause; + + connection.query(query, (err, results) => { + if (err) { + connection.release(); + return callback(err); + } + + return callback(null, results); + }); + }); + }); +}; + +function customFieldName(id) { + return id.replace(/MERGE_/, 'CUSTOM_').toLowerCase(); +} diff --git a/lib/table-helpers.js b/lib/table-helpers.js index 3ab373cc..eaac0597 100644 --- a/lib/table-helpers.js +++ b/lib/table-helpers.js @@ -95,8 +95,6 @@ module.exports.filter = (source, fields, request, columns, searchFields, default let query = 'SELECT SQL_CALC_FOUND_ROWS ' + fields.join(', ') + ' FROM ' + source +' WHERE ' + (searchWhere ? '(' + searchWhere + ')': '1') + (queryData ? ' AND (' + queryData.where + ')' : '') + ' ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?'; let args = searchArgs.concat(queryData ? queryData.values : []).concat([Number(request.length) || 50, Number(request.start) || 0]); - log.info("tableHelpers", query); - connection.query(query, args, (err, rows) => { if (err) { connection.release(); diff --git a/lib/tools.js b/lib/tools.js index e1be6d27..080afdcb 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -129,6 +129,10 @@ function updateMenu(res) { title: _('Automation'), url: '/triggers', key: 'triggers' + }, { + title: _('Reports'), + url: '/reports', + key: 'reports' }); } diff --git a/meta.json b/meta.json index 8e5bc365..22fe97fb 100644 --- a/meta.json +++ b/meta.json @@ -1,3 +1,3 @@ { - "schemaVersion": 26 + "schemaVersion": 27 } diff --git a/public/ace/mode-handlebars.js b/public/ace/mode-handlebars.js new file mode 100644 index 00000000..bd345d55 --- /dev/null +++ b/public/ace/mode-handlebars.js @@ -0,0 +1 @@ +ace.define("ace/mode/doc_comment_highlight_rules",["require","exports","module","ace/lib/oop","ace/mode/text_highlight_rules"],function(e,t,n){"use strict";var r=e("../lib/oop"),i=e("./text_highlight_rules").TextHighlightRules,s=function(){this.$rules={start:[{token:"comment.doc.tag",regex:"@[\\w\\d_]+"},s.getTagRule(),{defaultToken:"comment.doc",caseInsensitive:!0}]}};r.inherits(s,i),s.getTagRule=function(e){return{token:"comment.doc.tag.storage.type",regex:"\\b(?:TODO|FIXME|XXX|HACK)\\b"}},s.getStartRule=function(e){return{token:"comment.doc",regex:"\\/\\*(?=\\*)",next:e}},s.getEndRule=function(e){return{token:"comment.doc",regex:"\\*\\/",next:e}},t.DocCommentHighlightRules=s}),ace.define("ace/mode/javascript_highlight_rules",["require","exports","module","ace/lib/oop","ace/mode/doc_comment_highlight_rules","ace/mode/text_highlight_rules"],function(e,t,n){"use strict";function a(){var e=o.replace("\\d","\\d\\-"),t={onMatch:function(e,t,n){var r=e.charAt(1)=="/"?2:1;if(r==1)t!=this.nextState?n.unshift(this.next,this.nextState,0):n.unshift(this.next),n[2]++;else if(r==2&&t==this.nextState){n[1]--;if(!n[1]||n[1]<0)n.shift(),n.shift()}return[{type:"meta.tag.punctuation."+(r==1?"":"end-")+"tag-open.xml",value:e.slice(0,r)},{type:"meta.tag.tag-name.xml",value:e.substr(r)}]},regex:"",onMatch:function(e,t,n){return t==n[0]&&n.shift(),e.length==2&&(n[0]==this.nextState&&n[1]--,(!n[1]||n[1]<0)&&n.splice(0,2)),this.next=n[0]||"start",[{type:this.token,value:e}]},nextState:"jsx"},n,f("jsxAttributes"),{token:"entity.other.attribute-name.xml",regex:e},{token:"keyword.operator.attribute-equals.xml",regex:"="},{token:"text.tag-whitespace.xml",regex:"\\s+"},{token:"string.attribute-value.xml",regex:"'",stateName:"jsx_attr_q",push:[{token:"string.attribute-value.xml",regex:"'",next:"pop"},{include:"reference"},{defaultToken:"string.attribute-value.xml"}]},{token:"string.attribute-value.xml",regex:'"',stateName:"jsx_attr_qq",push:[{token:"string.attribute-value.xml",regex:'"',next:"pop"},{include:"reference"},{defaultToken:"string.attribute-value.xml"}]},t],this.$rules.reference=[{token:"constant.language.escape.reference.xml",regex:"(?:&#[0-9]+;)|(?:&#x[0-9a-fA-F]+;)|(?:&[a-zA-Z0-9_:\\.-]+;)"}]}function f(e){return[{token:"comment",regex:/\/\*/,next:[i.getTagRule(),{token:"comment",regex:"\\*\\/",next:e||"pop"},{defaultToken:"comment",caseInsensitive:!0}]},{token:"comment",regex:"\\/\\/",next:[i.getTagRule(),{token:"comment",regex:"$|^",next:e||"pop"},{defaultToken:"comment",caseInsensitive:!0}]}]}var r=e("../lib/oop"),i=e("./doc_comment_highlight_rules").DocCommentHighlightRules,s=e("./text_highlight_rules").TextHighlightRules,o="[a-zA-Z\\$_\u00a1-\uffff][a-zA-Z\\d\\$_\u00a1-\uffff]*\\b",u=function(e){var t=this.createKeywordMapper({"variable.language":"Array|Boolean|Date|Function|Iterator|Number|Object|RegExp|String|Proxy|Namespace|QName|XML|XMLList|ArrayBuffer|Float32Array|Float64Array|Int16Array|Int32Array|Int8Array|Uint16Array|Uint32Array|Uint8Array|Uint8ClampedArray|Error|EvalError|InternalError|RangeError|ReferenceError|StopIteration|SyntaxError|TypeError|URIError|decodeURI|decodeURIComponent|encodeURI|encodeURIComponent|eval|isFinite|isNaN|parseFloat|parseInt|JSON|Math|this|arguments|prototype|window|document",keyword:"const|yield|import|get|set|break|case|catch|continue|default|delete|do|else|finally|for|function|if|in|instanceof|new|return|switch|throw|try|typeof|let|var|while|with|debugger|__parent__|__count__|escape|unescape|with|__proto__|class|enum|extends|super|export|implements|private|public|interface|package|protected|static","storage.type":"const|let|var|function","constant.language":"null|Infinity|NaN|undefined","support.function":"alert","constant.language.boolean":"true|false"},"identifier"),n="case|do|else|finally|in|instanceof|return|throw|try|typeof|yield|void",r="\\\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|u{[0-9a-fA-F]{1,6}}|[0-2][0-7]{0,2}|3[0-7][0-7]?|[4-7][0-7]?|.)";this.$rules={no_regex:[i.getStartRule("doc-start"),f("no_regex"),{token:"string",regex:"'(?=.)",next:"qstring"},{token:"string",regex:'"(?=.)',next:"qqstring"},{token:"constant.numeric",regex:/0(?:[xX][0-9a-fA-F]+|[bB][01]+)\b/},{token:"constant.numeric",regex:/[+-]?\d[\d_]*(?:(?:\.\d*)?(?:[eE][+-]?\d+)?)?\b/},{token:["storage.type","punctuation.operator","support.function","punctuation.operator","entity.name.function","text","keyword.operator"],regex:"("+o+")(\\.)(prototype)(\\.)("+o+")(\\s*)(=)",next:"function_arguments"},{token:["storage.type","punctuation.operator","entity.name.function","text","keyword.operator","text","storage.type","text","paren.lparen"],regex:"("+o+")(\\.)("+o+")(\\s*)(=)(\\s*)(function)(\\s*)(\\()",next:"function_arguments"},{token:["entity.name.function","text","keyword.operator","text","storage.type","text","paren.lparen"],regex:"("+o+")(\\s*)(=)(\\s*)(function)(\\s*)(\\()",next:"function_arguments"},{token:["storage.type","punctuation.operator","entity.name.function","text","keyword.operator","text","storage.type","text","entity.name.function","text","paren.lparen"],regex:"("+o+")(\\.)("+o+")(\\s*)(=)(\\s*)(function)(\\s+)(\\w+)(\\s*)(\\()",next:"function_arguments"},{token:["storage.type","text","entity.name.function","text","paren.lparen"],regex:"(function)(\\s+)("+o+")(\\s*)(\\()",next:"function_arguments"},{token:["entity.name.function","text","punctuation.operator","text","storage.type","text","paren.lparen"],regex:"("+o+")(\\s*)(:)(\\s*)(function)(\\s*)(\\()",next:"function_arguments"},{token:["text","text","storage.type","text","paren.lparen"],regex:"(:)(\\s*)(function)(\\s*)(\\()",next:"function_arguments"},{token:"keyword",regex:"(?:"+n+")\\b",next:"start"},{token:["support.constant"],regex:/that\b/},{token:["storage.type","punctuation.operator","support.function.firebug"],regex:/(console)(\.)(warn|info|log|error|time|trace|timeEnd|assert)\b/},{token:t,regex:o},{token:"punctuation.operator",regex:/[.](?![.])/,next:"property"},{token:"keyword.operator",regex:/--|\+\+|\.{3}|===|==|=|!=|!==|<+=?|>+=?|!|&&|\|\||\?\:|[!$%&*+\-~\/^]=?/,next:"start"},{token:"punctuation.operator",regex:/[?:,;.]/,next:"start"},{token:"paren.lparen",regex:/[\[({]/,next:"start"},{token:"paren.rparen",regex:/[\])}]/},{token:"comment",regex:/^#!.*$/}],property:[{token:"text",regex:"\\s+"},{token:["storage.type","punctuation.operator","entity.name.function","text","keyword.operator","text","storage.type","text","entity.name.function","text","paren.lparen"],regex:"("+o+")(\\.)("+o+")(\\s*)(=)(\\s*)(function)(?:(\\s+)(\\w+))?(\\s*)(\\()",next:"function_arguments"},{token:"punctuation.operator",regex:/[.](?![.])/},{token:"support.function",regex:/(s(?:h(?:ift|ow(?:Mod(?:elessDialog|alDialog)|Help))|croll(?:X|By(?:Pages|Lines)?|Y|To)?|t(?:op|rike)|i(?:n|zeToContent|debar|gnText)|ort|u(?:p|b(?:str(?:ing)?)?)|pli(?:ce|t)|e(?:nd|t(?:Re(?:sizable|questHeader)|M(?:i(?:nutes|lliseconds)|onth)|Seconds|Ho(?:tKeys|urs)|Year|Cursor|Time(?:out)?|Interval|ZOptions|Date|UTC(?:M(?:i(?:nutes|lliseconds)|onth)|Seconds|Hours|Date|FullYear)|FullYear|Active)|arch)|qrt|lice|avePreferences|mall)|h(?:ome|andleEvent)|navigate|c(?:har(?:CodeAt|At)|o(?:s|n(?:cat|textual|firm)|mpile)|eil|lear(?:Timeout|Interval)?|a(?:ptureEvents|ll)|reate(?:StyleSheet|Popup|EventObject))|t(?:o(?:GMTString|S(?:tring|ource)|U(?:TCString|pperCase)|Lo(?:caleString|werCase))|est|a(?:n|int(?:Enabled)?))|i(?:s(?:NaN|Finite)|ndexOf|talics)|d(?:isableExternalCapture|ump|etachEvent)|u(?:n(?:shift|taint|escape|watch)|pdateCommands)|j(?:oin|avaEnabled)|p(?:o(?:p|w)|ush|lugins.refresh|a(?:ddings|rse(?:Int|Float)?)|r(?:int|ompt|eference))|e(?:scape|nableExternalCapture|val|lementFromPoint|x(?:p|ec(?:Script|Command)?))|valueOf|UTC|queryCommand(?:State|Indeterm|Enabled|Value)|f(?:i(?:nd|le(?:ModifiedDate|Size|CreatedDate|UpdatedDate)|xed)|o(?:nt(?:size|color)|rward)|loor|romCharCode)|watch|l(?:ink|o(?:ad|g)|astIndexOf)|a(?:sin|nchor|cos|t(?:tachEvent|ob|an(?:2)?)|pply|lert|b(?:s|ort))|r(?:ou(?:nd|teEvents)|e(?:size(?:By|To)|calc|turnValue|place|verse|l(?:oad|ease(?:Capture|Events)))|andom)|g(?:o|et(?:ResponseHeader|M(?:i(?:nutes|lliseconds)|onth)|Se(?:conds|lection)|Hours|Year|Time(?:zoneOffset)?|Da(?:y|te)|UTC(?:M(?:i(?:nutes|lliseconds)|onth)|Seconds|Hours|Da(?:y|te)|FullYear)|FullYear|A(?:ttention|llResponseHeaders)))|m(?:in|ove(?:B(?:y|elow)|To(?:Absolute)?|Above)|ergeAttributes|a(?:tch|rgins|x))|b(?:toa|ig|o(?:ld|rderWidths)|link|ack))\b(?=\()/},{token:"support.function.dom",regex:/(s(?:ub(?:stringData|mit)|plitText|e(?:t(?:NamedItem|Attribute(?:Node)?)|lect))|has(?:ChildNodes|Feature)|namedItem|c(?:l(?:ick|o(?:se|neNode))|reate(?:C(?:omment|DATASection|aption)|T(?:Head|extNode|Foot)|DocumentFragment|ProcessingInstruction|E(?:ntityReference|lement)|Attribute))|tabIndex|i(?:nsert(?:Row|Before|Cell|Data)|tem)|open|delete(?:Row|C(?:ell|aption)|T(?:Head|Foot)|Data)|focus|write(?:ln)?|a(?:dd|ppend(?:Child|Data))|re(?:set|place(?:Child|Data)|move(?:NamedItem|Child|Attribute(?:Node)?)?)|get(?:NamedItem|Element(?:sBy(?:Name|TagName|ClassName)|ById)|Attribute(?:Node)?)|blur)\b(?=\()/},{token:"support.constant",regex:/(s(?:ystemLanguage|cr(?:ipts|ollbars|een(?:X|Y|Top|Left))|t(?:yle(?:Sheets)?|atus(?:Text|bar)?)|ibling(?:Below|Above)|ource|uffixes|e(?:curity(?:Policy)?|l(?:ection|f)))|h(?:istory|ost(?:name)?|as(?:h|Focus))|y|X(?:MLDocument|SLDocument)|n(?:ext|ame(?:space(?:s|URI)|Prop))|M(?:IN_VALUE|AX_VALUE)|c(?:haracterSet|o(?:n(?:structor|trollers)|okieEnabled|lorDepth|mp(?:onents|lete))|urrent|puClass|l(?:i(?:p(?:boardData)?|entInformation)|osed|asses)|alle(?:e|r)|rypto)|t(?:o(?:olbar|p)|ext(?:Transform|Indent|Decoration|Align)|ags)|SQRT(?:1_2|2)|i(?:n(?:ner(?:Height|Width)|put)|ds|gnoreCase)|zIndex|o(?:scpu|n(?:readystatechange|Line)|uter(?:Height|Width)|p(?:sProfile|ener)|ffscreenBuffering)|NEGATIVE_INFINITY|d(?:i(?:splay|alog(?:Height|Top|Width|Left|Arguments)|rectories)|e(?:scription|fault(?:Status|Ch(?:ecked|arset)|View)))|u(?:ser(?:Profile|Language|Agent)|n(?:iqueID|defined)|pdateInterval)|_content|p(?:ixelDepth|ort|ersonalbar|kcs11|l(?:ugins|atform)|a(?:thname|dding(?:Right|Bottom|Top|Left)|rent(?:Window|Layer)?|ge(?:X(?:Offset)?|Y(?:Offset)?))|r(?:o(?:to(?:col|type)|duct(?:Sub)?|mpter)|e(?:vious|fix)))|e(?:n(?:coding|abledPlugin)|x(?:ternal|pando)|mbeds)|v(?:isibility|endor(?:Sub)?|Linkcolor)|URLUnencoded|P(?:I|OSITIVE_INFINITY)|f(?:ilename|o(?:nt(?:Size|Family|Weight)|rmName)|rame(?:s|Element)|gColor)|E|whiteSpace|l(?:i(?:stStyleType|n(?:eHeight|kColor))|o(?:ca(?:tion(?:bar)?|lName)|wsrc)|e(?:ngth|ft(?:Context)?)|a(?:st(?:M(?:odified|atch)|Index|Paren)|yer(?:s|X)|nguage))|a(?:pp(?:MinorVersion|Name|Co(?:deName|re)|Version)|vail(?:Height|Top|Width|Left)|ll|r(?:ity|guments)|Linkcolor|bove)|r(?:ight(?:Context)?|e(?:sponse(?:XML|Text)|adyState))|global|x|m(?:imeTypes|ultiline|enubar|argin(?:Right|Bottom|Top|Left))|L(?:N(?:10|2)|OG(?:10E|2E))|b(?:o(?:ttom|rder(?:Width|RightWidth|BottomWidth|Style|Color|TopWidth|LeftWidth))|ufferDepth|elow|ackground(?:Color|Image)))\b/},{token:"identifier",regex:o},{regex:"",token:"empty",next:"no_regex"}],start:[i.getStartRule("doc-start"),f("start"),{token:"string.regexp",regex:"\\/",next:"regex"},{token:"text",regex:"\\s+|^$",next:"start"},{token:"empty",regex:"",next:"no_regex"}],regex:[{token:"regexp.keyword.operator",regex:"\\\\(?:u[\\da-fA-F]{4}|x[\\da-fA-F]{2}|.)"},{token:"string.regexp",regex:"/[sxngimy]*",next:"no_regex"},{token:"invalid",regex:/\{\d+\b,?\d*\}[+*]|[+*$^?][+*]|[$^][?]|\?{3,}/},{token:"constant.language.escape",regex:/\(\?[:=!]|\)|\{\d+\b,?\d*\}|[+*]\?|[()$^+*?.]/},{token:"constant.language.delimiter",regex:/\|/},{token:"constant.language.escape",regex:/\[\^?/,next:"regex_character_class"},{token:"empty",regex:"$",next:"no_regex"},{defaultToken:"string.regexp"}],regex_character_class:[{token:"regexp.charclass.keyword.operator",regex:"\\\\(?:u[\\da-fA-F]{4}|x[\\da-fA-F]{2}|.)"},{token:"constant.language.escape",regex:"]",next:"regex"},{token:"constant.language.escape",regex:"-"},{token:"empty",regex:"$",next:"no_regex"},{defaultToken:"string.regexp.charachterclass"}],function_arguments:[{token:"variable.parameter",regex:o},{token:"punctuation.operator",regex:"[, ]+"},{token:"punctuation.operator",regex:"$"},{token:"empty",regex:"",next:"no_regex"}],qqstring:[{token:"constant.language.escape",regex:r},{token:"string",regex:"\\\\$",next:"qqstring"},{token:"string",regex:'"|$',next:"no_regex"},{defaultToken:"string"}],qstring:[{token:"constant.language.escape",regex:r},{token:"string",regex:"\\\\$",next:"qstring"},{token:"string",regex:"'|$",next:"no_regex"},{defaultToken:"string"}]};if(!e||!e.noES6)this.$rules.no_regex.unshift({regex:"[{}]",onMatch:function(e,t,n){this.next=e=="{"?this.nextState:"";if(e=="{"&&n.length)n.unshift("start",t);else if(e=="}"&&n.length){n.shift(),this.next=n.shift();if(this.next.indexOf("string")!=-1||this.next.indexOf("jsx")!=-1)return"paren.quasi.end"}return e=="{"?"paren.lparen":"paren.rparen"},nextState:"start"},{token:"string.quasi.start",regex:/`/,push:[{token:"constant.language.escape",regex:r},{token:"paren.quasi.start",regex:/\${/,push:"start"},{token:"string.quasi.end",regex:/`/,next:"pop"},{defaultToken:"string.quasi"}]}),(!e||!e.noJSX)&&a.call(this);this.embedRules(i,"doc-",[i.getEndRule("no_regex")]),this.normalizeRules()};r.inherits(u,s),t.JavaScriptHighlightRules=u}),ace.define("ace/mode/matching_brace_outdent",["require","exports","module","ace/range"],function(e,t,n){"use strict";var r=e("../range").Range,i=function(){};(function(){this.checkOutdent=function(e,t){return/^\s+$/.test(e)?/^\s*\}/.test(t):!1},this.autoOutdent=function(e,t){var n=e.getLine(t),i=n.match(/^(\s*\})/);if(!i)return 0;var s=i[1].length,o=e.findMatchingBracket({row:t,column:s});if(!o||o.row==t)return 0;var u=this.$getIndent(e.getLine(o.row));e.replace(new r(t,0,t,s-1),u)},this.$getIndent=function(e){return e.match(/^\s*/)[0]}}).call(i.prototype),t.MatchingBraceOutdent=i}),ace.define("ace/mode/behaviour/cstyle",["require","exports","module","ace/lib/oop","ace/mode/behaviour","ace/token_iterator","ace/lib/lang"],function(e,t,n){"use strict";var r=e("../../lib/oop"),i=e("../behaviour").Behaviour,s=e("../../token_iterator").TokenIterator,o=e("../../lib/lang"),u=["text","paren.rparen","punctuation.operator"],a=["text","paren.rparen","punctuation.operator","comment"],f,l={},c=function(e){var t=-1;e.multiSelect&&(t=e.selection.index,l.rangeCount!=e.multiSelect.rangeCount&&(l={rangeCount:e.multiSelect.rangeCount}));if(l[t])return f=l[t];f=l[t]={autoInsertedBrackets:0,autoInsertedRow:-1,autoInsertedLineEnd:"",maybeInsertedBrackets:0,maybeInsertedRow:-1,maybeInsertedLineStart:"",maybeInsertedLineEnd:""}},h=function(e,t,n,r){var i=e.end.row-e.start.row;return{text:n+t+r,selection:[0,e.start.column+1,i,e.end.column+(i?0:1)]}},p=function(){this.add("braces","insertion",function(e,t,n,r,i){var s=n.getCursorPosition(),u=r.doc.getLine(s.row);if(i=="{"){c(n);var a=n.getSelectionRange(),l=r.doc.getTextRange(a);if(l!==""&&l!=="{"&&n.getWrapBehavioursEnabled())return h(a,l,"{","}");if(p.isSaneInsertion(n,r))return/[\]\}\)]/.test(u[s.column])||n.inMultiSelectMode?(p.recordAutoInsert(n,r,"}"),{text:"{}",selection:[1,1]}):(p.recordMaybeInsert(n,r,"{"),{text:"{",selection:[1,1]})}else if(i=="}"){c(n);var d=u.substring(s.column,s.column+1);if(d=="}"){var v=r.$findOpeningBracket("}",{column:s.column+1,row:s.row});if(v!==null&&p.isAutoInsertedClosing(s,u,i))return p.popAutoInsertedClosing(),{text:"",selection:[1,1]}}}else{if(i=="\n"||i=="\r\n"){c(n);var m="";p.isMaybeInsertedClosing(s,u)&&(m=o.stringRepeat("}",f.maybeInsertedBrackets),p.clearMaybeInsertedClosing());var d=u.substring(s.column,s.column+1);if(d==="}"){var g=r.findMatchingBracket({row:s.row,column:s.column+1},"}");if(!g)return null;var y=this.$getIndent(r.getLine(g.row))}else{if(!m){p.clearMaybeInsertedClosing();return}var y=this.$getIndent(u)}var b=y+r.getTabString();return{text:"\n"+b+"\n"+y+m,selection:[1,b.length,1,b.length]}}p.clearMaybeInsertedClosing()}}),this.add("braces","deletion",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&s=="{"){c(n);var o=r.doc.getLine(i.start.row),u=o.substring(i.end.column,i.end.column+1);if(u=="}")return i.end.column++,i;f.maybeInsertedBrackets--}}),this.add("parens","insertion",function(e,t,n,r,i){if(i=="("){c(n);var s=n.getSelectionRange(),o=r.doc.getTextRange(s);if(o!==""&&n.getWrapBehavioursEnabled())return h(s,o,"(",")");if(p.isSaneInsertion(n,r))return p.recordAutoInsert(n,r,")"),{text:"()",selection:[1,1]}}else if(i==")"){c(n);var u=n.getCursorPosition(),a=r.doc.getLine(u.row),f=a.substring(u.column,u.column+1);if(f==")"){var l=r.$findOpeningBracket(")",{column:u.column+1,row:u.row});if(l!==null&&p.isAutoInsertedClosing(u,a,i))return p.popAutoInsertedClosing(),{text:"",selection:[1,1]}}}}),this.add("parens","deletion",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&s=="("){c(n);var o=r.doc.getLine(i.start.row),u=o.substring(i.start.column+1,i.start.column+2);if(u==")")return i.end.column++,i}}),this.add("brackets","insertion",function(e,t,n,r,i){if(i=="["){c(n);var s=n.getSelectionRange(),o=r.doc.getTextRange(s);if(o!==""&&n.getWrapBehavioursEnabled())return h(s,o,"[","]");if(p.isSaneInsertion(n,r))return p.recordAutoInsert(n,r,"]"),{text:"[]",selection:[1,1]}}else if(i=="]"){c(n);var u=n.getCursorPosition(),a=r.doc.getLine(u.row),f=a.substring(u.column,u.column+1);if(f=="]"){var l=r.$findOpeningBracket("]",{column:u.column+1,row:u.row});if(l!==null&&p.isAutoInsertedClosing(u,a,i))return p.popAutoInsertedClosing(),{text:"",selection:[1,1]}}}}),this.add("brackets","deletion",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&s=="["){c(n);var o=r.doc.getLine(i.start.row),u=o.substring(i.start.column+1,i.start.column+2);if(u=="]")return i.end.column++,i}}),this.add("string_dquotes","insertion",function(e,t,n,r,i){if(i=='"'||i=="'"){c(n);var s=i,o=n.getSelectionRange(),u=r.doc.getTextRange(o);if(u!==""&&u!=="'"&&u!='"'&&n.getWrapBehavioursEnabled())return h(o,u,s,s);if(!u){var a=n.getCursorPosition(),f=r.doc.getLine(a.row),l=f.substring(a.column-1,a.column),p=f.substring(a.column,a.column+1),d=r.getTokenAt(a.row,a.column),v=r.getTokenAt(a.row,a.column+1);if(l=="\\"&&d&&/escape/.test(d.type))return null;var m=d&&/string|escape/.test(d.type),g=!v||/string|escape/.test(v.type),y;if(p==s)y=m!==g;else{if(m&&!g)return null;if(m&&g)return null;var b=r.$mode.tokenRe;b.lastIndex=0;var w=b.test(l);b.lastIndex=0;var E=b.test(l);if(w||E)return null;if(p&&!/[\s;,.})\]\\]/.test(p))return null;y=!0}return{text:y?s+s:"",selection:[1,1]}}}}),this.add("string_dquotes","deletion",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&(s=='"'||s=="'")){c(n);var o=r.doc.getLine(i.start.row),u=o.substring(i.start.column+1,i.start.column+2);if(u==s)return i.end.column++,i}})};p.isSaneInsertion=function(e,t){var n=e.getCursorPosition(),r=new s(t,n.row,n.column);if(!this.$matchTokenType(r.getCurrentToken()||"text",u)){var i=new s(t,n.row,n.column+1);if(!this.$matchTokenType(i.getCurrentToken()||"text",u))return!1}return r.stepForward(),r.getCurrentTokenRow()!==n.row||this.$matchTokenType(r.getCurrentToken()||"text",a)},p.$matchTokenType=function(e,t){return t.indexOf(e.type||e)>-1},p.recordAutoInsert=function(e,t,n){var r=e.getCursorPosition(),i=t.doc.getLine(r.row);this.isAutoInsertedClosing(r,i,f.autoInsertedLineEnd[0])||(f.autoInsertedBrackets=0),f.autoInsertedRow=r.row,f.autoInsertedLineEnd=n+i.substr(r.column),f.autoInsertedBrackets++},p.recordMaybeInsert=function(e,t,n){var r=e.getCursorPosition(),i=t.doc.getLine(r.row);this.isMaybeInsertedClosing(r,i)||(f.maybeInsertedBrackets=0),f.maybeInsertedRow=r.row,f.maybeInsertedLineStart=i.substr(0,r.column)+n,f.maybeInsertedLineEnd=i.substr(r.column),f.maybeInsertedBrackets++},p.isAutoInsertedClosing=function(e,t,n){return f.autoInsertedBrackets>0&&e.row===f.autoInsertedRow&&n===f.autoInsertedLineEnd[0]&&t.substr(e.column)===f.autoInsertedLineEnd},p.isMaybeInsertedClosing=function(e,t){return f.maybeInsertedBrackets>0&&e.row===f.maybeInsertedRow&&t.substr(e.column)===f.maybeInsertedLineEnd&&t.substr(0,e.column)==f.maybeInsertedLineStart},p.popAutoInsertedClosing=function(){f.autoInsertedLineEnd=f.autoInsertedLineEnd.substr(1),f.autoInsertedBrackets--},p.clearMaybeInsertedClosing=function(){f&&(f.maybeInsertedBrackets=0,f.maybeInsertedRow=-1)},r.inherits(p,i),t.CstyleBehaviour=p}),ace.define("ace/mode/folding/cstyle",["require","exports","module","ace/lib/oop","ace/range","ace/mode/folding/fold_mode"],function(e,t,n){"use strict";var r=e("../../lib/oop"),i=e("../../range").Range,s=e("./fold_mode").FoldMode,o=t.FoldMode=function(e){e&&(this.foldingStartMarker=new RegExp(this.foldingStartMarker.source.replace(/\|[^|]*?$/,"|"+e.start)),this.foldingStopMarker=new RegExp(this.foldingStopMarker.source.replace(/\|[^|]*?$/,"|"+e.end)))};r.inherits(o,s),function(){this.foldingStartMarker=/(\{|\[)[^\}\]]*$|^\s*(\/\*)/,this.foldingStopMarker=/^[^\[\{]*(\}|\])|^[\s\*]*(\*\/)/,this.singleLineBlockCommentRe=/^\s*(\/\*).*\*\/\s*$/,this.tripleStarBlockCommentRe=/^\s*(\/\*\*\*).*\*\/\s*$/,this.startRegionRe=/^\s*(\/\*|\/\/)#?region\b/,this._getFoldWidgetBase=this.getFoldWidget,this.getFoldWidget=function(e,t,n){var r=e.getLine(n);if(this.singleLineBlockCommentRe.test(r)&&!this.startRegionRe.test(r)&&!this.tripleStarBlockCommentRe.test(r))return"";var i=this._getFoldWidgetBase(e,t,n);return!i&&this.startRegionRe.test(r)?"start":i},this.getFoldWidgetRange=function(e,t,n,r){var i=e.getLine(n);if(this.startRegionRe.test(i))return this.getCommentRegionBlock(e,i,n);var s=i.match(this.foldingStartMarker);if(s){var o=s.index;if(s[1])return this.openingBracketBlock(e,s[1],n,o);var u=e.getCommentFoldRange(n,o+s[0].length,1);return u&&!u.isMultiLine()&&(r?u=this.getSectionRange(e,n):t!="all"&&(u=null)),u}if(t==="markbegin")return;var s=i.match(this.foldingStopMarker);if(s){var o=s.index+s[0].length;return s[1]?this.closingBracketBlock(e,s[1],n,o):e.getCommentFoldRange(n,o,-1)}},this.getSectionRange=function(e,t){var n=e.getLine(t),r=n.search(/\S/),s=t,o=n.length;t+=1;var u=t,a=e.getLength();while(++tf)break;var l=this.getFoldWidgetRange(e,"all",t);if(l){if(l.start.row<=s)break;if(l.isMultiLine())t=l.end.row;else if(r==f)break}u=t}return new i(s,o,u,e.getLine(u).length)},this.getCommentRegionBlock=function(e,t,n){var r=t.search(/\s*$/),s=e.getLength(),o=n,u=/^\s*(?:\/\*|\/\/|--)#?(end)?region\b/,a=1;while(++no)return new i(o,r,l,t.length)}}.call(o.prototype)}),ace.define("ace/mode/javascript",["require","exports","module","ace/lib/oop","ace/mode/text","ace/mode/javascript_highlight_rules","ace/mode/matching_brace_outdent","ace/range","ace/worker/worker_client","ace/mode/behaviour/cstyle","ace/mode/folding/cstyle"],function(e,t,n){"use strict";var r=e("../lib/oop"),i=e("./text").Mode,s=e("./javascript_highlight_rules").JavaScriptHighlightRules,o=e("./matching_brace_outdent").MatchingBraceOutdent,u=e("../range").Range,a=e("../worker/worker_client").WorkerClient,f=e("./behaviour/cstyle").CstyleBehaviour,l=e("./folding/cstyle").FoldMode,c=function(){this.HighlightRules=s,this.$outdent=new o,this.$behaviour=new f,this.foldingRules=new l};r.inherits(c,i),function(){this.lineCommentStart="//",this.blockComment={start:"/*",end:"*/"},this.getNextLineIndent=function(e,t,n){var r=this.$getIndent(t),i=this.getTokenizer().getLineTokens(t,e),s=i.tokens,o=i.state;if(s.length&&s[s.length-1].type=="comment")return r;if(e=="start"||e=="no_regex"){var u=t.match(/^.*(?:\bcase\b.*\:|[\{\(\[])\s*$/);u&&(r+=n)}else if(e=="doc-start"){if(o=="start"||o=="no_regex")return"";var u=t.match(/^\s*(\/?)\*/);u&&(u[1]&&(r+=" "),r+="* ")}return r},this.checkOutdent=function(e,t,n){return this.$outdent.checkOutdent(t,n)},this.autoOutdent=function(e,t,n){this.$outdent.autoOutdent(t,n)},this.createWorker=function(e){var t=new a(["ace"],"ace/mode/javascript_worker","JavaScriptWorker");return t.attachToDocument(e.getDocument()),t.on("annotate",function(t){e.setAnnotations(t.data)}),t.on("terminate",function(){e.clearAnnotations()}),t},this.$id="ace/mode/javascript"}.call(c.prototype),t.Mode=c}),ace.define("ace/mode/css_highlight_rules",["require","exports","module","ace/lib/oop","ace/lib/lang","ace/mode/text_highlight_rules"],function(e,t,n){"use strict";var r=e("../lib/oop"),i=e("../lib/lang"),s=e("./text_highlight_rules").TextHighlightRules,o=t.supportType="align-content|align-items|align-self|all|animation|animation-delay|animation-direction|animation-duration|animation-fill-mode|animation-iteration-count|animation-name|animation-play-state|animation-timing-function|backface-visibility|background|background-attachment|background-blend-mode|background-clip|background-color|background-image|background-origin|background-position|background-repeat|background-size|border|border-bottom|border-bottom-color|border-bottom-left-radius|border-bottom-right-radius|border-bottom-style|border-bottom-width|border-collapse|border-color|border-image|border-image-outset|border-image-repeat|border-image-slice|border-image-source|border-image-width|border-left|border-left-color|border-left-style|border-left-width|border-radius|border-right|border-right-color|border-right-style|border-right-width|border-spacing|border-style|border-top|border-top-color|border-top-left-radius|border-top-right-radius|border-top-style|border-top-width|border-width|bottom|box-shadow|box-sizing|caption-side|clear|clip|color|column-count|column-fill|column-gap|column-rule|column-rule-color|column-rule-style|column-rule-width|column-span|column-width|columns|content|counter-increment|counter-reset|cursor|direction|display|empty-cells|filter|flex|flex-basis|flex-direction|flex-flow|flex-grow|flex-shrink|flex-wrap|float|font|font-family|font-size|font-size-adjust|font-stretch|font-style|font-variant|font-weight|hanging-punctuation|height|justify-content|left|letter-spacing|line-height|list-style|list-style-image|list-style-position|list-style-type|margin|margin-bottom|margin-left|margin-right|margin-top|max-height|max-width|min-height|min-width|nav-down|nav-index|nav-left|nav-right|nav-up|opacity|order|outline|outline-color|outline-offset|outline-style|outline-width|overflow|overflow-x|overflow-y|padding|padding-bottom|padding-left|padding-right|padding-top|page-break-after|page-break-before|page-break-inside|perspective|perspective-origin|position|quotes|resize|right|tab-size|table-layout|text-align|text-align-last|text-decoration|text-decoration-color|text-decoration-line|text-decoration-style|text-indent|text-justify|text-overflow|text-shadow|text-transform|top|transform|transform-origin|transform-style|transition|transition-delay|transition-duration|transition-property|transition-timing-function|unicode-bidi|vertical-align|visibility|white-space|width|word-break|word-spacing|word-wrap|z-index",u=t.supportFunction="rgb|rgba|url|attr|counter|counters",a=t.supportConstant="absolute|after-edge|after|all-scroll|all|alphabetic|always|antialiased|armenian|auto|avoid-column|avoid-page|avoid|balance|baseline|before-edge|before|below|bidi-override|block-line-height|block|bold|bolder|border-box|both|bottom|box|break-all|break-word|capitalize|caps-height|caption|center|central|char|circle|cjk-ideographic|clone|close-quote|col-resize|collapse|column|consider-shifts|contain|content-box|cover|crosshair|cubic-bezier|dashed|decimal-leading-zero|decimal|default|disabled|disc|disregard-shifts|distribute-all-lines|distribute-letter|distribute-space|distribute|dotted|double|e-resize|ease-in|ease-in-out|ease-out|ease|ellipsis|end|exclude-ruby|fill|fixed|georgian|glyphs|grid-height|groove|hand|hanging|hebrew|help|hidden|hiragana-iroha|hiragana|horizontal|icon|ideograph-alpha|ideograph-numeric|ideograph-parenthesis|ideograph-space|ideographic|inactive|include-ruby|inherit|initial|inline-block|inline-box|inline-line-height|inline-table|inline|inset|inside|inter-ideograph|inter-word|invert|italic|justify|katakana-iroha|katakana|keep-all|last|left|lighter|line-edge|line-through|line|linear|list-item|local|loose|lower-alpha|lower-greek|lower-latin|lower-roman|lowercase|lr-tb|ltr|mathematical|max-height|max-size|medium|menu|message-box|middle|move|n-resize|ne-resize|newspaper|no-change|no-close-quote|no-drop|no-open-quote|no-repeat|none|normal|not-allowed|nowrap|nw-resize|oblique|open-quote|outset|outside|overline|padding-box|page|pointer|pre-line|pre-wrap|pre|preserve-3d|progress|relative|repeat-x|repeat-y|repeat|replaced|reset-size|ridge|right|round|row-resize|rtl|s-resize|scroll|se-resize|separate|slice|small-caps|small-caption|solid|space|square|start|static|status-bar|step-end|step-start|steps|stretch|strict|sub|super|sw-resize|table-caption|table-cell|table-column-group|table-column|table-footer-group|table-header-group|table-row-group|table-row|table|tb-rl|text-after-edge|text-before-edge|text-bottom|text-size|text-top|text|thick|thin|transparent|underline|upper-alpha|upper-latin|upper-roman|uppercase|use-script|vertical-ideographic|vertical-text|visible|w-resize|wait|whitespace|z-index|zero",f=t.supportConstantColor="aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|white|yellow",l=t.supportConstantFonts="arial|century|comic|courier|cursive|fantasy|garamond|georgia|helvetica|impact|lucida|symbol|system|tahoma|times|trebuchet|utopia|verdana|webdings|sans-serif|serif|monospace",c=t.numRe="\\-?(?:(?:[0-9]+)|(?:[0-9]*\\.[0-9]+))",h=t.pseudoElements="(\\:+)\\b(after|before|first-letter|first-line|moz-selection|selection)\\b",p=t.pseudoClasses="(:)\\b(active|checked|disabled|empty|enabled|first-child|first-of-type|focus|hover|indeterminate|invalid|last-child|last-of-type|link|not|nth-child|nth-last-child|nth-last-of-type|nth-of-type|only-child|only-of-type|required|root|target|valid|visited)\\b",d=function(){var e=this.createKeywordMapper({"support.function":u,"support.constant":a,"support.type":o,"support.constant.color":f,"support.constant.fonts":l},"text",!0);this.$rules={start:[{token:"comment",regex:"\\/\\*",push:"comment"},{token:"paren.lparen",regex:"\\{",push:"ruleset"},{token:"string",regex:"@.*?{",push:"media"},{token:"keyword",regex:"#[a-z0-9-_]+"},{token:"variable",regex:"\\.[a-z0-9-_]+"},{token:"string",regex:":[a-z0-9-_]+"},{token:"constant",regex:"[a-z0-9-_]+"},{caseInsensitive:!0}],media:[{token:"comment",regex:"\\/\\*",push:"comment"},{token:"paren.lparen",regex:"\\{",push:"ruleset"},{token:"string",regex:"\\}",next:"pop"},{token:"keyword",regex:"#[a-z0-9-_]+"},{token:"variable",regex:"\\.[a-z0-9-_]+"},{token:"string",regex:":[a-z0-9-_]+"},{token:"constant",regex:"[a-z0-9-_]+"},{caseInsensitive:!0}],comment:[{token:"comment",regex:"\\*\\/",next:"pop"},{defaultToken:"comment"}],ruleset:[{token:"paren.rparen",regex:"\\}",next:"pop"},{token:"comment",regex:"\\/\\*",push:"comment"},{token:"string",regex:'["](?:(?:\\\\.)|(?:[^"\\\\]))*?["]'},{token:"string",regex:"['](?:(?:\\\\.)|(?:[^'\\\\]))*?[']"},{token:["constant.numeric","keyword"],regex:"("+c+")(ch|cm|deg|em|ex|fr|gd|grad|Hz|in|kHz|mm|ms|pc|pt|px|rad|rem|s|turn|vh|vm|vw|%)"},{token:"constant.numeric",regex:c},{token:"constant.numeric",regex:"#[a-f0-9]{6}"},{token:"constant.numeric",regex:"#[a-f0-9]{3}"},{token:["punctuation","entity.other.attribute-name.pseudo-element.css"],regex:h},{token:["punctuation","entity.other.attribute-name.pseudo-class.css"],regex:p},{token:["support.function","string","support.function"],regex:"(url\\()(.*)(\\))"},{token:e,regex:"\\-?[a-zA-Z_][a-zA-Z0-9_\\-]*"},{caseInsensitive:!0}]},this.normalizeRules()};r.inherits(d,s),t.CssHighlightRules=d}),ace.define("ace/mode/css_completions",["require","exports","module"],function(e,t,n){"use strict";var r={background:{"#$0":1},"background-color":{"#$0":1,transparent:1,fixed:1},"background-image":{"url('/$0')":1},"background-repeat":{repeat:1,"repeat-x":1,"repeat-y":1,"no-repeat":1,inherit:1},"background-position":{bottom:2,center:2,left:2,right:2,top:2,inherit:2},"background-attachment":{scroll:1,fixed:1},"background-size":{cover:1,contain:1},"background-clip":{"border-box":1,"padding-box":1,"content-box":1},"background-origin":{"border-box":1,"padding-box":1,"content-box":1},border:{"solid $0":1,"dashed $0":1,"dotted $0":1,"#$0":1},"border-color":{"#$0":1},"border-style":{solid:2,dashed:2,dotted:2,"double":2,groove:2,hidden:2,inherit:2,inset:2,none:2,outset:2,ridged:2},"border-collapse":{collapse:1,separate:1},bottom:{px:1,em:1,"%":1},clear:{left:1,right:1,both:1,none:1},color:{"#$0":1,"rgb(#$00,0,0)":1},cursor:{"default":1,pointer:1,move:1,text:1,wait:1,help:1,progress:1,"n-resize":1,"ne-resize":1,"e-resize":1,"se-resize":1,"s-resize":1,"sw-resize":1,"w-resize":1,"nw-resize":1},display:{none:1,block:1,inline:1,"inline-block":1,"table-cell":1},"empty-cells":{show:1,hide:1},"float":{left:1,right:1,none:1},"font-family":{Arial:2,"Comic Sans MS":2,Consolas:2,"Courier New":2,Courier:2,Georgia:2,Monospace:2,"Sans-Serif":2,"Segoe UI":2,Tahoma:2,"Times New Roman":2,"Trebuchet MS":2,Verdana:1},"font-size":{px:1,em:1,"%":1},"font-weight":{bold:1,normal:1},"font-style":{italic:1,normal:1},"font-variant":{normal:1,"small-caps":1},height:{px:1,em:1,"%":1},left:{px:1,em:1,"%":1},"letter-spacing":{normal:1},"line-height":{normal:1},"list-style-type":{none:1,disc:1,circle:1,square:1,decimal:1,"decimal-leading-zero":1,"lower-roman":1,"upper-roman":1,"lower-greek":1,"lower-latin":1,"upper-latin":1,georgian:1,"lower-alpha":1,"upper-alpha":1},margin:{px:1,em:1,"%":1},"margin-right":{px:1,em:1,"%":1},"margin-left":{px:1,em:1,"%":1},"margin-top":{px:1,em:1,"%":1},"margin-bottom":{px:1,em:1,"%":1},"max-height":{px:1,em:1,"%":1},"max-width":{px:1,em:1,"%":1},"min-height":{px:1,em:1,"%":1},"min-width":{px:1,em:1,"%":1},overflow:{hidden:1,visible:1,auto:1,scroll:1},"overflow-x":{hidden:1,visible:1,auto:1,scroll:1},"overflow-y":{hidden:1,visible:1,auto:1,scroll:1},padding:{px:1,em:1,"%":1},"padding-top":{px:1,em:1,"%":1},"padding-right":{px:1,em:1,"%":1},"padding-bottom":{px:1,em:1,"%":1},"padding-left":{px:1,em:1,"%":1},"page-break-after":{auto:1,always:1,avoid:1,left:1,right:1},"page-break-before":{auto:1,always:1,avoid:1,left:1,right:1},position:{absolute:1,relative:1,fixed:1,"static":1},right:{px:1,em:1,"%":1},"table-layout":{fixed:1,auto:1},"text-decoration":{none:1,underline:1,"line-through":1,blink:1},"text-align":{left:1,right:1,center:1,justify:1},"text-transform":{capitalize:1,uppercase:1,lowercase:1,none:1},top:{px:1,em:1,"%":1},"vertical-align":{top:1,bottom:1},visibility:{hidden:1,visible:1},"white-space":{nowrap:1,normal:1,pre:1,"pre-line":1,"pre-wrap":1},width:{px:1,em:1,"%":1},"word-spacing":{normal:1},filter:{"alpha(opacity=$0100)":1},"text-shadow":{"$02px 2px 2px #777":1},"text-overflow":{"ellipsis-word":1,clip:1,ellipsis:1},"-moz-border-radius":1,"-moz-border-radius-topright":1,"-moz-border-radius-bottomright":1,"-moz-border-radius-topleft":1,"-moz-border-radius-bottomleft":1,"-webkit-border-radius":1,"-webkit-border-top-right-radius":1,"-webkit-border-top-left-radius":1,"-webkit-border-bottom-right-radius":1,"-webkit-border-bottom-left-radius":1,"-moz-box-shadow":1,"-webkit-box-shadow":1,transform:{"rotate($00deg)":1,"skew($00deg)":1},"-moz-transform":{"rotate($00deg)":1,"skew($00deg)":1},"-webkit-transform":{"rotate($00deg)":1,"skew($00deg)":1}},i=function(){};(function(){this.completionsDefined=!1,this.defineCompletions=function(){if(document){var e=document.createElement("c").style;for(var t in e){if(typeof e[t]!="string")continue;var n=t.replace(/[A-Z]/g,function(e){return"-"+e.toLowerCase()});r.hasOwnProperty(n)||(r[n]=1)}}this.completionsDefined=!0},this.getCompletions=function(e,t,n,r){this.completionsDefined||this.defineCompletions();var i=t.getTokenAt(n.row,n.column);if(!i)return[];if(e==="ruleset"){var s=t.getLine(n.row).substr(0,n.column);return/:[^;]+$/.test(s)?(/([\w\-]+):[^:]*$/.test(s),this.getPropertyValueCompletions(e,t,n,r)):this.getPropertyCompletions(e,t,n,r)}return[]},this.getPropertyCompletions=function(e,t,n,i){var s=Object.keys(r);return s.map(function(e){return{caption:e,snippet:e+": $0",meta:"property",score:Number.MAX_VALUE}})},this.getPropertyValueCompletions=function(e,t,n,i){var s=t.getLine(n.row).substr(0,n.column),o=(/([\w\-]+):[^:]*$/.exec(s)||{})[1];if(!o)return[];var u=[];return o in r&&typeof r[o]=="object"&&(u=Object.keys(r[o])),u.map(function(e){return{caption:e,snippet:e,meta:"property value",score:Number.MAX_VALUE}})}}).call(i.prototype),t.CssCompletions=i}),ace.define("ace/mode/behaviour/css",["require","exports","module","ace/lib/oop","ace/mode/behaviour","ace/mode/behaviour/cstyle","ace/token_iterator"],function(e,t,n){"use strict";var r=e("../../lib/oop"),i=e("../behaviour").Behaviour,s=e("./cstyle").CstyleBehaviour,o=e("../../token_iterator").TokenIterator,u=function(){this.inherit(s),this.add("colon","insertion",function(e,t,n,r,i){if(i===":"){var s=n.getCursorPosition(),u=new o(r,s.row,s.column),a=u.getCurrentToken();a&&a.value.match(/\s+/)&&(a=u.stepBackward());if(a&&a.type==="support.type"){var f=r.doc.getLine(s.row),l=f.substring(s.column,s.column+1);if(l===":")return{text:"",selection:[1,1]};if(!f.substring(s.column).match(/^\s*;/))return{text:":;",selection:[1,1]}}}}),this.add("colon","deletion",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&s===":"){var u=n.getCursorPosition(),a=new o(r,u.row,u.column),f=a.getCurrentToken();f&&f.value.match(/\s+/)&&(f=a.stepBackward());if(f&&f.type==="support.type"){var l=r.doc.getLine(i.start.row),c=l.substring(i.end.column,i.end.column+1);if(c===";")return i.end.column++,i}}}),this.add("semicolon","insertion",function(e,t,n,r,i){if(i===";"){var s=n.getCursorPosition(),o=r.doc.getLine(s.row),u=o.substring(s.column,s.column+1);if(u===";")return{text:"",selection:[1,1]}}})};r.inherits(u,s),t.CssBehaviour=u}),ace.define("ace/mode/css",["require","exports","module","ace/lib/oop","ace/mode/text","ace/mode/css_highlight_rules","ace/mode/matching_brace_outdent","ace/worker/worker_client","ace/mode/css_completions","ace/mode/behaviour/css","ace/mode/folding/cstyle"],function(e,t,n){"use strict";var r=e("../lib/oop"),i=e("./text").Mode,s=e("./css_highlight_rules").CssHighlightRules,o=e("./matching_brace_outdent").MatchingBraceOutdent,u=e("../worker/worker_client").WorkerClient,a=e("./css_completions").CssCompletions,f=e("./behaviour/css").CssBehaviour,l=e("./folding/cstyle").FoldMode,c=function(){this.HighlightRules=s,this.$outdent=new o,this.$behaviour=new f,this.$completer=new a,this.foldingRules=new l};r.inherits(c,i),function(){this.foldingRules="cStyle",this.blockComment={start:"/*",end:"*/"},this.getNextLineIndent=function(e,t,n){var r=this.$getIndent(t),i=this.getTokenizer().getLineTokens(t,e).tokens;if(i.length&&i[i.length-1].type=="comment")return r;var s=t.match(/^.*\{\s*$/);return s&&(r+=n),r},this.checkOutdent=function(e,t,n){return this.$outdent.checkOutdent(t,n)},this.autoOutdent=function(e,t,n){this.$outdent.autoOutdent(t,n)},this.getCompletions=function(e,t,n,r){return this.$completer.getCompletions(e,t,n,r)},this.createWorker=function(e){var t=new u(["ace"],"ace/mode/css_worker","Worker");return t.attachToDocument(e.getDocument()),t.on("annotate",function(t){e.setAnnotations(t.data)}),t.on("terminate",function(){e.clearAnnotations()}),t},this.$id="ace/mode/css"}.call(c.prototype),t.Mode=c}),ace.define("ace/mode/xml_highlight_rules",["require","exports","module","ace/lib/oop","ace/mode/text_highlight_rules"],function(e,t,n){"use strict";var r=e("../lib/oop"),i=e("./text_highlight_rules").TextHighlightRules,s=function(e){var t="[_:a-zA-Z\u00c0-\uffff][-_:.a-zA-Z0-9\u00c0-\uffff]*";this.$rules={start:[{token:"string.cdata.xml",regex:"<\\!\\[CDATA\\[",next:"cdata"},{token:["punctuation.xml-decl.xml","keyword.xml-decl.xml"],regex:"(<\\?)(xml)(?=[\\s])",next:"xml_decl",caseInsensitive:!0},{token:["punctuation.instruction.xml","keyword.instruction.xml"],regex:"(<\\?)("+t+")",next:"processing_instruction"},{token:"comment.xml",regex:"<\\!--",next:"comment"},{token:["xml-pe.doctype.xml","xml-pe.doctype.xml"],regex:"(<\\!)(DOCTYPE)(?=[\\s])",next:"doctype",caseInsensitive:!0},{include:"tag"},{token:"text.end-tag-open.xml",regex:"",next:"start"}],processing_instruction:[{token:"punctuation.instruction.xml",regex:"\\?>",next:"start"},{defaultToken:"instruction.xml"}],doctype:[{include:"whitespace"},{include:"string"},{token:"xml-pe.doctype.xml",regex:">",next:"start"},{token:"xml-pe.xml",regex:"[-_a-zA-Z0-9:]+"},{token:"punctuation.int-subset",regex:"\\[",push:"int_subset"}],int_subset:[{token:"text.xml",regex:"\\s+"},{token:"punctuation.int-subset.xml",regex:"]",next:"pop"},{token:["punctuation.markup-decl.xml","keyword.markup-decl.xml"],regex:"(<\\!)("+t+")",push:[{token:"text",regex:"\\s+"},{token:"punctuation.markup-decl.xml",regex:">",next:"pop"},{include:"string"}]}],cdata:[{token:"string.cdata.xml",regex:"\\]\\]>",next:"start"},{token:"text.xml",regex:"\\s+"},{token:"text.xml",regex:"(?:[^\\]]|\\](?!\\]>))+"}],comment:[{token:"comment.xml",regex:"-->",next:"start"},{defaultToken:"comment.xml"}],reference:[{token:"constant.language.escape.reference.xml",regex:"(?:&#[0-9]+;)|(?:&#x[0-9a-fA-F]+;)|(?:&[a-zA-Z0-9_:\\.-]+;)"}],attr_reference:[{token:"constant.language.escape.reference.attribute-value.xml",regex:"(?:&#[0-9]+;)|(?:&#x[0-9a-fA-F]+;)|(?:&[a-zA-Z0-9_:\\.-]+;)"}],tag:[{token:["meta.tag.punctuation.tag-open.xml","meta.tag.punctuation.end-tag-open.xml","meta.tag.tag-name.xml"],regex:"(?:(<)|(",next:"start"}]}],tag_whitespace:[{token:"text.tag-whitespace.xml",regex:"\\s+"}],whitespace:[{token:"text.whitespace.xml",regex:"\\s+"}],string:[{token:"string.xml",regex:"'",push:[{token:"string.xml",regex:"'",next:"pop"},{defaultToken:"string.xml"}]},{token:"string.xml",regex:'"',push:[{token:"string.xml",regex:'"',next:"pop"},{defaultToken:"string.xml"}]}],attributes:[{token:"entity.other.attribute-name.xml",regex:"(?:"+t+":)?"+t+""},{token:"keyword.operator.attribute-equals.xml",regex:"="},{include:"tag_whitespace"},{include:"attribute_value"}],attribute_value:[{token:"string.attribute-value.xml",regex:"'",push:[{token:"string.attribute-value.xml",regex:"'",next:"pop"},{include:"attr_reference"},{defaultToken:"string.attribute-value.xml"}]},{token:"string.attribute-value.xml",regex:'"',push:[{token:"string.attribute-value.xml",regex:'"',next:"pop"},{include:"attr_reference"},{defaultToken:"string.attribute-value.xml"}]}]},this.constructor===s&&this.normalizeRules()};(function(){this.embedTagRules=function(e,t,n){this.$rules.tag.unshift({token:["meta.tag.punctuation.tag-open.xml","meta.tag."+n+".tag-name.xml"],regex:"(<)("+n+"(?=\\s|>|$))",next:[{include:"attributes"},{token:"meta.tag.punctuation.tag-close.xml",regex:"/?>",next:t+"start"}]}),this.$rules[n+"-end"]=[{include:"attributes"},{token:"meta.tag.punctuation.tag-close.xml",regex:"/?>",next:"start",onMatch:function(e,t,n){return n.splice(0),this.token}}],this.embedRules(e,t,[{token:["meta.tag.punctuation.end-tag-open.xml","meta.tag."+n+".tag-name.xml"],regex:"(|$))",next:n+"-end"},{token:"string.cdata.xml",regex:"<\\!\\[CDATA\\["},{token:"string.cdata.xml",regex:"\\]\\]>"}])}}).call(i.prototype),r.inherits(s,i),t.XmlHighlightRules=s}),ace.define("ace/mode/html_highlight_rules",["require","exports","module","ace/lib/oop","ace/lib/lang","ace/mode/css_highlight_rules","ace/mode/javascript_highlight_rules","ace/mode/xml_highlight_rules"],function(e,t,n){"use strict";var r=e("../lib/oop"),i=e("../lib/lang"),s=e("./css_highlight_rules").CssHighlightRules,o=e("./javascript_highlight_rules").JavaScriptHighlightRules,u=e("./xml_highlight_rules").XmlHighlightRules,a=i.createMap({a:"anchor",button:"form",form:"form",img:"image",input:"form",label:"form",option:"form",script:"script",select:"form",textarea:"form",style:"style",table:"table",tbody:"table",td:"table",tfoot:"table",th:"table",tr:"table"}),f=function(){u.call(this),this.addRules({attributes:[{include:"tag_whitespace"},{token:"entity.other.attribute-name.xml",regex:"[-_a-zA-Z0-9:.]+"},{token:"keyword.operator.attribute-equals.xml",regex:"=",push:[{include:"tag_whitespace"},{token:"string.unquoted.attribute-value.html",regex:"[^<>='\"`\\s]+",next:"pop"},{token:"empty",regex:"",next:"pop"}]},{include:"attribute_value"}],tag:[{token:function(e,t){var n=a[t];return["meta.tag.punctuation."+(e=="<"?"":"end-")+"tag-open.xml","meta.tag"+(n?"."+n:"")+".tag-name.xml"]},regex:"(",next:"start"}]}),this.embedTagRules(s,"css-","style"),this.embedTagRules((new o({noJSX:!0})).getRules(),"js-","script"),this.constructor===f&&this.normalizeRules()};r.inherits(f,u),t.HtmlHighlightRules=f}),ace.define("ace/mode/behaviour/xml",["require","exports","module","ace/lib/oop","ace/mode/behaviour","ace/token_iterator","ace/lib/lang"],function(e,t,n){"use strict";function u(e,t){return e.type.lastIndexOf(t+".xml")>-1}var r=e("../../lib/oop"),i=e("../behaviour").Behaviour,s=e("../../token_iterator").TokenIterator,o=e("../../lib/lang"),a=function(){this.add("string_dquotes","insertion",function(e,t,n,r,i){if(i=='"'||i=="'"){var o=i,a=r.doc.getTextRange(n.getSelectionRange());if(a!==""&&a!=="'"&&a!='"'&&n.getWrapBehavioursEnabled())return{text:o+a+o,selection:!1};var f=n.getCursorPosition(),l=r.doc.getLine(f.row),c=l.substring(f.column,f.column+1),h=new s(r,f.row,f.column),p=h.getCurrentToken();if(c==o&&(u(p,"attribute-value")||u(p,"string")))return{text:"",selection:[1,1]};p||(p=h.stepBackward());if(!p)return;while(u(p,"tag-whitespace")||u(p,"whitespace"))p=h.stepBackward();var d=!c||c.match(/\s/);if(u(p,"attribute-equals")&&(d||c==">")||u(p,"decl-attribute-equals")&&(d||c=="?"))return{text:o+o,selection:[1,1]}}}),this.add("string_dquotes","deletion",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&(s=='"'||s=="'")){var o=r.doc.getLine(i.start.row),u=o.substring(i.start.column+1,i.start.column+2);if(u==s)return i.end.column++,i}}),this.add("autoclosing","insertion",function(e,t,n,r,i){if(i==">"){var o=n.getCursorPosition(),a=new s(r,o.row,o.column),f=a.getCurrentToken()||a.stepBackward();if(!f||!(u(f,"tag-name")||u(f,"tag-whitespace")||u(f,"attribute-name")||u(f,"attribute-equals")||u(f,"attribute-value")))return;if(u(f,"reference.attribute-value"))return;if(u(f,"attribute-value")){var l=f.value.charAt(0);if(l=='"'||l=="'"){var c=f.value.charAt(f.value.length-1),h=a.getCurrentTokenColumn()+f.value.length;if(h>o.column||h==o.column&&l!=c)return}}while(!u(f,"tag-name"))f=a.stepBackward();var p=a.getCurrentTokenRow(),d=a.getCurrentTokenColumn();if(u(a.stepBackward(),"end-tag-open"))return;var v=f.value;p==o.row&&(v=v.substring(0,o.column-d));if(this.voidElements.hasOwnProperty(v.toLowerCase()))return;return{text:">",selection:[1,1]}}}),this.add("autoindent","insertion",function(e,t,n,r,i){if(i=="\n"){var o=n.getCursorPosition(),u=r.getLine(o.row),a=new s(r,o.row,o.column),f=a.getCurrentToken();if(f&&f.type.indexOf("tag-close")!==-1){if(f.value=="/>")return;while(f&&f.type.indexOf("tag-name")===-1)f=a.stepBackward();if(!f)return;var l=f.value,c=a.getCurrentTokenRow();f=a.stepBackward();if(!f||f.type.indexOf("end-tag")!==-1)return;if(this.voidElements&&!this.voidElements[l]){var h=r.getTokenAt(o.row,o.column+1),u=r.getLine(c),p=this.$getIndent(u),d=p+r.getTabString();return h&&h.value==="-1}var r=e("../../lib/oop"),i=e("../../lib/lang"),s=e("../../range").Range,o=e("./fold_mode").FoldMode,u=e("../../token_iterator").TokenIterator,a=t.FoldMode=function(e,t){o.call(this),this.voidElements=e||{},this.optionalEndTags=r.mixin({},this.voidElements),t&&r.mixin(this.optionalEndTags,t)};r.inherits(a,o);var f=function(){this.tagName="",this.closing=!1,this.selfClosing=!1,this.start={row:0,column:0},this.end={row:0,column:0}};(function(){this.getFoldWidget=function(e,t,n){var r=this._getFirstTagInLine(e,n);return r?r.closing||!r.tagName&&r.selfClosing?t=="markbeginend"?"end":"":!r.tagName||r.selfClosing||this.voidElements.hasOwnProperty(r.tagName.toLowerCase())?"":this._findEndTagInLine(e,n,r.tagName,r.end.column)?"":"start":""},this._getFirstTagInLine=function(e,t){var n=e.getTokens(t),r=new f;for(var i=0;i";break}}return r}if(l(s,"tag-close"))return r.selfClosing=s.value=="/>",r;r.start.column+=s.value.length}return null},this._findEndTagInLine=function(e,t,n,r){var i=e.getTokens(t),s=0;for(var o=0;o",n.end.row=e.getCurrentTokenRow(),n.end.column=e.getCurrentTokenColumn()+t.value.length,e.stepForward(),n;while(t=e.stepForward());return null},this._readTagBackward=function(e){var t=e.getCurrentToken();if(!t)return null;var n=new f;do{if(l(t,"tag-open"))return n.closing=l(t,"end-tag-open"),n.start.row=e.getCurrentTokenRow(),n.start.column=e.getCurrentTokenColumn(),e.stepBackward(),n;l(t,"tag-name")?n.tagName=t.value:l(t,"tag-close")&&(n.selfClosing=t.value=="/>",n.end.row=e.getCurrentTokenRow(),n.end.column=e.getCurrentTokenColumn()+t.value.length)}while(t=e.stepBackward());return null},this._pop=function(e,t){while(e.length){var n=e[e.length-1];if(!t||n.tagName==t.tagName)return e.pop();if(this.optionalEndTags.hasOwnProperty(n.tagName)){e.pop();continue}return null}},this.getFoldWidgetRange=function(e,t,n){var r=this._getFirstTagInLine(e,n);if(!r)return null;var i=r.closing||r.selfClosing,o=[],a;if(!i){var f=new u(e,n,r.start.column),l={row:n,column:r.start.column+r.tagName.length+2};r.start.row==r.end.row&&(l.column=r.end.column);while(a=this._readTagForward(f)){if(a.selfClosing){if(!o.length)return a.start.column+=a.tagName.length+2,a.end.column-=2,s.fromPoints(a.start,a.end);continue}if(a.closing){this._pop(o,a);if(o.length==0)return s.fromPoints(l,a.start)}else o.push(a)}}else{var f=new u(e,n,r.end.column),c={row:n,column:r.start.column};while(a=this._readTagBackward(f)){if(a.selfClosing){if(!o.length)return a.start.column+=a.tagName.length+2,a.end.column-=2,s.fromPoints(a.start,a.end);continue}if(!a.closing){this._pop(o,a);if(o.length==0)return a.start.column+=a.tagName.length+2,a.start.row==a.end.row&&a.start.column-1}function l(e,t){var n=new r(e,t.row,t.column),i=n.getCurrentToken();while(i&&!f(i,"tag-name"))i=n.stepBackward();if(i)return i.value}function c(e,t){var n=new r(e,t.row,t.column),i=n.getCurrentToken();while(i&&!f(i,"attribute-name"))i=n.stepBackward();if(i)return i.value}var r=e("../token_iterator").TokenIterator,i=["accesskey","class","contenteditable","contextmenu","dir","draggable","dropzone","hidden","id","inert","itemid","itemprop","itemref","itemscope","itemtype","lang","spellcheck","style","tabindex","title","translate"],s=["onabort","onblur","oncancel","oncanplay","oncanplaythrough","onchange","onclick","onclose","oncontextmenu","oncuechange","ondblclick","ondrag","ondragend","ondragenter","ondragleave","ondragover","ondragstart","ondrop","ondurationchange","onemptied","onended","onerror","onfocus","oninput","oninvalid","onkeydown","onkeypress","onkeyup","onload","onloadeddata","onloadedmetadata","onloadstart","onmousedown","onmousemove","onmouseout","onmouseover","onmouseup","onmousewheel","onpause","onplay","onplaying","onprogress","onratechange","onreset","onscroll","onseeked","onseeking","onselect","onshow","onstalled","onsubmit","onsuspend","ontimeupdate","onvolumechange","onwaiting"],o=i.concat(s),u={html:{manifest:1},head:{},title:{},base:{href:1,target:1},link:{href:1,hreflang:1,rel:{stylesheet:1,icon:1},media:{all:1,screen:1,print:1},type:{"text/css":1,"image/png":1,"image/jpeg":1,"image/gif":1},sizes:1},meta:{"http-equiv":{"content-type":1},name:{description:1,keywords:1},content:{"text/html; charset=UTF-8":1},charset:1},style:{type:1,media:{all:1,screen:1,print:1},scoped:1},script:{charset:1,type:{"text/javascript":1},src:1,defer:1,async:1},noscript:{href:1},body:{onafterprint:1,onbeforeprint:1,onbeforeunload:1,onhashchange:1,onmessage:1,onoffline:1,onpopstate:1,onredo:1,onresize:1,onstorage:1,onundo:1,onunload:1},section:{},nav:{},article:{pubdate:1},aside:{},h1:{},h2:{},h3:{},h4:{},h5:{},h6:{},header:{},footer:{},address:{},main:{},p:{},hr:{},pre:{},blockquote:{cite:1},ol:{start:1,reversed:1},ul:{},li:{value:1},dl:{},dt:{},dd:{},figure:{},figcaption:{},div:{},a:{href:1,target:{_blank:1,top:1},ping:1,rel:{nofollow:1,alternate:1,author:1,bookmark:1,help:1,license:1,next:1,noreferrer:1,prefetch:1,prev:1,search:1,tag:1},media:1,hreflang:1,type:1},em:{},strong:{},small:{},s:{},cite:{},q:{cite:1},dfn:{},abbr:{},data:{},time:{datetime:1},code:{},"var":{},samp:{},kbd:{},sub:{},sup:{},i:{},b:{},u:{},mark:{},ruby:{},rt:{},rp:{},bdi:{},bdo:{},span:{},br:{},wbr:{},ins:{cite:1,datetime:1},del:{cite:1,datetime:1},img:{alt:1,src:1,height:1,width:1,usemap:1,ismap:1},iframe:{name:1,src:1,height:1,width:1,sandbox:{"allow-same-origin":1,"allow-top-navigation":1,"allow-forms":1,"allow-scripts":1},seamless:{seamless:1}},embed:{src:1,height:1,width:1,type:1},object:{param:1,data:1,type:1,height:1,width:1,usemap:1,name:1,form:1,classid:1},param:{name:1,value:1},video:{src:1,autobuffer:1,autoplay:{autoplay:1},loop:{loop:1},controls:{controls:1},width:1,height:1,poster:1,muted:{muted:1},preload:{auto:1,metadata:1,none:1}},audio:{src:1,autobuffer:1,autoplay:{autoplay:1},loop:{loop:1},controls:{controls:1},muted:{muted:1},preload:{auto:1,metadata:1,none:1}},source:{src:1,type:1,media:1},track:{kind:1,src:1,srclang:1,label:1,"default":1},canvas:{width:1,height:1},map:{name:1},area:{shape:1,coords:1,href:1,hreflang:1,alt:1,target:1,media:1,rel:1,ping:1,type:1},svg:{},math:{},table:{summary:1},caption:{},colgroup:{span:1},col:{span:1},tbody:{},thead:{},tfoot:{},tr:{},td:{headers:1,rowspan:1,colspan:1},th:{headers:1,rowspan:1,colspan:1,scope:1},form:{"accept-charset":1,action:1,autocomplete:1,enctype:{"multipart/form-data":1,"application/x-www-form-urlencoded":1},method:{get:1,post:1},name:1,novalidate:1,target:{_blank:1,top:1}},fieldset:{disabled:1,form:1,name:1},legend:{},label:{form:1,"for":1},input:{type:{text:1,password:1,hidden:1,checkbox:1,submit:1,radio:1,file:1,button:1,reset:1,image:31,color:1,date:1,datetime:1,"datetime-local":1,email:1,month:1,number:1,range:1,search:1,tel:1,time:1,url:1,week:1},accept:1,alt:1,autocomplete:{on:1,off:1},autofocus:{autofocus:1},checked:{checked:1},disabled:{disabled:1},form:1,formaction:1,formenctype:{"application/x-www-form-urlencoded":1,"multipart/form-data":1,"text/plain":1},formmethod:{get:1,post:1},formnovalidate:{formnovalidate:1},formtarget:{_blank:1,_self:1,_parent:1,_top:1},height:1,list:1,max:1,maxlength:1,min:1,multiple:{multiple:1},pattern:1,placeholder:1,readonly:{readonly:1},required:{required:1},size:1,src:1,step:1,width:1,files:1,value:1},button:{autofocus:1,disabled:{disabled:1},form:1,formaction:1,formenctype:1,formmethod:1,formnovalidate:1,formtarget:1,name:1,value:1,type:{button:1,submit:1}},select:{autofocus:1,disabled:1,form:1,multiple:{multiple:1},name:1,size:1,readonly:{readonly:1}},datalist:{},optgroup:{disabled:1,label:1},option:{disabled:1,selected:1,label:1,value:1},textarea:{autofocus:{autofocus:1},disabled:{disabled:1},form:1,maxlength:1,name:1,placeholder:1,readonly:{readonly:1},required:{required:1},rows:1,cols:1,wrap:{on:1,off:1,hard:1,soft:1}},keygen:{autofocus:1,challenge:{challenge:1},disabled:{disabled:1},form:1,keytype:{rsa:1,dsa:1,ec:1},name:1},output:{"for":1,form:1,name:1},progress:{value:1,max:1},meter:{value:1,min:1,max:1,low:1,high:1,optimum:1},details:{open:1},summary:{},command:{type:1,label:1,icon:1,disabled:1,checked:1,radiogroup:1,command:1},menu:{type:1,label:1},dialog:{open:1}},a=Object.keys(u),h=function(){};(function(){this.getCompletions=function(e,t,n,r){var i=t.getTokenAt(n.row,n.column);if(!i)return[];if(f(i,"tag-name")||f(i,"tag-open")||f(i,"end-tag-open"))return this.getTagCompletions(e,t,n,r);if(f(i,"tag-whitespace")||f(i,"attribute-name"))return this.getAttributeCompletions(e,t,n,r);if(f(i,"attribute-value"))return this.getAttributeValueCompletions(e,t,n,r);var s=t.getLine(n.row).substr(0,n.column);return/&[A-z]*$/i.test(s)?this.getHTMLEntityCompletions(e,t,n,r):[]},this.getTagCompletions=function(e,t,n,r){return a.map(function(e){return{value:e,meta:"tag",score:Number.MAX_VALUE}})},this.getAttributeCompletions=function(e,t,n,r){var i=l(t,n);if(!i)return[];var s=o;return i in u&&(s=s.concat(Object.keys(u[i]))),s.map(function(e){return{caption:e,snippet:e+'="$0"',meta:"attribute",score:Number.MAX_VALUE}})},this.getAttributeValueCompletions=function(e,t,n,r){var i=l(t,n),s=c(t,n);if(!i)return[];var o=[];return i in u&&s in u[i]&&typeof u[i][s]=="object"&&(o=Object.keys(u[i][s])),o.map(function(e){return{caption:e,snippet:e,meta:"attribute value",score:Number.MAX_VALUE}})},this.getHTMLEntityCompletions=function(e,t,n,r){var i=["Aacute;","aacute;","Acirc;","acirc;","acute;","AElig;","aelig;","Agrave;","agrave;","alefsym;","Alpha;","alpha;","amp;","and;","ang;","Aring;","aring;","asymp;","Atilde;","atilde;","Auml;","auml;","bdquo;","Beta;","beta;","brvbar;","bull;","cap;","Ccedil;","ccedil;","cedil;","cent;","Chi;","chi;","circ;","clubs;","cong;","copy;","crarr;","cup;","curren;","Dagger;","dagger;","dArr;","darr;","deg;","Delta;","delta;","diams;","divide;","Eacute;","eacute;","Ecirc;","ecirc;","Egrave;","egrave;","empty;","emsp;","ensp;","Epsilon;","epsilon;","equiv;","Eta;","eta;","ETH;","eth;","Euml;","euml;","euro;","exist;","fnof;","forall;","frac12;","frac14;","frac34;","frasl;","Gamma;","gamma;","ge;","gt;","hArr;","harr;","hearts;","hellip;","Iacute;","iacute;","Icirc;","icirc;","iexcl;","Igrave;","igrave;","image;","infin;","int;","Iota;","iota;","iquest;","isin;","Iuml;","iuml;","Kappa;","kappa;","Lambda;","lambda;","lang;","laquo;","lArr;","larr;","lceil;","ldquo;","le;","lfloor;","lowast;","loz;","lrm;","lsaquo;","lsquo;","lt;","macr;","mdash;","micro;","middot;","minus;","Mu;","mu;","nabla;","nbsp;","ndash;","ne;","ni;","not;","notin;","nsub;","Ntilde;","ntilde;","Nu;","nu;","Oacute;","oacute;","Ocirc;","ocirc;","OElig;","oelig;","Ograve;","ograve;","oline;","Omega;","omega;","Omicron;","omicron;","oplus;","or;","ordf;","ordm;","Oslash;","oslash;","Otilde;","otilde;","otimes;","Ouml;","ouml;","para;","part;","permil;","perp;","Phi;","phi;","Pi;","pi;","piv;","plusmn;","pound;","Prime;","prime;","prod;","prop;","Psi;","psi;","quot;","radic;","rang;","raquo;","rArr;","rarr;","rceil;","rdquo;","real;","reg;","rfloor;","Rho;","rho;","rlm;","rsaquo;","rsquo;","sbquo;","Scaron;","scaron;","sdot;","sect;","shy;","Sigma;","sigma;","sigmaf;","sim;","spades;","sub;","sube;","sum;","sup;","sup1;","sup2;","sup3;","supe;","szlig;","Tau;","tau;","there4;","Theta;","theta;","thetasym;","thinsp;","THORN;","thorn;","tilde;","times;","trade;","Uacute;","uacute;","uArr;","uarr;","Ucirc;","ucirc;","Ugrave;","ugrave;","uml;","upsih;","Upsilon;","upsilon;","Uuml;","uuml;","weierp;","Xi;","xi;","Yacute;","yacute;","yen;","Yuml;","yuml;","Zeta;","zeta;","zwj;","zwnj;"];return i.map(function(e){return{caption:e,snippet:e,meta:"html entity",score:Number.MAX_VALUE}})}}).call(h.prototype),t.HtmlCompletions=h}),ace.define("ace/mode/html",["require","exports","module","ace/lib/oop","ace/lib/lang","ace/mode/text","ace/mode/javascript","ace/mode/css","ace/mode/html_highlight_rules","ace/mode/behaviour/xml","ace/mode/folding/html","ace/mode/html_completions","ace/worker/worker_client"],function(e,t,n){"use strict";var r=e("../lib/oop"),i=e("../lib/lang"),s=e("./text").Mode,o=e("./javascript").Mode,u=e("./css").Mode,a=e("./html_highlight_rules").HtmlHighlightRules,f=e("./behaviour/xml").XmlBehaviour,l=e("./folding/html").FoldMode,c=e("./html_completions").HtmlCompletions,h=e("../worker/worker_client").WorkerClient,p=["area","base","br","col","embed","hr","img","input","keygen","link","meta","menuitem","param","source","track","wbr"],d=["li","dt","dd","p","rt","rp","optgroup","option","colgroup","td","th"],v=function(e){this.fragmentContext=e&&e.fragmentContext,this.HighlightRules=a,this.$behaviour=new f,this.$completer=new c,this.createModeDelegates({"js-":o,"css-":u}),this.foldingRules=new l(this.voidElements,i.arrayToMap(d))};r.inherits(v,s),function(){this.blockComment={start:""},this.voidElements=i.arrayToMap(p),this.getNextLineIndent=function(e,t,n){return this.$getIndent(t)},this.checkOutdent=function(e,t,n){return!1},this.getCompletions=function(e,t,n,r){return this.$completer.getCompletions(e,t,n,r)},this.createWorker=function(e){if(this.constructor!=v)return;var t=new h(["ace"],"ace/mode/html_worker","Worker");return t.attachToDocument(e.getDocument()),this.fragmentContext&&t.call("setOptions",[{context:this.fragmentContext}]),t.on("error",function(t){e.setAnnotations(t.data)}),t.on("terminate",function(){e.clearAnnotations()}),t},this.$id="ace/mode/html"}.call(v.prototype),t.Mode=v}),ace.define("ace/mode/handlebars_highlight_rules",["require","exports","module","ace/lib/oop","ace/mode/html_highlight_rules"],function(e,t,n){"use strict";function s(e,t){return t.splice(0,3),t.shift()||"start"}var r=e("../lib/oop"),i=e("./html_highlight_rules").HtmlHighlightRules,o=function(){i.call(this);var e={regex:"(?={{)",push:"handlebars"};for(var t in this.$rules)this.$rules[t].unshift(e);this.$rules.handlebars=[{token:"comment.start",regex:"{{!--",push:[{token:"comment.end",regex:"--}}",next:s},{defaultToken:"comment"}]},{token:"comment.start",regex:"{{!",push:[{token:"comment.end",regex:"}}",next:s},{defaultToken:"comment"}]},{token:"support.function",regex:"{{{",push:[{token:"support.function",regex:"}}}",next:s},{token:"variable.parameter",regex:"[a-zA-Z_$][a-zA-Z0-9_$]*"}]},{token:"storage.type.start",regex:"{{[#\\^/&]?",push:[{token:"storage.type.end",regex:"}}",next:s},{token:"variable.parameter",regex:"[a-zA-Z_$][a-zA-Z0-9_$]*"}]}],this.normalizeRules()};r.inherits(o,i),t.HandlebarsHighlightRules=o}),ace.define("ace/mode/behaviour/html",["require","exports","module","ace/lib/oop","ace/mode/behaviour/xml"],function(e,t,n){"use strict";var r=e("../../lib/oop"),i=e("../behaviour/xml").XmlBehaviour,s=function(){i.call(this)};r.inherits(s,i),t.HtmlBehaviour=s}),ace.define("ace/mode/handlebars",["require","exports","module","ace/lib/oop","ace/mode/html","ace/mode/handlebars_highlight_rules","ace/mode/behaviour/html","ace/mode/folding/html"],function(e,t,n){"use strict";var r=e("../lib/oop"),i=e("./html").Mode,s=e("./handlebars_highlight_rules").HandlebarsHighlightRules,o=e("./behaviour/html").HtmlBehaviour,u=e("./folding/html").FoldMode,a=function(){i.call(this),this.HighlightRules=s,this.$behaviour=new o,this.foldingRules=new u};r.inherits(a,i),function(){this.blockComment={start:"{{!--",end:"--}}"},this.$id="ace/mode/handlebars"}.call(a.prototype),t.Mode=a}) \ No newline at end of file diff --git a/public/ace/mode-javascript.js b/public/ace/mode-javascript.js new file mode 100644 index 00000000..26a8bdc8 --- /dev/null +++ b/public/ace/mode-javascript.js @@ -0,0 +1 @@ +ace.define("ace/mode/doc_comment_highlight_rules",["require","exports","module","ace/lib/oop","ace/mode/text_highlight_rules"],function(e,t,n){"use strict";var r=e("../lib/oop"),i=e("./text_highlight_rules").TextHighlightRules,s=function(){this.$rules={start:[{token:"comment.doc.tag",regex:"@[\\w\\d_]+"},s.getTagRule(),{defaultToken:"comment.doc",caseInsensitive:!0}]}};r.inherits(s,i),s.getTagRule=function(e){return{token:"comment.doc.tag.storage.type",regex:"\\b(?:TODO|FIXME|XXX|HACK)\\b"}},s.getStartRule=function(e){return{token:"comment.doc",regex:"\\/\\*(?=\\*)",next:e}},s.getEndRule=function(e){return{token:"comment.doc",regex:"\\*\\/",next:e}},t.DocCommentHighlightRules=s}),ace.define("ace/mode/javascript_highlight_rules",["require","exports","module","ace/lib/oop","ace/mode/doc_comment_highlight_rules","ace/mode/text_highlight_rules"],function(e,t,n){"use strict";function a(){var e=o.replace("\\d","\\d\\-"),t={onMatch:function(e,t,n){var r=e.charAt(1)=="/"?2:1;if(r==1)t!=this.nextState?n.unshift(this.next,this.nextState,0):n.unshift(this.next),n[2]++;else if(r==2&&t==this.nextState){n[1]--;if(!n[1]||n[1]<0)n.shift(),n.shift()}return[{type:"meta.tag.punctuation."+(r==1?"":"end-")+"tag-open.xml",value:e.slice(0,r)},{type:"meta.tag.tag-name.xml",value:e.substr(r)}]},regex:"",onMatch:function(e,t,n){return t==n[0]&&n.shift(),e.length==2&&(n[0]==this.nextState&&n[1]--,(!n[1]||n[1]<0)&&n.splice(0,2)),this.next=n[0]||"start",[{type:this.token,value:e}]},nextState:"jsx"},n,f("jsxAttributes"),{token:"entity.other.attribute-name.xml",regex:e},{token:"keyword.operator.attribute-equals.xml",regex:"="},{token:"text.tag-whitespace.xml",regex:"\\s+"},{token:"string.attribute-value.xml",regex:"'",stateName:"jsx_attr_q",push:[{token:"string.attribute-value.xml",regex:"'",next:"pop"},{include:"reference"},{defaultToken:"string.attribute-value.xml"}]},{token:"string.attribute-value.xml",regex:'"',stateName:"jsx_attr_qq",push:[{token:"string.attribute-value.xml",regex:'"',next:"pop"},{include:"reference"},{defaultToken:"string.attribute-value.xml"}]},t],this.$rules.reference=[{token:"constant.language.escape.reference.xml",regex:"(?:&#[0-9]+;)|(?:&#x[0-9a-fA-F]+;)|(?:&[a-zA-Z0-9_:\\.-]+;)"}]}function f(e){return[{token:"comment",regex:/\/\*/,next:[i.getTagRule(),{token:"comment",regex:"\\*\\/",next:e||"pop"},{defaultToken:"comment",caseInsensitive:!0}]},{token:"comment",regex:"\\/\\/",next:[i.getTagRule(),{token:"comment",regex:"$|^",next:e||"pop"},{defaultToken:"comment",caseInsensitive:!0}]}]}var r=e("../lib/oop"),i=e("./doc_comment_highlight_rules").DocCommentHighlightRules,s=e("./text_highlight_rules").TextHighlightRules,o="[a-zA-Z\\$_\u00a1-\uffff][a-zA-Z\\d\\$_\u00a1-\uffff]*\\b",u=function(e){var t=this.createKeywordMapper({"variable.language":"Array|Boolean|Date|Function|Iterator|Number|Object|RegExp|String|Proxy|Namespace|QName|XML|XMLList|ArrayBuffer|Float32Array|Float64Array|Int16Array|Int32Array|Int8Array|Uint16Array|Uint32Array|Uint8Array|Uint8ClampedArray|Error|EvalError|InternalError|RangeError|ReferenceError|StopIteration|SyntaxError|TypeError|URIError|decodeURI|decodeURIComponent|encodeURI|encodeURIComponent|eval|isFinite|isNaN|parseFloat|parseInt|JSON|Math|this|arguments|prototype|window|document",keyword:"const|yield|import|get|set|break|case|catch|continue|default|delete|do|else|finally|for|function|if|in|instanceof|new|return|switch|throw|try|typeof|let|var|while|with|debugger|__parent__|__count__|escape|unescape|with|__proto__|class|enum|extends|super|export|implements|private|public|interface|package|protected|static","storage.type":"const|let|var|function","constant.language":"null|Infinity|NaN|undefined","support.function":"alert","constant.language.boolean":"true|false"},"identifier"),n="case|do|else|finally|in|instanceof|return|throw|try|typeof|yield|void",r="\\\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|u{[0-9a-fA-F]{1,6}}|[0-2][0-7]{0,2}|3[0-7][0-7]?|[4-7][0-7]?|.)";this.$rules={no_regex:[i.getStartRule("doc-start"),f("no_regex"),{token:"string",regex:"'(?=.)",next:"qstring"},{token:"string",regex:'"(?=.)',next:"qqstring"},{token:"constant.numeric",regex:/0(?:[xX][0-9a-fA-F]+|[bB][01]+)\b/},{token:"constant.numeric",regex:/[+-]?\d[\d_]*(?:(?:\.\d*)?(?:[eE][+-]?\d+)?)?\b/},{token:["storage.type","punctuation.operator","support.function","punctuation.operator","entity.name.function","text","keyword.operator"],regex:"("+o+")(\\.)(prototype)(\\.)("+o+")(\\s*)(=)",next:"function_arguments"},{token:["storage.type","punctuation.operator","entity.name.function","text","keyword.operator","text","storage.type","text","paren.lparen"],regex:"("+o+")(\\.)("+o+")(\\s*)(=)(\\s*)(function)(\\s*)(\\()",next:"function_arguments"},{token:["entity.name.function","text","keyword.operator","text","storage.type","text","paren.lparen"],regex:"("+o+")(\\s*)(=)(\\s*)(function)(\\s*)(\\()",next:"function_arguments"},{token:["storage.type","punctuation.operator","entity.name.function","text","keyword.operator","text","storage.type","text","entity.name.function","text","paren.lparen"],regex:"("+o+")(\\.)("+o+")(\\s*)(=)(\\s*)(function)(\\s+)(\\w+)(\\s*)(\\()",next:"function_arguments"},{token:["storage.type","text","entity.name.function","text","paren.lparen"],regex:"(function)(\\s+)("+o+")(\\s*)(\\()",next:"function_arguments"},{token:["entity.name.function","text","punctuation.operator","text","storage.type","text","paren.lparen"],regex:"("+o+")(\\s*)(:)(\\s*)(function)(\\s*)(\\()",next:"function_arguments"},{token:["text","text","storage.type","text","paren.lparen"],regex:"(:)(\\s*)(function)(\\s*)(\\()",next:"function_arguments"},{token:"keyword",regex:"(?:"+n+")\\b",next:"start"},{token:["support.constant"],regex:/that\b/},{token:["storage.type","punctuation.operator","support.function.firebug"],regex:/(console)(\.)(warn|info|log|error|time|trace|timeEnd|assert)\b/},{token:t,regex:o},{token:"punctuation.operator",regex:/[.](?![.])/,next:"property"},{token:"keyword.operator",regex:/--|\+\+|\.{3}|===|==|=|!=|!==|<+=?|>+=?|!|&&|\|\||\?\:|[!$%&*+\-~\/^]=?/,next:"start"},{token:"punctuation.operator",regex:/[?:,;.]/,next:"start"},{token:"paren.lparen",regex:/[\[({]/,next:"start"},{token:"paren.rparen",regex:/[\])}]/},{token:"comment",regex:/^#!.*$/}],property:[{token:"text",regex:"\\s+"},{token:["storage.type","punctuation.operator","entity.name.function","text","keyword.operator","text","storage.type","text","entity.name.function","text","paren.lparen"],regex:"("+o+")(\\.)("+o+")(\\s*)(=)(\\s*)(function)(?:(\\s+)(\\w+))?(\\s*)(\\()",next:"function_arguments"},{token:"punctuation.operator",regex:/[.](?![.])/},{token:"support.function",regex:/(s(?:h(?:ift|ow(?:Mod(?:elessDialog|alDialog)|Help))|croll(?:X|By(?:Pages|Lines)?|Y|To)?|t(?:op|rike)|i(?:n|zeToContent|debar|gnText)|ort|u(?:p|b(?:str(?:ing)?)?)|pli(?:ce|t)|e(?:nd|t(?:Re(?:sizable|questHeader)|M(?:i(?:nutes|lliseconds)|onth)|Seconds|Ho(?:tKeys|urs)|Year|Cursor|Time(?:out)?|Interval|ZOptions|Date|UTC(?:M(?:i(?:nutes|lliseconds)|onth)|Seconds|Hours|Date|FullYear)|FullYear|Active)|arch)|qrt|lice|avePreferences|mall)|h(?:ome|andleEvent)|navigate|c(?:har(?:CodeAt|At)|o(?:s|n(?:cat|textual|firm)|mpile)|eil|lear(?:Timeout|Interval)?|a(?:ptureEvents|ll)|reate(?:StyleSheet|Popup|EventObject))|t(?:o(?:GMTString|S(?:tring|ource)|U(?:TCString|pperCase)|Lo(?:caleString|werCase))|est|a(?:n|int(?:Enabled)?))|i(?:s(?:NaN|Finite)|ndexOf|talics)|d(?:isableExternalCapture|ump|etachEvent)|u(?:n(?:shift|taint|escape|watch)|pdateCommands)|j(?:oin|avaEnabled)|p(?:o(?:p|w)|ush|lugins.refresh|a(?:ddings|rse(?:Int|Float)?)|r(?:int|ompt|eference))|e(?:scape|nableExternalCapture|val|lementFromPoint|x(?:p|ec(?:Script|Command)?))|valueOf|UTC|queryCommand(?:State|Indeterm|Enabled|Value)|f(?:i(?:nd|le(?:ModifiedDate|Size|CreatedDate|UpdatedDate)|xed)|o(?:nt(?:size|color)|rward)|loor|romCharCode)|watch|l(?:ink|o(?:ad|g)|astIndexOf)|a(?:sin|nchor|cos|t(?:tachEvent|ob|an(?:2)?)|pply|lert|b(?:s|ort))|r(?:ou(?:nd|teEvents)|e(?:size(?:By|To)|calc|turnValue|place|verse|l(?:oad|ease(?:Capture|Events)))|andom)|g(?:o|et(?:ResponseHeader|M(?:i(?:nutes|lliseconds)|onth)|Se(?:conds|lection)|Hours|Year|Time(?:zoneOffset)?|Da(?:y|te)|UTC(?:M(?:i(?:nutes|lliseconds)|onth)|Seconds|Hours|Da(?:y|te)|FullYear)|FullYear|A(?:ttention|llResponseHeaders)))|m(?:in|ove(?:B(?:y|elow)|To(?:Absolute)?|Above)|ergeAttributes|a(?:tch|rgins|x))|b(?:toa|ig|o(?:ld|rderWidths)|link|ack))\b(?=\()/},{token:"support.function.dom",regex:/(s(?:ub(?:stringData|mit)|plitText|e(?:t(?:NamedItem|Attribute(?:Node)?)|lect))|has(?:ChildNodes|Feature)|namedItem|c(?:l(?:ick|o(?:se|neNode))|reate(?:C(?:omment|DATASection|aption)|T(?:Head|extNode|Foot)|DocumentFragment|ProcessingInstruction|E(?:ntityReference|lement)|Attribute))|tabIndex|i(?:nsert(?:Row|Before|Cell|Data)|tem)|open|delete(?:Row|C(?:ell|aption)|T(?:Head|Foot)|Data)|focus|write(?:ln)?|a(?:dd|ppend(?:Child|Data))|re(?:set|place(?:Child|Data)|move(?:NamedItem|Child|Attribute(?:Node)?)?)|get(?:NamedItem|Element(?:sBy(?:Name|TagName|ClassName)|ById)|Attribute(?:Node)?)|blur)\b(?=\()/},{token:"support.constant",regex:/(s(?:ystemLanguage|cr(?:ipts|ollbars|een(?:X|Y|Top|Left))|t(?:yle(?:Sheets)?|atus(?:Text|bar)?)|ibling(?:Below|Above)|ource|uffixes|e(?:curity(?:Policy)?|l(?:ection|f)))|h(?:istory|ost(?:name)?|as(?:h|Focus))|y|X(?:MLDocument|SLDocument)|n(?:ext|ame(?:space(?:s|URI)|Prop))|M(?:IN_VALUE|AX_VALUE)|c(?:haracterSet|o(?:n(?:structor|trollers)|okieEnabled|lorDepth|mp(?:onents|lete))|urrent|puClass|l(?:i(?:p(?:boardData)?|entInformation)|osed|asses)|alle(?:e|r)|rypto)|t(?:o(?:olbar|p)|ext(?:Transform|Indent|Decoration|Align)|ags)|SQRT(?:1_2|2)|i(?:n(?:ner(?:Height|Width)|put)|ds|gnoreCase)|zIndex|o(?:scpu|n(?:readystatechange|Line)|uter(?:Height|Width)|p(?:sProfile|ener)|ffscreenBuffering)|NEGATIVE_INFINITY|d(?:i(?:splay|alog(?:Height|Top|Width|Left|Arguments)|rectories)|e(?:scription|fault(?:Status|Ch(?:ecked|arset)|View)))|u(?:ser(?:Profile|Language|Agent)|n(?:iqueID|defined)|pdateInterval)|_content|p(?:ixelDepth|ort|ersonalbar|kcs11|l(?:ugins|atform)|a(?:thname|dding(?:Right|Bottom|Top|Left)|rent(?:Window|Layer)?|ge(?:X(?:Offset)?|Y(?:Offset)?))|r(?:o(?:to(?:col|type)|duct(?:Sub)?|mpter)|e(?:vious|fix)))|e(?:n(?:coding|abledPlugin)|x(?:ternal|pando)|mbeds)|v(?:isibility|endor(?:Sub)?|Linkcolor)|URLUnencoded|P(?:I|OSITIVE_INFINITY)|f(?:ilename|o(?:nt(?:Size|Family|Weight)|rmName)|rame(?:s|Element)|gColor)|E|whiteSpace|l(?:i(?:stStyleType|n(?:eHeight|kColor))|o(?:ca(?:tion(?:bar)?|lName)|wsrc)|e(?:ngth|ft(?:Context)?)|a(?:st(?:M(?:odified|atch)|Index|Paren)|yer(?:s|X)|nguage))|a(?:pp(?:MinorVersion|Name|Co(?:deName|re)|Version)|vail(?:Height|Top|Width|Left)|ll|r(?:ity|guments)|Linkcolor|bove)|r(?:ight(?:Context)?|e(?:sponse(?:XML|Text)|adyState))|global|x|m(?:imeTypes|ultiline|enubar|argin(?:Right|Bottom|Top|Left))|L(?:N(?:10|2)|OG(?:10E|2E))|b(?:o(?:ttom|rder(?:Width|RightWidth|BottomWidth|Style|Color|TopWidth|LeftWidth))|ufferDepth|elow|ackground(?:Color|Image)))\b/},{token:"identifier",regex:o},{regex:"",token:"empty",next:"no_regex"}],start:[i.getStartRule("doc-start"),f("start"),{token:"string.regexp",regex:"\\/",next:"regex"},{token:"text",regex:"\\s+|^$",next:"start"},{token:"empty",regex:"",next:"no_regex"}],regex:[{token:"regexp.keyword.operator",regex:"\\\\(?:u[\\da-fA-F]{4}|x[\\da-fA-F]{2}|.)"},{token:"string.regexp",regex:"/[sxngimy]*",next:"no_regex"},{token:"invalid",regex:/\{\d+\b,?\d*\}[+*]|[+*$^?][+*]|[$^][?]|\?{3,}/},{token:"constant.language.escape",regex:/\(\?[:=!]|\)|\{\d+\b,?\d*\}|[+*]\?|[()$^+*?.]/},{token:"constant.language.delimiter",regex:/\|/},{token:"constant.language.escape",regex:/\[\^?/,next:"regex_character_class"},{token:"empty",regex:"$",next:"no_regex"},{defaultToken:"string.regexp"}],regex_character_class:[{token:"regexp.charclass.keyword.operator",regex:"\\\\(?:u[\\da-fA-F]{4}|x[\\da-fA-F]{2}|.)"},{token:"constant.language.escape",regex:"]",next:"regex"},{token:"constant.language.escape",regex:"-"},{token:"empty",regex:"$",next:"no_regex"},{defaultToken:"string.regexp.charachterclass"}],function_arguments:[{token:"variable.parameter",regex:o},{token:"punctuation.operator",regex:"[, ]+"},{token:"punctuation.operator",regex:"$"},{token:"empty",regex:"",next:"no_regex"}],qqstring:[{token:"constant.language.escape",regex:r},{token:"string",regex:"\\\\$",next:"qqstring"},{token:"string",regex:'"|$',next:"no_regex"},{defaultToken:"string"}],qstring:[{token:"constant.language.escape",regex:r},{token:"string",regex:"\\\\$",next:"qstring"},{token:"string",regex:"'|$",next:"no_regex"},{defaultToken:"string"}]};if(!e||!e.noES6)this.$rules.no_regex.unshift({regex:"[{}]",onMatch:function(e,t,n){this.next=e=="{"?this.nextState:"";if(e=="{"&&n.length)n.unshift("start",t);else if(e=="}"&&n.length){n.shift(),this.next=n.shift();if(this.next.indexOf("string")!=-1||this.next.indexOf("jsx")!=-1)return"paren.quasi.end"}return e=="{"?"paren.lparen":"paren.rparen"},nextState:"start"},{token:"string.quasi.start",regex:/`/,push:[{token:"constant.language.escape",regex:r},{token:"paren.quasi.start",regex:/\${/,push:"start"},{token:"string.quasi.end",regex:/`/,next:"pop"},{defaultToken:"string.quasi"}]}),(!e||!e.noJSX)&&a.call(this);this.embedRules(i,"doc-",[i.getEndRule("no_regex")]),this.normalizeRules()};r.inherits(u,s),t.JavaScriptHighlightRules=u}),ace.define("ace/mode/matching_brace_outdent",["require","exports","module","ace/range"],function(e,t,n){"use strict";var r=e("../range").Range,i=function(){};(function(){this.checkOutdent=function(e,t){return/^\s+$/.test(e)?/^\s*\}/.test(t):!1},this.autoOutdent=function(e,t){var n=e.getLine(t),i=n.match(/^(\s*\})/);if(!i)return 0;var s=i[1].length,o=e.findMatchingBracket({row:t,column:s});if(!o||o.row==t)return 0;var u=this.$getIndent(e.getLine(o.row));e.replace(new r(t,0,t,s-1),u)},this.$getIndent=function(e){return e.match(/^\s*/)[0]}}).call(i.prototype),t.MatchingBraceOutdent=i}),ace.define("ace/mode/behaviour/cstyle",["require","exports","module","ace/lib/oop","ace/mode/behaviour","ace/token_iterator","ace/lib/lang"],function(e,t,n){"use strict";var r=e("../../lib/oop"),i=e("../behaviour").Behaviour,s=e("../../token_iterator").TokenIterator,o=e("../../lib/lang"),u=["text","paren.rparen","punctuation.operator"],a=["text","paren.rparen","punctuation.operator","comment"],f,l={},c=function(e){var t=-1;e.multiSelect&&(t=e.selection.index,l.rangeCount!=e.multiSelect.rangeCount&&(l={rangeCount:e.multiSelect.rangeCount}));if(l[t])return f=l[t];f=l[t]={autoInsertedBrackets:0,autoInsertedRow:-1,autoInsertedLineEnd:"",maybeInsertedBrackets:0,maybeInsertedRow:-1,maybeInsertedLineStart:"",maybeInsertedLineEnd:""}},h=function(e,t,n,r){var i=e.end.row-e.start.row;return{text:n+t+r,selection:[0,e.start.column+1,i,e.end.column+(i?0:1)]}},p=function(){this.add("braces","insertion",function(e,t,n,r,i){var s=n.getCursorPosition(),u=r.doc.getLine(s.row);if(i=="{"){c(n);var a=n.getSelectionRange(),l=r.doc.getTextRange(a);if(l!==""&&l!=="{"&&n.getWrapBehavioursEnabled())return h(a,l,"{","}");if(p.isSaneInsertion(n,r))return/[\]\}\)]/.test(u[s.column])||n.inMultiSelectMode?(p.recordAutoInsert(n,r,"}"),{text:"{}",selection:[1,1]}):(p.recordMaybeInsert(n,r,"{"),{text:"{",selection:[1,1]})}else if(i=="}"){c(n);var d=u.substring(s.column,s.column+1);if(d=="}"){var v=r.$findOpeningBracket("}",{column:s.column+1,row:s.row});if(v!==null&&p.isAutoInsertedClosing(s,u,i))return p.popAutoInsertedClosing(),{text:"",selection:[1,1]}}}else{if(i=="\n"||i=="\r\n"){c(n);var m="";p.isMaybeInsertedClosing(s,u)&&(m=o.stringRepeat("}",f.maybeInsertedBrackets),p.clearMaybeInsertedClosing());var d=u.substring(s.column,s.column+1);if(d==="}"){var g=r.findMatchingBracket({row:s.row,column:s.column+1},"}");if(!g)return null;var y=this.$getIndent(r.getLine(g.row))}else{if(!m){p.clearMaybeInsertedClosing();return}var y=this.$getIndent(u)}var b=y+r.getTabString();return{text:"\n"+b+"\n"+y+m,selection:[1,b.length,1,b.length]}}p.clearMaybeInsertedClosing()}}),this.add("braces","deletion",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&s=="{"){c(n);var o=r.doc.getLine(i.start.row),u=o.substring(i.end.column,i.end.column+1);if(u=="}")return i.end.column++,i;f.maybeInsertedBrackets--}}),this.add("parens","insertion",function(e,t,n,r,i){if(i=="("){c(n);var s=n.getSelectionRange(),o=r.doc.getTextRange(s);if(o!==""&&n.getWrapBehavioursEnabled())return h(s,o,"(",")");if(p.isSaneInsertion(n,r))return p.recordAutoInsert(n,r,")"),{text:"()",selection:[1,1]}}else if(i==")"){c(n);var u=n.getCursorPosition(),a=r.doc.getLine(u.row),f=a.substring(u.column,u.column+1);if(f==")"){var l=r.$findOpeningBracket(")",{column:u.column+1,row:u.row});if(l!==null&&p.isAutoInsertedClosing(u,a,i))return p.popAutoInsertedClosing(),{text:"",selection:[1,1]}}}}),this.add("parens","deletion",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&s=="("){c(n);var o=r.doc.getLine(i.start.row),u=o.substring(i.start.column+1,i.start.column+2);if(u==")")return i.end.column++,i}}),this.add("brackets","insertion",function(e,t,n,r,i){if(i=="["){c(n);var s=n.getSelectionRange(),o=r.doc.getTextRange(s);if(o!==""&&n.getWrapBehavioursEnabled())return h(s,o,"[","]");if(p.isSaneInsertion(n,r))return p.recordAutoInsert(n,r,"]"),{text:"[]",selection:[1,1]}}else if(i=="]"){c(n);var u=n.getCursorPosition(),a=r.doc.getLine(u.row),f=a.substring(u.column,u.column+1);if(f=="]"){var l=r.$findOpeningBracket("]",{column:u.column+1,row:u.row});if(l!==null&&p.isAutoInsertedClosing(u,a,i))return p.popAutoInsertedClosing(),{text:"",selection:[1,1]}}}}),this.add("brackets","deletion",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&s=="["){c(n);var o=r.doc.getLine(i.start.row),u=o.substring(i.start.column+1,i.start.column+2);if(u=="]")return i.end.column++,i}}),this.add("string_dquotes","insertion",function(e,t,n,r,i){if(i=='"'||i=="'"){c(n);var s=i,o=n.getSelectionRange(),u=r.doc.getTextRange(o);if(u!==""&&u!=="'"&&u!='"'&&n.getWrapBehavioursEnabled())return h(o,u,s,s);if(!u){var a=n.getCursorPosition(),f=r.doc.getLine(a.row),l=f.substring(a.column-1,a.column),p=f.substring(a.column,a.column+1),d=r.getTokenAt(a.row,a.column),v=r.getTokenAt(a.row,a.column+1);if(l=="\\"&&d&&/escape/.test(d.type))return null;var m=d&&/string|escape/.test(d.type),g=!v||/string|escape/.test(v.type),y;if(p==s)y=m!==g;else{if(m&&!g)return null;if(m&&g)return null;var b=r.$mode.tokenRe;b.lastIndex=0;var w=b.test(l);b.lastIndex=0;var E=b.test(l);if(w||E)return null;if(p&&!/[\s;,.})\]\\]/.test(p))return null;y=!0}return{text:y?s+s:"",selection:[1,1]}}}}),this.add("string_dquotes","deletion",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&(s=='"'||s=="'")){c(n);var o=r.doc.getLine(i.start.row),u=o.substring(i.start.column+1,i.start.column+2);if(u==s)return i.end.column++,i}})};p.isSaneInsertion=function(e,t){var n=e.getCursorPosition(),r=new s(t,n.row,n.column);if(!this.$matchTokenType(r.getCurrentToken()||"text",u)){var i=new s(t,n.row,n.column+1);if(!this.$matchTokenType(i.getCurrentToken()||"text",u))return!1}return r.stepForward(),r.getCurrentTokenRow()!==n.row||this.$matchTokenType(r.getCurrentToken()||"text",a)},p.$matchTokenType=function(e,t){return t.indexOf(e.type||e)>-1},p.recordAutoInsert=function(e,t,n){var r=e.getCursorPosition(),i=t.doc.getLine(r.row);this.isAutoInsertedClosing(r,i,f.autoInsertedLineEnd[0])||(f.autoInsertedBrackets=0),f.autoInsertedRow=r.row,f.autoInsertedLineEnd=n+i.substr(r.column),f.autoInsertedBrackets++},p.recordMaybeInsert=function(e,t,n){var r=e.getCursorPosition(),i=t.doc.getLine(r.row);this.isMaybeInsertedClosing(r,i)||(f.maybeInsertedBrackets=0),f.maybeInsertedRow=r.row,f.maybeInsertedLineStart=i.substr(0,r.column)+n,f.maybeInsertedLineEnd=i.substr(r.column),f.maybeInsertedBrackets++},p.isAutoInsertedClosing=function(e,t,n){return f.autoInsertedBrackets>0&&e.row===f.autoInsertedRow&&n===f.autoInsertedLineEnd[0]&&t.substr(e.column)===f.autoInsertedLineEnd},p.isMaybeInsertedClosing=function(e,t){return f.maybeInsertedBrackets>0&&e.row===f.maybeInsertedRow&&t.substr(e.column)===f.maybeInsertedLineEnd&&t.substr(0,e.column)==f.maybeInsertedLineStart},p.popAutoInsertedClosing=function(){f.autoInsertedLineEnd=f.autoInsertedLineEnd.substr(1),f.autoInsertedBrackets--},p.clearMaybeInsertedClosing=function(){f&&(f.maybeInsertedBrackets=0,f.maybeInsertedRow=-1)},r.inherits(p,i),t.CstyleBehaviour=p}),ace.define("ace/mode/folding/cstyle",["require","exports","module","ace/lib/oop","ace/range","ace/mode/folding/fold_mode"],function(e,t,n){"use strict";var r=e("../../lib/oop"),i=e("../../range").Range,s=e("./fold_mode").FoldMode,o=t.FoldMode=function(e){e&&(this.foldingStartMarker=new RegExp(this.foldingStartMarker.source.replace(/\|[^|]*?$/,"|"+e.start)),this.foldingStopMarker=new RegExp(this.foldingStopMarker.source.replace(/\|[^|]*?$/,"|"+e.end)))};r.inherits(o,s),function(){this.foldingStartMarker=/(\{|\[)[^\}\]]*$|^\s*(\/\*)/,this.foldingStopMarker=/^[^\[\{]*(\}|\])|^[\s\*]*(\*\/)/,this.singleLineBlockCommentRe=/^\s*(\/\*).*\*\/\s*$/,this.tripleStarBlockCommentRe=/^\s*(\/\*\*\*).*\*\/\s*$/,this.startRegionRe=/^\s*(\/\*|\/\/)#?region\b/,this._getFoldWidgetBase=this.getFoldWidget,this.getFoldWidget=function(e,t,n){var r=e.getLine(n);if(this.singleLineBlockCommentRe.test(r)&&!this.startRegionRe.test(r)&&!this.tripleStarBlockCommentRe.test(r))return"";var i=this._getFoldWidgetBase(e,t,n);return!i&&this.startRegionRe.test(r)?"start":i},this.getFoldWidgetRange=function(e,t,n,r){var i=e.getLine(n);if(this.startRegionRe.test(i))return this.getCommentRegionBlock(e,i,n);var s=i.match(this.foldingStartMarker);if(s){var o=s.index;if(s[1])return this.openingBracketBlock(e,s[1],n,o);var u=e.getCommentFoldRange(n,o+s[0].length,1);return u&&!u.isMultiLine()&&(r?u=this.getSectionRange(e,n):t!="all"&&(u=null)),u}if(t==="markbegin")return;var s=i.match(this.foldingStopMarker);if(s){var o=s.index+s[0].length;return s[1]?this.closingBracketBlock(e,s[1],n,o):e.getCommentFoldRange(n,o,-1)}},this.getSectionRange=function(e,t){var n=e.getLine(t),r=n.search(/\S/),s=t,o=n.length;t+=1;var u=t,a=e.getLength();while(++tf)break;var l=this.getFoldWidgetRange(e,"all",t);if(l){if(l.start.row<=s)break;if(l.isMultiLine())t=l.end.row;else if(r==f)break}u=t}return new i(s,o,u,e.getLine(u).length)},this.getCommentRegionBlock=function(e,t,n){var r=t.search(/\s*$/),s=e.getLength(),o=n,u=/^\s*(?:\/\*|\/\/|--)#?(end)?region\b/,a=1;while(++no)return new i(o,r,l,t.length)}}.call(o.prototype)}),ace.define("ace/mode/javascript",["require","exports","module","ace/lib/oop","ace/mode/text","ace/mode/javascript_highlight_rules","ace/mode/matching_brace_outdent","ace/range","ace/worker/worker_client","ace/mode/behaviour/cstyle","ace/mode/folding/cstyle"],function(e,t,n){"use strict";var r=e("../lib/oop"),i=e("./text").Mode,s=e("./javascript_highlight_rules").JavaScriptHighlightRules,o=e("./matching_brace_outdent").MatchingBraceOutdent,u=e("../range").Range,a=e("../worker/worker_client").WorkerClient,f=e("./behaviour/cstyle").CstyleBehaviour,l=e("./folding/cstyle").FoldMode,c=function(){this.HighlightRules=s,this.$outdent=new o,this.$behaviour=new f,this.foldingRules=new l};r.inherits(c,i),function(){this.lineCommentStart="//",this.blockComment={start:"/*",end:"*/"},this.getNextLineIndent=function(e,t,n){var r=this.$getIndent(t),i=this.getTokenizer().getLineTokens(t,e),s=i.tokens,o=i.state;if(s.length&&s[s.length-1].type=="comment")return r;if(e=="start"||e=="no_regex"){var u=t.match(/^.*(?:\bcase\b.*\:|[\{\(\[])\s*$/);u&&(r+=n)}else if(e=="doc-start"){if(o=="start"||o=="no_regex")return"";var u=t.match(/^\s*(\/?)\*/);u&&(u[1]&&(r+=" "),r+="* ")}return r},this.checkOutdent=function(e,t,n){return this.$outdent.checkOutdent(t,n)},this.autoOutdent=function(e,t,n){this.$outdent.autoOutdent(t,n)},this.createWorker=function(e){var t=new a(["ace"],"ace/mode/javascript_worker","JavaScriptWorker");return t.attachToDocument(e.getDocument()),t.on("annotate",function(t){e.setAnnotations(t.data)}),t.on("terminate",function(){e.clearAnnotations()}),t},this.$id="ace/mode/javascript"}.call(c.prototype),t.Mode=c}) \ No newline at end of file diff --git a/public/ace/mode-json.js b/public/ace/mode-json.js new file mode 100644 index 00000000..f6226b80 --- /dev/null +++ b/public/ace/mode-json.js @@ -0,0 +1 @@ +ace.define("ace/mode/json_highlight_rules",["require","exports","module","ace/lib/oop","ace/mode/text_highlight_rules"],function(e,t,n){"use strict";var r=e("../lib/oop"),i=e("./text_highlight_rules").TextHighlightRules,s=function(){this.$rules={start:[{token:"variable",regex:'["](?:(?:\\\\.)|(?:[^"\\\\]))*?["]\\s*(?=:)'},{token:"string",regex:'"',next:"string"},{token:"constant.numeric",regex:"0[xX][0-9a-fA-F]+\\b"},{token:"constant.numeric",regex:"[+-]?\\d+(?:(?:\\.\\d*)?(?:[eE][+-]?\\d+)?)?\\b"},{token:"constant.language.boolean",regex:"(?:true|false)\\b"},{token:"invalid.illegal",regex:"['](?:(?:\\\\.)|(?:[^'\\\\]))*?[']"},{token:"invalid.illegal",regex:"\\/\\/.*$"},{token:"paren.lparen",regex:"[[({]"},{token:"paren.rparen",regex:"[\\])}]"},{token:"text",regex:"\\s+"}],string:[{token:"constant.language.escape",regex:/\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|["\\\/bfnrt])/},{token:"string",regex:'[^"\\\\]+'},{token:"string",regex:'"',next:"start"},{token:"string",regex:"",next:"start"}]}};r.inherits(s,i),t.JsonHighlightRules=s}),ace.define("ace/mode/matching_brace_outdent",["require","exports","module","ace/range"],function(e,t,n){"use strict";var r=e("../range").Range,i=function(){};(function(){this.checkOutdent=function(e,t){return/^\s+$/.test(e)?/^\s*\}/.test(t):!1},this.autoOutdent=function(e,t){var n=e.getLine(t),i=n.match(/^(\s*\})/);if(!i)return 0;var s=i[1].length,o=e.findMatchingBracket({row:t,column:s});if(!o||o.row==t)return 0;var u=this.$getIndent(e.getLine(o.row));e.replace(new r(t,0,t,s-1),u)},this.$getIndent=function(e){return e.match(/^\s*/)[0]}}).call(i.prototype),t.MatchingBraceOutdent=i}),ace.define("ace/mode/behaviour/cstyle",["require","exports","module","ace/lib/oop","ace/mode/behaviour","ace/token_iterator","ace/lib/lang"],function(e,t,n){"use strict";var r=e("../../lib/oop"),i=e("../behaviour").Behaviour,s=e("../../token_iterator").TokenIterator,o=e("../../lib/lang"),u=["text","paren.rparen","punctuation.operator"],a=["text","paren.rparen","punctuation.operator","comment"],f,l={},c=function(e){var t=-1;e.multiSelect&&(t=e.selection.index,l.rangeCount!=e.multiSelect.rangeCount&&(l={rangeCount:e.multiSelect.rangeCount}));if(l[t])return f=l[t];f=l[t]={autoInsertedBrackets:0,autoInsertedRow:-1,autoInsertedLineEnd:"",maybeInsertedBrackets:0,maybeInsertedRow:-1,maybeInsertedLineStart:"",maybeInsertedLineEnd:""}},h=function(e,t,n,r){var i=e.end.row-e.start.row;return{text:n+t+r,selection:[0,e.start.column+1,i,e.end.column+(i?0:1)]}},p=function(){this.add("braces","insertion",function(e,t,n,r,i){var s=n.getCursorPosition(),u=r.doc.getLine(s.row);if(i=="{"){c(n);var a=n.getSelectionRange(),l=r.doc.getTextRange(a);if(l!==""&&l!=="{"&&n.getWrapBehavioursEnabled())return h(a,l,"{","}");if(p.isSaneInsertion(n,r))return/[\]\}\)]/.test(u[s.column])||n.inMultiSelectMode?(p.recordAutoInsert(n,r,"}"),{text:"{}",selection:[1,1]}):(p.recordMaybeInsert(n,r,"{"),{text:"{",selection:[1,1]})}else if(i=="}"){c(n);var d=u.substring(s.column,s.column+1);if(d=="}"){var v=r.$findOpeningBracket("}",{column:s.column+1,row:s.row});if(v!==null&&p.isAutoInsertedClosing(s,u,i))return p.popAutoInsertedClosing(),{text:"",selection:[1,1]}}}else{if(i=="\n"||i=="\r\n"){c(n);var m="";p.isMaybeInsertedClosing(s,u)&&(m=o.stringRepeat("}",f.maybeInsertedBrackets),p.clearMaybeInsertedClosing());var d=u.substring(s.column,s.column+1);if(d==="}"){var g=r.findMatchingBracket({row:s.row,column:s.column+1},"}");if(!g)return null;var y=this.$getIndent(r.getLine(g.row))}else{if(!m){p.clearMaybeInsertedClosing();return}var y=this.$getIndent(u)}var b=y+r.getTabString();return{text:"\n"+b+"\n"+y+m,selection:[1,b.length,1,b.length]}}p.clearMaybeInsertedClosing()}}),this.add("braces","deletion",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&s=="{"){c(n);var o=r.doc.getLine(i.start.row),u=o.substring(i.end.column,i.end.column+1);if(u=="}")return i.end.column++,i;f.maybeInsertedBrackets--}}),this.add("parens","insertion",function(e,t,n,r,i){if(i=="("){c(n);var s=n.getSelectionRange(),o=r.doc.getTextRange(s);if(o!==""&&n.getWrapBehavioursEnabled())return h(s,o,"(",")");if(p.isSaneInsertion(n,r))return p.recordAutoInsert(n,r,")"),{text:"()",selection:[1,1]}}else if(i==")"){c(n);var u=n.getCursorPosition(),a=r.doc.getLine(u.row),f=a.substring(u.column,u.column+1);if(f==")"){var l=r.$findOpeningBracket(")",{column:u.column+1,row:u.row});if(l!==null&&p.isAutoInsertedClosing(u,a,i))return p.popAutoInsertedClosing(),{text:"",selection:[1,1]}}}}),this.add("parens","deletion",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&s=="("){c(n);var o=r.doc.getLine(i.start.row),u=o.substring(i.start.column+1,i.start.column+2);if(u==")")return i.end.column++,i}}),this.add("brackets","insertion",function(e,t,n,r,i){if(i=="["){c(n);var s=n.getSelectionRange(),o=r.doc.getTextRange(s);if(o!==""&&n.getWrapBehavioursEnabled())return h(s,o,"[","]");if(p.isSaneInsertion(n,r))return p.recordAutoInsert(n,r,"]"),{text:"[]",selection:[1,1]}}else if(i=="]"){c(n);var u=n.getCursorPosition(),a=r.doc.getLine(u.row),f=a.substring(u.column,u.column+1);if(f=="]"){var l=r.$findOpeningBracket("]",{column:u.column+1,row:u.row});if(l!==null&&p.isAutoInsertedClosing(u,a,i))return p.popAutoInsertedClosing(),{text:"",selection:[1,1]}}}}),this.add("brackets","deletion",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&s=="["){c(n);var o=r.doc.getLine(i.start.row),u=o.substring(i.start.column+1,i.start.column+2);if(u=="]")return i.end.column++,i}}),this.add("string_dquotes","insertion",function(e,t,n,r,i){if(i=='"'||i=="'"){c(n);var s=i,o=n.getSelectionRange(),u=r.doc.getTextRange(o);if(u!==""&&u!=="'"&&u!='"'&&n.getWrapBehavioursEnabled())return h(o,u,s,s);if(!u){var a=n.getCursorPosition(),f=r.doc.getLine(a.row),l=f.substring(a.column-1,a.column),p=f.substring(a.column,a.column+1),d=r.getTokenAt(a.row,a.column),v=r.getTokenAt(a.row,a.column+1);if(l=="\\"&&d&&/escape/.test(d.type))return null;var m=d&&/string|escape/.test(d.type),g=!v||/string|escape/.test(v.type),y;if(p==s)y=m!==g;else{if(m&&!g)return null;if(m&&g)return null;var b=r.$mode.tokenRe;b.lastIndex=0;var w=b.test(l);b.lastIndex=0;var E=b.test(l);if(w||E)return null;if(p&&!/[\s;,.})\]\\]/.test(p))return null;y=!0}return{text:y?s+s:"",selection:[1,1]}}}}),this.add("string_dquotes","deletion",function(e,t,n,r,i){var s=r.doc.getTextRange(i);if(!i.isMultiLine()&&(s=='"'||s=="'")){c(n);var o=r.doc.getLine(i.start.row),u=o.substring(i.start.column+1,i.start.column+2);if(u==s)return i.end.column++,i}})};p.isSaneInsertion=function(e,t){var n=e.getCursorPosition(),r=new s(t,n.row,n.column);if(!this.$matchTokenType(r.getCurrentToken()||"text",u)){var i=new s(t,n.row,n.column+1);if(!this.$matchTokenType(i.getCurrentToken()||"text",u))return!1}return r.stepForward(),r.getCurrentTokenRow()!==n.row||this.$matchTokenType(r.getCurrentToken()||"text",a)},p.$matchTokenType=function(e,t){return t.indexOf(e.type||e)>-1},p.recordAutoInsert=function(e,t,n){var r=e.getCursorPosition(),i=t.doc.getLine(r.row);this.isAutoInsertedClosing(r,i,f.autoInsertedLineEnd[0])||(f.autoInsertedBrackets=0),f.autoInsertedRow=r.row,f.autoInsertedLineEnd=n+i.substr(r.column),f.autoInsertedBrackets++},p.recordMaybeInsert=function(e,t,n){var r=e.getCursorPosition(),i=t.doc.getLine(r.row);this.isMaybeInsertedClosing(r,i)||(f.maybeInsertedBrackets=0),f.maybeInsertedRow=r.row,f.maybeInsertedLineStart=i.substr(0,r.column)+n,f.maybeInsertedLineEnd=i.substr(r.column),f.maybeInsertedBrackets++},p.isAutoInsertedClosing=function(e,t,n){return f.autoInsertedBrackets>0&&e.row===f.autoInsertedRow&&n===f.autoInsertedLineEnd[0]&&t.substr(e.column)===f.autoInsertedLineEnd},p.isMaybeInsertedClosing=function(e,t){return f.maybeInsertedBrackets>0&&e.row===f.maybeInsertedRow&&t.substr(e.column)===f.maybeInsertedLineEnd&&t.substr(0,e.column)==f.maybeInsertedLineStart},p.popAutoInsertedClosing=function(){f.autoInsertedLineEnd=f.autoInsertedLineEnd.substr(1),f.autoInsertedBrackets--},p.clearMaybeInsertedClosing=function(){f&&(f.maybeInsertedBrackets=0,f.maybeInsertedRow=-1)},r.inherits(p,i),t.CstyleBehaviour=p}),ace.define("ace/mode/folding/cstyle",["require","exports","module","ace/lib/oop","ace/range","ace/mode/folding/fold_mode"],function(e,t,n){"use strict";var r=e("../../lib/oop"),i=e("../../range").Range,s=e("./fold_mode").FoldMode,o=t.FoldMode=function(e){e&&(this.foldingStartMarker=new RegExp(this.foldingStartMarker.source.replace(/\|[^|]*?$/,"|"+e.start)),this.foldingStopMarker=new RegExp(this.foldingStopMarker.source.replace(/\|[^|]*?$/,"|"+e.end)))};r.inherits(o,s),function(){this.foldingStartMarker=/(\{|\[)[^\}\]]*$|^\s*(\/\*)/,this.foldingStopMarker=/^[^\[\{]*(\}|\])|^[\s\*]*(\*\/)/,this.singleLineBlockCommentRe=/^\s*(\/\*).*\*\/\s*$/,this.tripleStarBlockCommentRe=/^\s*(\/\*\*\*).*\*\/\s*$/,this.startRegionRe=/^\s*(\/\*|\/\/)#?region\b/,this._getFoldWidgetBase=this.getFoldWidget,this.getFoldWidget=function(e,t,n){var r=e.getLine(n);if(this.singleLineBlockCommentRe.test(r)&&!this.startRegionRe.test(r)&&!this.tripleStarBlockCommentRe.test(r))return"";var i=this._getFoldWidgetBase(e,t,n);return!i&&this.startRegionRe.test(r)?"start":i},this.getFoldWidgetRange=function(e,t,n,r){var i=e.getLine(n);if(this.startRegionRe.test(i))return this.getCommentRegionBlock(e,i,n);var s=i.match(this.foldingStartMarker);if(s){var o=s.index;if(s[1])return this.openingBracketBlock(e,s[1],n,o);var u=e.getCommentFoldRange(n,o+s[0].length,1);return u&&!u.isMultiLine()&&(r?u=this.getSectionRange(e,n):t!="all"&&(u=null)),u}if(t==="markbegin")return;var s=i.match(this.foldingStopMarker);if(s){var o=s.index+s[0].length;return s[1]?this.closingBracketBlock(e,s[1],n,o):e.getCommentFoldRange(n,o,-1)}},this.getSectionRange=function(e,t){var n=e.getLine(t),r=n.search(/\S/),s=t,o=n.length;t+=1;var u=t,a=e.getLength();while(++tf)break;var l=this.getFoldWidgetRange(e,"all",t);if(l){if(l.start.row<=s)break;if(l.isMultiLine())t=l.end.row;else if(r==f)break}u=t}return new i(s,o,u,e.getLine(u).length)},this.getCommentRegionBlock=function(e,t,n){var r=t.search(/\s*$/),s=e.getLength(),o=n,u=/^\s*(?:\/\*|\/\/|--)#?(end)?region\b/,a=1;while(++no)return new i(o,r,l,t.length)}}.call(o.prototype)}),ace.define("ace/mode/json",["require","exports","module","ace/lib/oop","ace/mode/text","ace/mode/json_highlight_rules","ace/mode/matching_brace_outdent","ace/mode/behaviour/cstyle","ace/mode/folding/cstyle","ace/worker/worker_client"],function(e,t,n){"use strict";var r=e("../lib/oop"),i=e("./text").Mode,s=e("./json_highlight_rules").JsonHighlightRules,o=e("./matching_brace_outdent").MatchingBraceOutdent,u=e("./behaviour/cstyle").CstyleBehaviour,a=e("./folding/cstyle").FoldMode,f=e("../worker/worker_client").WorkerClient,l=function(){this.HighlightRules=s,this.$outdent=new o,this.$behaviour=new u,this.foldingRules=new a};r.inherits(l,i),function(){this.getNextLineIndent=function(e,t,n){var r=this.$getIndent(t);if(e=="start"){var i=t.match(/^.*[\{\(\[]\s*$/);i&&(r+=n)}return r},this.checkOutdent=function(e,t,n){return this.$outdent.checkOutdent(t,n)},this.autoOutdent=function(e,t,n){this.$outdent.autoOutdent(t,n)},this.createWorker=function(e){var t=new f(["ace"],"ace/mode/json_worker","JsonWorker");return t.attachToDocument(e.getDocument()),t.on("annotate",function(t){e.setAnnotations(t.data)}),t.on("terminate",function(){e.clearAnnotations()}),t},this.$id="ace/mode/json"}.call(l.prototype),t.Mode=l}) \ No newline at end of file diff --git a/public/ace/worker-javascript.js b/public/ace/worker-javascript.js new file mode 100644 index 00000000..bca38f05 --- /dev/null +++ b/public/ace/worker-javascript.js @@ -0,0 +1 @@ +"no use strict";(function(e){function t(e,t){var n=e,r="";while(n){var i=t[n];if(typeof i=="string")return i+r;if(i)return i.location.replace(/\/*$/,"/")+(r||i.main||i.name);if(i===!1)return"";var s=n.lastIndexOf("/");if(s===-1)break;r=n.substr(s)+r,n=n.slice(0,s)}return e}if(typeof e.window!="undefined"&&e.document)return;if(e.require&&e.define)return;e.console||(e.console=function(){var e=Array.prototype.slice.call(arguments,0);postMessage({type:"log",data:e})},e.console.error=e.console.warn=e.console.log=e.console.trace=e.console),e.window=e,e.ace=e,e.onerror=function(e,t,n,r,i){postMessage({type:"error",data:{message:e,data:i.data,file:t,line:n,col:r,stack:i.stack}})},e.normalizeModule=function(t,n){if(n.indexOf("!")!==-1){var r=n.split("!");return e.normalizeModule(t,r[0])+"!"+e.normalizeModule(t,r[1])}if(n.charAt(0)=="."){var i=t.split("/").slice(0,-1).join("/");n=(i?i+"/":"")+n;while(n.indexOf(".")!==-1&&s!=n){var s=n;n=n.replace(/^\.\//,"").replace(/\/\.\//,"/").replace(/[^\/]+\/\.\.\//,"")}}return n},e.require=function(r,i){i||(i=r,r=null);if(!i.charAt)throw new Error("worker.js require() accepts only (parentId, id) as arguments");i=e.normalizeModule(r,i);var s=e.require.modules[i];if(s)return s.initialized||(s.initialized=!0,s.exports=s.factory().exports),s.exports;if(!e.require.tlns)return console.log("unable to load "+i);var o=t(i,e.require.tlns);return o.slice(-3)!=".js"&&(o+=".js"),e.require.id=i,e.require.modules[i]={},importScripts(o),e.require(r,i)},e.require.modules={},e.require.tlns={},e.define=function(t,n,r){arguments.length==2?(r=n,typeof t!="string"&&(n=t,t=e.require.id)):arguments.length==1&&(r=t,n=[],t=e.require.id);if(typeof r!="function"){e.require.modules[t]={exports:r,initialized:!0};return}n.length||(n=["require","exports","module"]);var i=function(n){return e.require(t,n)};e.require.modules[t]={exports:{},factory:function(){var e=this,t=r.apply(this,n.map(function(t){switch(t){case"require":return i;case"exports":return e.exports;case"module":return e;default:return i(t)}}));return t&&(e.exports=t),e}}},e.define.amd={},require.tlns={},e.initBaseUrls=function(t){for(var n in t)require.tlns[n]=t[n]},e.initSender=function(){var n=e.require("ace/lib/event_emitter").EventEmitter,r=e.require("ace/lib/oop"),i=function(){};return function(){r.implement(this,n),this.callback=function(e,t){postMessage({type:"call",id:t,data:e})},this.emit=function(e,t){postMessage({type:"event",name:e,data:t})}}.call(i.prototype),new i};var n=e.main=null,r=e.sender=null;e.onmessage=function(t){var i=t.data;if(i.event&&r)r._signal(i.event,i.data);else if(i.command)if(n[i.command])n[i.command].apply(n,i.args);else{if(!e[i.command])throw new Error("Unknown command:"+i.command);e[i.command].apply(e,i.args)}else if(i.init){e.initBaseUrls(i.tlns),require("ace/lib/es5-shim"),r=e.sender=e.initSender();var s=require(i.module)[i.classname];n=e.main=new s(r)}}})(this),ace.define("ace/lib/oop",["require","exports","module"],function(e,t,n){"use strict";t.inherits=function(e,t){e.super_=t,e.prototype=Object.create(t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}})},t.mixin=function(e,t){for(var n in t)e[n]=t[n];return e},t.implement=function(e,n){t.mixin(e,n)}}),ace.define("ace/range",["require","exports","module"],function(e,t,n){"use strict";var r=function(e,t){return e.row-t.row||e.column-t.column},i=function(e,t,n,r){this.start={row:e,column:t},this.end={row:n,column:r}};(function(){this.isEqual=function(e){return this.start.row===e.start.row&&this.end.row===e.end.row&&this.start.column===e.start.column&&this.end.column===e.end.column},this.toString=function(){return"Range: ["+this.start.row+"/"+this.start.column+"] -> ["+this.end.row+"/"+this.end.column+"]"},this.contains=function(e,t){return this.compare(e,t)==0},this.compareRange=function(e){var t,n=e.end,r=e.start;return t=this.compare(n.row,n.column),t==1?(t=this.compare(r.row,r.column),t==1?2:t==0?1:0):t==-1?-2:(t=this.compare(r.row,r.column),t==-1?-1:t==1?42:0)},this.comparePoint=function(e){return this.compare(e.row,e.column)},this.containsRange=function(e){return this.comparePoint(e.start)==0&&this.comparePoint(e.end)==0},this.intersects=function(e){var t=this.compareRange(e);return t==-1||t==0||t==1},this.isEnd=function(e,t){return this.end.row==e&&this.end.column==t},this.isStart=function(e,t){return this.start.row==e&&this.start.column==t},this.setStart=function(e,t){typeof e=="object"?(this.start.column=e.column,this.start.row=e.row):(this.start.row=e,this.start.column=t)},this.setEnd=function(e,t){typeof e=="object"?(this.end.column=e.column,this.end.row=e.row):(this.end.row=e,this.end.column=t)},this.inside=function(e,t){return this.compare(e,t)==0?this.isEnd(e,t)||this.isStart(e,t)?!1:!0:!1},this.insideStart=function(e,t){return this.compare(e,t)==0?this.isEnd(e,t)?!1:!0:!1},this.insideEnd=function(e,t){return this.compare(e,t)==0?this.isStart(e,t)?!1:!0:!1},this.compare=function(e,t){return!this.isMultiLine()&&e===this.start.row?tthis.end.column?1:0:ethis.end.row?1:this.start.row===e?t>=this.start.column?0:-1:this.end.row===e?t<=this.end.column?0:1:0},this.compareStart=function(e,t){return this.start.row==e&&this.start.column==t?-1:this.compare(e,t)},this.compareEnd=function(e,t){return this.end.row==e&&this.end.column==t?1:this.compare(e,t)},this.compareInside=function(e,t){return this.end.row==e&&this.end.column==t?1:this.start.row==e&&this.start.column==t?-1:this.compare(e,t)},this.clipRows=function(e,t){if(this.end.row>t)var n={row:t+1,column:0};else if(this.end.rowt)var r={row:t+1,column:0};else if(this.start.row=0&&t.row=0&&t.column<=e[t.row].length}function s(e,t){t.action!="insert"&&t.action!="remove"&&r(t,"delta.action must be 'insert' or 'remove'"),t.lines instanceof Array||r(t,"delta.lines must be an Array"),(!t.start||!t.end)&&r(t,"delta.start/end must be an present");var n=t.start;i(e,t.start)||r(t,"delta.start must be contained in document");var s=t.end;t.action=="remove"&&!i(e,s)&&r(t,"delta.end must contained in document for 'remove' actions");var o=s.row-n.row,u=s.column-(o==0?n.column:0);(o!=t.lines.length-1||t.lines[o].length!=u)&&r(t,"delta.range must match delta lines")}t.applyDelta=function(e,t,n){var r=t.start.row,i=t.start.column,s=e[r]||"";switch(t.action){case"insert":var o=t.lines;if(o.length===1)e[r]=s.substring(0,i)+t.lines[0]+s.substring(i);else{var u=[r,1].concat(t.lines);e.splice.apply(e,u),e[r]=s.substring(0,i)+e[r],e[r+t.lines.length-1]+=s.substring(i)}break;case"remove":var a=t.end.column,f=t.end.row;r===f?e[r]=s.substring(0,i)+s.substring(a):e.splice(r,f-r+1,s.substring(0,i)+e[f].substring(a))}}}),ace.define("ace/lib/event_emitter",["require","exports","module"],function(e,t,n){"use strict";var r={},i=function(){this.propagationStopped=!0},s=function(){this.defaultPrevented=!0};r._emit=r._dispatchEvent=function(e,t){this._eventRegistry||(this._eventRegistry={}),this._defaultHandlers||(this._defaultHandlers={});var n=this._eventRegistry[e]||[],r=this._defaultHandlers[e];if(!n.length&&!r)return;if(typeof t!="object"||!t)t={};t.type||(t.type=e),t.stopPropagation||(t.stopPropagation=i),t.preventDefault||(t.preventDefault=s),n=n.slice();for(var o=0;othis.row)return;var n=t(e,{row:this.row,column:this.column},this.$insertRight);this.setPosition(n.row,n.column,!0)},this.setPosition=function(e,t,n){var r;n?r={row:e,column:t}:r=this.$clipPositionToDocument(e,t);if(this.row==r.row&&this.column==r.column)return;var i={row:this.row,column:this.column};this.row=r.row,this.column=r.column,this._signal("change",{old:i,value:r})},this.detach=function(){this.document.removeEventListener("change",this.$onChange)},this.attach=function(e){this.document=e||this.document,this.document.on("change",this.$onChange)},this.$clipPositionToDocument=function(e,t){var n={};return e>=this.document.getLength()?(n.row=Math.max(0,this.document.getLength()-1),n.column=this.document.getLine(n.row).length):e<0?(n.row=0,n.column=0):(n.row=e,n.column=Math.min(this.document.getLine(n.row).length,Math.max(0,t))),t<0&&(n.column=0),n}}).call(s.prototype)}),ace.define("ace/document",["require","exports","module","ace/lib/oop","ace/apply_delta","ace/lib/event_emitter","ace/range","ace/anchor"],function(e,t,n){"use strict";var r=e("./lib/oop"),i=e("./apply_delta").applyDelta,s=e("./lib/event_emitter").EventEmitter,o=e("./range").Range,u=e("./anchor").Anchor,a=function(e){this.$lines=[""],e.length===0?this.$lines=[""]:Array.isArray(e)?this.insertMergedLines({row:0,column:0},e):this.insert({row:0,column:0},e)};(function(){r.implement(this,s),this.setValue=function(e){var t=this.getLength()-1;this.remove(new o(0,0,t,this.getLine(t).length)),this.insert({row:0,column:0},e)},this.getValue=function(){return this.getAllLines().join(this.getNewLineCharacter())},this.createAnchor=function(e,t){return new u(this,e,t)},"aaa".split(/a/).length===0?this.$split=function(e){return e.replace(/\r\n|\r/g,"\n").split("\n")}:this.$split=function(e){return e.split(/\r\n|\r|\n/)},this.$detectNewLine=function(e){var t=e.match(/^.*?(\r\n|\r|\n)/m);this.$autoNewLine=t?t[1]:"\n",this._signal("changeNewLineMode")},this.getNewLineCharacter=function(){switch(this.$newLineMode){case"windows":return"\r\n";case"unix":return"\n";default:return this.$autoNewLine||"\n"}},this.$autoNewLine="",this.$newLineMode="auto",this.setNewLineMode=function(e){if(this.$newLineMode===e)return;this.$newLineMode=e,this._signal("changeNewLineMode")},this.getNewLineMode=function(){return this.$newLineMode},this.isNewLine=function(e){return e=="\r\n"||e=="\r"||e=="\n"},this.getLine=function(e){return this.$lines[e]||""},this.getLines=function(e,t){return this.$lines.slice(e,t+1)},this.getAllLines=function(){return this.getLines(0,this.getLength())},this.getLength=function(){return this.$lines.length},this.getTextRange=function(e){return this.getLinesForRange(e).join(this.getNewLineCharacter())},this.getLinesForRange=function(e){var t;if(e.start.row===e.end.row)t=[this.getLine(e.start.row).substring(e.start.column,e.end.column)];else{t=this.getLines(e.start.row,e.end.row),t[0]=(t[0]||"").substring(e.start.column);var n=t.length-1;e.end.row-e.start.row==n&&(t[n]=t[n].substring(0,e.end.column))}return t},this.insertLines=function(e,t){return console.warn("Use of document.insertLines is deprecated. Use the insertFullLines method instead."),this.insertFullLines(e,t)},this.removeLines=function(e,t){return console.warn("Use of document.removeLines is deprecated. Use the removeFullLines method instead."),this.removeFullLines(e,t)},this.insertNewLine=function(e){return console.warn("Use of document.insertNewLine is deprecated. Use insertMergedLines(position, ['', '']) instead."),this.insertMergedLines(e,["",""])},this.insert=function(e,t){return this.getLength()<=1&&this.$detectNewLine(t),this.insertMergedLines(e,this.$split(t))},this.insertInLine=function(e,t){var n=this.clippedPos(e.row,e.column),r=this.pos(e.row,e.column+t.length);return this.applyDelta({start:n,end:r,action:"insert",lines:[t]},!0),this.clonePos(r)},this.clippedPos=function(e,t){var n=this.getLength();e===undefined?e=n:e<0?e=0:e>=n&&(e=n-1,t=undefined);var r=this.getLine(e);return t==undefined&&(t=r.length),t=Math.min(Math.max(t,0),r.length),{row:e,column:t}},this.clonePos=function(e){return{row:e.row,column:e.column}},this.pos=function(e,t){return{row:e,column:t}},this.$clipPosition=function(e){var t=this.getLength();return e.row>=t?(e.row=Math.max(0,t-1),e.column=this.getLine(t-1).length):(e.row=Math.max(0,e.row),e.column=Math.min(Math.max(e.column,0),this.getLine(e.row).length)),e},this.insertFullLines=function(e,t){e=Math.min(Math.max(e,0),this.getLength());var n=0;e0,r=t=0&&this.applyDelta({start:this.pos(e,this.getLine(e).length),end:this.pos(e+1,0),action:"remove",lines:["",""]})},this.replace=function(e,t){e instanceof o||(e=o.fromPoints(e.start,e.end));if(t.length===0&&e.isEmpty())return e.start;if(t==this.getTextRange(e))return e.end;this.remove(e);var n;return t?n=this.insert(e.start,t):n=e.start,n},this.applyDeltas=function(e){for(var t=0;t=0;t--)this.revertDelta(e[t])},this.applyDelta=function(e,t){var n=e.action=="insert";if(n?e.lines.length<=1&&!e.lines[0]:!o.comparePoints(e.start,e.end))return;n&&e.lines.length>2e4&&this.$splitAndapplyLargeDelta(e,2e4),i(this.$lines,e,t),this._signal("change",e)},this.$splitAndapplyLargeDelta=function(e,t){var n=e.lines,r=n.length,i=e.start.row,s=e.start.column,o=0,u=0;do{o=u,u+=t-1;var a=n.slice(o,u);if(u>r){e.lines=a,e.start.row=i+o,e.start.column=s;break}a.push(""),this.applyDelta({start:this.pos(i+o,s),end:this.pos(i+u,s=0),action:e.action,lines:a},!0)}while(!0)},this.revertDelta=function(e){this.applyDelta({start:this.clonePos(e.start),end:this.clonePos(e.end),action:e.action=="insert"?"remove":"insert",lines:e.lines.slice()})},this.indexToPosition=function(e,t){var n=this.$lines||this.getAllLines(),r=this.getNewLineCharacter().length;for(var i=t||0,s=n.length;i0){t&1&&(n+=e);if(t>>=1)e+=e}return n};var r=/^\s\s*/,i=/\s\s*$/;t.stringTrimLeft=function(e){return e.replace(r,"")},t.stringTrimRight=function(e){return e.replace(i,"")},t.copyObject=function(e){var t={};for(var n in e)t[n]=e[n];return t},t.copyArray=function(e){var t=[];for(var n=0,r=e.length;n0&&this._events[e].length>n&&(this._events[e].warned=!0,console.error("(node) warning: possible EventEmitter memory leak detected. %d listeners added. Use emitter.setMaxListeners() to increase limit.",this._events[e].length),typeof console.trace=="function"&&console.trace())}return this},r.prototype.on=r.prototype.addListener,r.prototype.once=function(e,t){function r(){this.removeListener(e,r),n||(n=!0,t.apply(this,arguments))}if(!i(t))throw TypeError("listener must be a function");var n=!1;return r.listener=t,this.on(e,r),this},r.prototype.removeListener=function(e,t){var n,r,s,u;if(!i(t))throw TypeError("listener must be a function");if(!this._events||!this._events[e])return this;n=this._events[e],s=n.length,r=-1;if(n===t||i(n.listener)&&n.listener===t)delete this._events[e],this._events.removeListener&&this.emit("removeListener",e,t);else if(o(n)){for(u=s;u-->0;)if(n[u]===t||n[u].listener&&n[u].listener===t){r=u;break}if(r<0)return this;n.length===1?(n.length=0,delete this._events[e]):n.splice(r,1),this._events.removeListener&&this.emit("removeListener",e,t)}return this},r.prototype.removeAllListeners=function(e){var t,n;if(!this._events)return this;if(!this._events.removeListener)return arguments.length===0?this._events={}:this._events[e]&&delete this._events[e],this;if(arguments.length===0){for(t in this._events){if(t==="removeListener")continue;this.removeAllListeners(t)}return this.removeAllListeners("removeListener"),this._events={},this}n=this._events[e];if(i(n))this.removeListener(e,n);else while(n.length)this.removeListener(e,n[n.length-1]);return delete this._events[e],this},r.prototype.listeners=function(e){var t;return!this._events||!this._events[e]?t=[]:i(this._events[e])?t=[this._events[e]]:t=this._events[e].slice(),t},r.listenerCount=function(e,t){var n;return!e._events||!e._events[t]?n=0:i(e._events[t])?n=1:n=e._events[t].length,n}},{}],"/node_modules/jshint/data/ascii-identifier-data.js":[function(e,t,n){var r=[];for(var i=0;i<128;i++)r[i]=i===36||i>=65&&i<=90||i===95||i>=97&&i<=122;var s=[];for(var i=0;i<128;i++)s[i]=r[i]||i>=48&&i<=57;t.exports={asciiIdentifierStartTable:r,asciiIdentifierPartTable:s}},{}],"/node_modules/jshint/lodash.js":[function(e,t,n){(function(e){(function(){function $(e,t,n){var r=e.length,i=n?r:-1;while(n?i--:++ir&&(r=i)}return r}function Dt(e,t){var n=-1,r=e.length;while(++ns?0:s+t),n=n===r||n>s?s:+n||0,n<0&&(n+=s),s=t>n?0:n-t>>>0,t>>>=0;var o=Array(s);while(++i>>1,o=e[s];(n?o<=t:o2&&n[i-2],o=i>2&&n[2],u=i>1&&n[i-1];typeof s=="function"?(s=on(s,u,5),i-=2):(s=typeof u=="function"?u:null,i-=s?1:0),o&&Tn(n[0],n[1],o)&&(s=i<3?null:s,i=1);while(++rf))return!1;while(c&&++a-1&&e%1==0&&e-1&&e%1==0&&e<=Nt}function kn(e){return e===e&&(e===0?1/e>0:!Jn(e))}function Ln(e){var t,n=Ct.support;if(!Y(e)||rt.call(e)!=d||!nt.call(e,"constructor")&&(t=e.constructor,typeof t=="function"&&!(t instanceof t)))return!1;var i;return Ut(e,function(e,t){i=t}),i===r||nt.call(e,i)}function An(e){var t=ir(e),n=t.length,r=n&&e.length,i=Ct.support,s=r&&Cn(r)&&(Xn(e)||i.nonEnumArgs&&Wn(e)),o=-1,u=[];while(++o>>0,r=Array(n);while(++t-1:gn(e,t,n)>-1):!1}function qn(e,t,n){var r=Xn(e)?Ot:qt;return t=mn(t,n,3),r(e,function(e,n,r){return!t(e,n,r)})}function Rn(e,t,n){var i=Xn(e)?Dt:tn;n&&Tn(e,t,n)&&(t=null);if(typeof t!="function"||n!==r)t=mn(t,n,3);return i(e,t)}function Un(e,t){if(typeof e!="function")throw new TypeError(s);return t=yt(t===r?e.length-1:+t||0,0),function(){var n=arguments,r=-1,i=yt(n.length-t,0),s=Array(i);while(++r0;while(++r>>1,Tt=dt?dt.BYTES_PER_ELEMENT:0,Nt=Math.pow(2,53)-1,kt=Ct.support={};(function(e){var t=function(){this.x=e},n={0:e,length:e},r=[];t.prototype={valueOf:e,y:e};for(var i in new t)r.push(i);kt.funcDecomp=/\bthis\b/.test(function(){return this}),kt.funcNames=typeof Function.name=="string";try{kt.nonEnumArgs=!ht.call(arguments,1)}catch(s){kt.nonEnumArgs=!0}})(1,0);var Ht=vt||function(e,t){return t==null?e:Bt(t,bn(t),Bt(t,rr(t),e))},It=fn(zt),Rt=ln();ot||(un=!st||!pt?fr(null):function(e){var t=e.byteLength,n=dt?ut(t/Tt):0,r=n*Tt,i=new st(t);if(n){var s=new dt(i,0,n);s.set(new dt(e,0,n))}return t!=r&&(s=new pt(i,r),s.set(new pt(e,r))),i});var yn=Yt("length"),bn=at?function(e){return at(On(e))}:fr([]),_n=cn(!0),jn=Un(Bn),Fn=hn(At,It),Xn=mt||function(e){return Y(e)&&Cn(e.length)&&rt.call(e)==u},$n=K(/x/)||pt&&!K(pt)?function(e){return rt.call(e)==c}:K,Gn=ft?function(e){if(!e||rt.call(e)!=d)return!1;var t=e.valueOf,n=Kn(t)&&(n=ft(t))&&ft(n);return n?e==n||ft(e)==n:Ln(e)}:Ln,tr=an(function(e,t,n){return n?Pt(e,t,n):Ht(e,t)}),rr=gt?function(e){if(e)var t=e.constructor,n=e.length;return typeof t=="function"&&t.prototype===e||typeof e!="function"&&Cn(n)?An(e):Jn(e)?gt(e):[]}:An,sr=an(Qt);Ct.assign=tr,Ct.callback=ar,Ct.constant=fr,Ct.forEach=Fn,Ct.keys=rr,Ct.keysIn=ir,Ct.merge=sr,Ct.property=cr,Ct.reject=qn,Ct.restParam=Un,Ct.slice=Hn,Ct.toPlainObject=er,Ct.unzip=Bn,Ct.values=or,Ct.zip=jn,Ct.each=Fn,Ct.extend=tr,Ct.iteratee=ar,Ct.clone=zn,Ct.escapeRegExp=ur,Ct.findLastIndex=_n,Ct.has=nr,Ct.identity=lr,Ct.includes=In,Ct.indexOf=Dn,Ct.isArguments=Wn,Ct.isArray=Xn,Ct.isEmpty=Vn,Ct.isFunction=$n,Ct.isNative=Kn,Ct.isNumber=Qn,Ct.isObject=Jn,Ct.isPlainObject=Gn,Ct.isString=Yn,Ct.isTypedArray=Zn,Ct.last=Pn,Ct.some=Rn,Ct.any=Rn,Ct.contains=In,Ct.include=In,Ct.VERSION=i,q&&R?X?(R.exports=Ct)._=Ct:q._=Ct:V._=Ct}).call(this)}).call(this,typeof global!="undefined"?global:typeof self!="undefined"?self:typeof window!="undefined"?window:{})},{}],"/node_modules/jshint/src/jshint.js":[function(e,t,n){var r=e("../lodash"),i=e("events"),s=e("./vars.js"),o=e("./messages.js"),u=e("./lex.js").Lexer,a=e("./reg.js"),f=e("./state.js").state,l=e("./style.js"),c=e("./options.js"),h=e("./scope-manager.js"),p=function(){"use strict";function k(e,t){return e=e.trim(),/^[+-]W\d{3}$/g.test(e)?!0:c.validNames.indexOf(e)===-1&&t.type!=="jslint"&&!r.has(c.removed,e)?(q("E001",t,e),!1):!0}function L(e){return Object.prototype.toString.call(e)==="[object String]"}function A(e,t){return e?!e.identifier||e.value!==t?!1:!0:!1}function O(e){if(!e.reserved)return!1;var t=e.meta;if(t&&t.isFutureReservedWord&&f.inES5()){if(!t.es5)return!1;if(t.strictOnly&&!f.option.strict&&!f.isStrict())return!1;if(e.isProperty)return!1}return!0}function M(e,t){return e.replace(/\{([^{}]*)\}/g,function(e,n){var r=t[n];return typeof r=="string"||typeof r=="number"?r:e})}function D(e,t){Object.keys(t).forEach(function(n){if(r.has(p.blacklist,n))return;e[n]=t[n]})}function P(){if(f.option.enforceall){for(var e in c.bool.enforcing)f.option[e]===undefined&&!c.noenforceall[e]&&(f.option[e]=!0);for(var t in c.bool.relaxing)f.option[t]===undefined&&(f.option[t]=!1)}}function H(){P(),!f.option.esversion&&!f.option.moz&&(f.option.es3?f.option.esversion=3:f.option.esnext?f.option.esversion=6:f.option.esversion=5),f.inES5()&&D(S,s.ecmaIdentifiers[5]),f.inES6()&&D(S,s.ecmaIdentifiers[6]),f.option.module&&(f.option.strict===!0&&(f.option.strict="global"),f.inES6()||F("W134",f.tokens.next,"module",6)),f.option.couch&&D(S,s.couch),f.option.qunit&&D(S,s.qunit),f.option.rhino&&D(S,s.rhino),f.option.shelljs&&(D(S,s.shelljs),D(S,s.node)),f.option.typed&&D(S,s.typed),f.option.phantom&&(D(S,s.phantom),f.option.strict===!0&&(f.option.strict="global")),f.option.prototypejs&&D(S,s.prototypejs),f.option.node&&(D(S,s.node),D(S,s.typed),f.option.strict===!0&&(f.option.strict="global")),f.option.devel&&D(S,s.devel),f.option.dojo&&D(S,s.dojo),f.option.browser&&(D(S,s.browser),D(S,s.typed)),f.option.browserify&&(D(S,s.browser),D(S,s.typed),D(S,s.browserify),f.option.strict===!0&&(f.option.strict="global")),f.option.nonstandard&&D(S,s.nonstandard),f.option.jasmine&&D(S,s.jasmine),f.option.jquery&&D(S,s.jquery),f.option.mootools&&D(S,s.mootools),f.option.worker&&D(S,s.worker),f.option.wsh&&D(S,s.wsh),f.option.globalstrict&&f.option.strict!==!1&&(f.option.strict="global"),f.option.yui&&D(S,s.yui),f.option.mocha&&D(S,s.mocha)}function B(e,t,n){var r=Math.floor(t/f.lines.length*100),i=o.errors[e].desc;throw{name:"JSHintError",line:t,character:n,message:i+" ("+r+"% scanned).",raw:i,code:e}}function j(){var e=f.ignoredLines;if(r.isEmpty(e))return;p.errors=r.reject(p.errors,function(t){return e[t.line]})}function F(e,t,n,r,i,s){var u,a,l,c;if(/^W\d{3}$/.test(e)){if(f.ignored[e])return;c=o.warnings[e]}else/E\d{3}/.test(e)?c=o.errors[e]:/I\d{3}/.test(e)&&(c=o.info[e]);return t=t||f.tokens.next||{},t.id==="(end)"&&(t=f.tokens.curr),a=t.line||0,u=t.from||0,l={id:"(error)",raw:c.desc,code:c.code,evidence:f.lines[a-1]||"",line:a,character:u,scope:p.scope,a:n,b:r,c:i,d:s},l.reason=M(c.desc,l),p.errors.push(l),j(),p.errors.length>=f.option.maxerr&&B("E043",a,u),l}function I(e,t,n,r,i,s,o){return F(e,{line:t,from:n},r,i,s,o)}function q(e,t,n,r,i,s){F(e,t,n,r,i,s)}function R(e,t,n,r,i,s,o){return q(e,{line:t,from:n},r,i,s,o)}function U(e,t){var n;return n={id:"(internal)",elem:e,value:t},p.internals.push(n),n}function z(){var e=f.tokens.next,t=e.body.match(/(-\s+)?[^\s,:]+(?:\s*:\s*(-\s+)?[^\s,]+)?/g)||[],i={};if(e.type==="globals"){t.forEach(function(n,r){n=n.split(":");var s=(n[0]||"").trim(),o=(n[1]||"").trim();if(s==="-"||!s.length){if(r>0&&r===t.length-1)return;q("E002",e);return}s.charAt(0)==="-"?(s=s.slice(1),o=!1,p.blacklist[s]=s,delete S[s]):i[s]=o==="true"}),D(S,i);for(var s in i)r.has(i,s)&&(n[s]=e)}e.type==="exported"&&t.forEach(function(n,r){if(!n.length){if(r>0&&r===t.length-1)return;q("E002",e);return}f.funct["(scope)"].addExported(n)}),e.type==="members"&&(E=E||{},t.forEach(function(e){var t=e.charAt(0),n=e.charAt(e.length-1);t===n&&(t==='"'||t==="'")&&(e=e.substr(1,e.length-2).replace('\\"','"')),E[e]=!1}));var o=["maxstatements","maxparams","maxdepth","maxcomplexity","maxerr","maxlen","indent"];if(e.type==="jshint"||e.type==="jslint")t.forEach(function(t){t=t.split(":");var n=(t[0]||"").trim(),i=(t[1]||"").trim();if(!k(n,e))return;if(o.indexOf(n)>=0){if(i!=="false"){i=+i;if(typeof i!="number"||!isFinite(i)||i<=0||Math.floor(i)!==i){q("E032",e,t[1].trim());return}f.option[n]=i}else f.option[n]=n==="indent"?4:!1;return}if(n==="validthis"){if(f.funct["(global)"])return void q("E009");if(i!=="true"&&i!=="false")return void q("E002",e);f.option.validthis=i==="true";return}if(n==="quotmark"){switch(i){case"true":case"false":f.option.quotmark=i==="true";break;case"double":case"single":f.option.quotmark=i;break;default:q("E002",e)}return}if(n==="shadow"){switch(i){case"true":f.option.shadow=!0;break;case"outer":f.option.shadow="outer";break;case"false":case"inner":f.option.shadow="inner";break;default:q("E002",e)}return}if(n==="unused"){switch(i){case"true":f.option.unused=!0;break;case"false":f.option.unused=!1;break;case"vars":case"strict":f.option.unused=i;break;default:q("E002",e)}return}if(n==="latedef"){switch(i){case"true":f.option.latedef=!0;break;case"false":f.option.latedef=!1;break;case"nofunc":f.option.latedef="nofunc";break;default:q("E002",e)}return}if(n==="ignore"){switch(i){case"line":f.ignoredLines[e.line]=!0,j();break;default:q("E002",e)}return}if(n==="strict"){switch(i){case"true":f.option.strict=!0;break;case"false":f.option.strict=!1;break;case"func":case"global":case"implied":f.option.strict=i;break;default:q("E002",e)}return}n==="module"&&(zt(f.funct)||q("E055",f.tokens.next,"module"));var s={es3:3,es5:5,esnext:6};if(r.has(s,n)){switch(i){case"true":f.option.moz=!1,f.option.esversion=s[n];break;case"false":f.option.moz||(f.option.esversion=5);break;default:q("E002",e)}return}if(n==="esversion"){switch(i){case"5":f.inES5(!0)&&F("I003");case"3":case"6":f.option.moz=!1,f.option.esversion=+i;break;case"2015":f.option.moz=!1,f.option.esversion=6;break;default:q("E002",e)}zt(f.funct)||q("E055",f.tokens.next,"esversion");return}var u=/^([+-])(W\d{3})$/g.exec(n);if(u){f.ignored[u[2]]=u[1]==="-";return}var a;if(i==="true"||i==="false"){e.type==="jslint"?(a=c.renamed[n]||n,f.option[a]=i==="true",c.inverted[a]!==undefined&&(f.option[a]=!f.option[a])):f.option[n]=i==="true",n==="newcap"&&(f.option["(explicitNewcap)"]=!0);return}q("E002",e)}),H()}function W(e){var t=e||0,n=y.length,r;if(t="a"&&t<="z"||t>="A"&&t<="Z")e.identifier=e.reserved=!0;return e}function ut(e,t){var n=nt(e,150);return ot(n),n.nud=typeof t=="function"?t:function(){this.arity="unary",this.right=Q(150);if(this.id==="++"||this.id==="--")f.option.plusplus?F("W016",this,this.id):this.right&&(!this.right.identifier||O(this.right))&&this.right.id!=="."&&this.right.id!=="["&&F("W017",this),this.right&&this.right.isMetaProperty?q("E031",this):this.right&&this.right.identifier&&f.funct["(scope)"].block.modify(this.right.value,this);return this},n}function at(e,t){var n=rt(e);return n.type=e,n.nud=t,n}function ft(e,t){var n=at(e,t);return n.identifier=!0,n.reserved=!0,n}function lt(e,t){var n=at(e,t&&t.nud||function(){return this});return t=t||{},t.isFutureReservedWord=!0,n.value=e,n.identifier=!0,n.reserved=!0,n.meta=t,n}function ct(e,t){return ft(e,function(){return typeof t=="function"&&t(this),this})}function ht(e,t,n,r){var i=nt(e,n);return ot(i),i.infix=!0,i.led=function(i){return r||Y(f.tokens.prev,f.tokens.curr),(e==="in"||e==="instanceof")&&i.id==="!"&&F("W018",i,"!"),typeof t=="function"?t(i,this):(this.left=i,this.right=Q(n),this)},i}function pt(e){var t=nt(e,42);return t.led=function(e){return Y(f.tokens.prev,f.tokens.curr),this.left=e,this.right=Xt({type:"arrow",loneArg:e}),this},t}function dt(e,t){var n=nt(e,100);return n.led=function(e){Y(f.tokens.prev,f.tokens.curr),this.left=e;var n=this.right=Q(100);return A(e,"NaN")||A(n,"NaN")?F("W019",this):t&&t.apply(this,[e,n]),(!e||!n)&&B("E041",f.tokens.curr.line),e.id==="!"&&F("W018",e,"!"),n.id==="!"&&F("W018",n,"!"),this},n}function vt(e){return e&&(e.type==="(number)"&&+e.value===0||e.type==="(string)"&&e.value===""||e.type==="null"&&!f.option.eqnull||e.type==="true"||e.type==="false"||e.type==="undefined")}function gt(e,t,n){var i;return n.option.notypeof?!1:!e||!t?!1:(i=n.inES6()?mt.es6:mt.es3,t.type==="(identifier)"&&t.value==="typeof"&&e.type==="(string)"?!r.contains(i,e.value):!1)}function yt(e,t){var n=!1;return e.type==="this"&&t.funct["(context)"]===null?n=!0:e.type==="(identifier)"&&(t.option.node&&e.value==="global"?n=!0:t.option.browser&&(e.value==="window"||e.value==="document")&&(n=!0)),n}function bt(e){function n(e){if(typeof e!="object")return;return e.right==="prototype"?e:n(e.left)}function r(e){while(!e.identifier&&typeof e.left=="object")e=e.left;if(e.identifier&&t.indexOf(e.value)>=0)return e.value}var t=["Array","ArrayBuffer","Boolean","Collator","DataView","Date","DateTimeFormat","Error","EvalError","Float32Array","Float64Array","Function","Infinity","Intl","Int16Array","Int32Array","Int8Array","Iterator","Number","NumberFormat","Object","RangeError","ReferenceError","RegExp","StopIteration","String","SyntaxError","TypeError","Uint16Array","Uint32Array","Uint8Array","Uint8ClampedArray","URIError"],i=n(e);if(i)return r(i)}function wt(e,t,n){var r=n&&n.allowDestructuring;t=t||e;if(f.option.freeze){var i=bt(e);i&&F("W121",e,i)}return e.identifier&&!e.isMetaProperty&&f.funct["(scope)"].block.reassign(e.value,e),e.id==="."?((!e.left||e.left.value==="arguments"&&!f.isStrict())&&F("E031",t),f.nameStack.set(f.tokens.prev),!0):e.id==="{"||e.id==="["?(r&&f.tokens.curr.left.destructAssign?f.tokens.curr.left.destructAssign.forEach(function(e){e.id&&f.funct["(scope)"].block.modify(e.id,e.token)}):e.id==="{"||!e.left?F("E031",t):e.left.value==="arguments"&&!f.isStrict()&&F("E031",t),e.id==="["&&f.nameStack.set(e.right),!0):e.isMetaProperty?(q("E031",t),!0):e.identifier&&!O(e)?(f.funct["(scope)"].labeltype(e.value)==="exception"&&F("W022",e),f.nameStack.set(e),!0):(e===f.syntax["function"]&&F("W023",f.tokens.curr),!1)}function Et(e,t,n){var r=ht(e,typeof t=="function"?t:function(e,t){t.left=e;if(e&&wt(e,t,{allowDestructuring:!0}))return t.right=Q(10),t;q("E031",t)},n);return r.exps=!0,r.assign=!0,r}function St(e,t,n){var r=nt(e,n);return ot(r),r.led=typeof t=="function"?t:function(e){return f.option.bitwise&&F("W016",this,this.id),this.left=e,this.right=Q(n),this},r}function xt(e){return Et(e,function(e,t){f.option.bitwise&&F("W016",t,t.id);if(e&&wt(e,t))return t.right=Q(10),t;q("E031",t)},20)}function Tt(e){var t=nt(e,150);return t.led=function(e){return f.option.plusplus?F("W016",this,this.id):(!e.identifier||O(e))&&e.id!=="."&&e.id!=="["&&F("W017",this),e.isMetaProperty?q("E031",this):e&&e.identifier&&f.funct["(scope)"].block.modify(e.value,e),this.left=e,this},t}function Nt(e,t,n){if(!f.tokens.next.identifier)return;n||V();var r=f.tokens.curr,i=f.tokens.curr.value;return O(r)?t&&f.inES5()?i:e&&i==="undefined"?i:(F("W024",f.tokens.curr,f.tokens.curr.id),i):i}function Ct(e,t){var n=Nt(e,t,!1);if(n)return n;if(f.tokens.next.value==="..."){f.inES6(!0)||F("W119",f.tokens.next,"spread/rest operator","6"),V();if(pn(f.tokens.next,"...")){F("E024",f.tokens.next,"...");while(pn(f.tokens.next,"..."))V()}if(!f.tokens.next.identifier){F("E024",f.tokens.curr,"...");return}return Ct(e,t)}q("E030",f.tokens.next,f.tokens.next.value),f.tokens.next.id!==";"&&V()}function kt(e){var t=0,n;if(f.tokens.next.id!==";"||e.inBracelessBlock)return;for(;;){do n=W(t),t+=1;while(n.id!=="(end)"&&n.id==="(comment)");if(n.reach)return;if(n.id!=="(endline)"){if(n.id==="function"){f.option.latedef===!0&&F("W026",n);break}F("W027",n,n.value,e.value);break}}}function Lt(){if(f.tokens.next.id!==";"){if(f.tokens.next.isUnclosed)return V();var e=G(f.tokens.next)===f.tokens.curr.line&&f.tokens.next.id!=="(end)",t=pn(f.tokens.next,"}");e&&!t?R("E058",f.tokens.curr.line,f.tokens.curr.character):f.option.asi||(t&&!f.option.lastsemic||!e)&&I("W033",f.tokens.curr.line,f.tokens.curr.character)}else V(";")}function At(){var e=g,t,n=f.tokens.next,r=!1;if(n.id===";"){V(";");return}var i=O(n);i&&n.meta&&n.meta.isFutureReservedWord&&W().id===":"&&(F("W024",n,n.id),i=!1),n.identifier&&!i&&W().id===":"&&(V(),V(":"),r=!0,f.funct["(scope)"].stack(),f.funct["(scope)"].block.addBreakLabel(n.value,{token:f.tokens.curr}),!f.tokens.next.labelled&&f.tokens.next.value!=="{"&&F("W028",f.tokens.next,n.value,f.tokens.next.value),f.tokens.next.label=n.value,n=f.tokens.next);if(n.id==="{"){var s=f.funct["(verb)"]==="case"&&f.tokens.curr.value===":";_t(!0,!0,!1,!1,s);return}return t=Q(0,!0),t&&(!t.identifier||t.value!=="function")&&(t.type!=="(punctuator)"||!t.left||!t.left.identifier||t.left.value!=="function")&&!f.isStrict()&&f.option.strict==="global"&&F("E007"),n.block||(!f.option.expr&&(!t||!t.exps)?F("W030",f.tokens.curr):f.option.nonew&&t&&t.left&&t.id==="("&&t.left.id==="new"&&F("W031",n),Lt()),g=e,r&&f.funct["(scope)"].unstack(),t}function Ot(){var e=[],t;while(!f.tokens.next.reach&&f.tokens.next.id!=="(end)")f.tokens.next.id===";"?(t=W(),(!t||t.id!=="("&&t.id!=="[")&&F("W032"),V(";")):e.push(At());return e}function Mt(){var e,t,n;while(f.tokens.next.id==="(string)"){t=W(0);if(t.id==="(endline)"){e=1;do n=W(e++);while(n.id==="(endline)");if(n.id===";")t=n;else{if(n.value==="["||n.value===".")break;(!f.option.asi||n.value==="(")&&F("W033",f.tokens.next)}}else{if(t.id==="."||t.id==="[")break;t.id!==";"&&F("W033",t)}V();var r=f.tokens.curr.value;(f.directive[r]||r==="use strict"&&f.option.strict==="implied")&&F("W034",f.tokens.curr,r),f.directive[r]=!0,t.id===";"&&V(";")}f.isStrict()&&(f.option["(explicitNewcap)"]||(f.option.newcap=!0),f.option.undef=!0)}function _t(e,t,n,i,s){var o,u=m,a=g,l,c,h,p;m=e,c=f.tokens.next;var d=f.funct["(metrics)"];d.nestedBlockDepth+=1,d.verifyMaxNestedBlockDepthPerFunction();if(f.tokens.next.id==="{"){V("{"),f.funct["(scope)"].stack(),h=f.tokens.curr.line;if(f.tokens.next.id!=="}"){g+=f.option.indent;while(!e&&f.tokens.next.from>g)g+=f.option.indent;if(n){l={};for(p in f.directive)r.has(f.directive,p)&&(l[p]=f.directive[p]);Mt(),f.option.strict&&f.funct["(context)"]["(global)"]&&!l["use strict"]&&!f.isStrict()&&F("E007")}o=Ot(),d.statementCount+=o.length,g-=f.option.indent}V("}",c),n&&(f.funct["(scope)"].validateParams(),l&&(f.directive=l)),f.funct["(scope)"].unstack(),g=a}else if(!e)if(n){f.funct["(scope)"].stack(),l={},t&&!i&&!f.inMoz()&&q("W118",f.tokens.curr,"function closure expressions");if(!t)for(p in f.directive)r.has(f.directive,p)&&(l[p]=f.directive[p]);Q(10),f.option.strict&&f.funct["(context)"]["(global)"]&&!l["use strict"]&&!f.isStrict()&&F("E007"),f.funct["(scope)"].unstack()}else q("E021",f.tokens.next,"{",f.tokens.next.value);else f.funct["(noblockscopedvar)"]=f.tokens.next.id!=="for",f.funct["(scope)"].stack(),(!t||f.option.curly)&&F("W116",f.tokens.next,"{",f.tokens.next.value),f.tokens.next.inBracelessBlock=!0,g+=f.option.indent,o=[At()],g-=f.option.indent,f.funct["(scope)"].unstack(),delete f.funct["(noblockscopedvar)"];switch(f.funct["(verb)"]){case"break":case"continue":case"return":case"throw":if(s)break;default:f.funct["(verb)"]=null}return m=u,e&&f.option.noempty&&(!o||o.length===0)&&F("W035",f.tokens.prev),d.nestedBlockDepth-=1,o}function Dt(e){E&&typeof E[e]!="boolean"&&F("W036",f.tokens.curr,e),typeof w[e]=="number"?w[e]+=1:w[e]=1}function Bt(){var e={};e.exps=!0,f.funct["(comparray)"].stack();var t=!1;return f.tokens.next.value!=="for"&&(t=!0,f.inMoz()||F("W116",f.tokens.next,"for",f.tokens.next.value),f.funct["(comparray)"].setState("use"),e.right=Q(10)),V("for"),f.tokens.next.value==="each"&&(V("each"),f.inMoz()||F("W118",f.tokens.curr,"for each")),V("("),f.funct["(comparray)"].setState("define"),e.left=Q(130),r.contains(["in","of"],f.tokens.next.value)?V():q("E045",f.tokens.curr),f.funct["(comparray)"].setState("generate"),Q(10),V(")"),f.tokens.next.value==="if"&&(V("if"),V("("),f.funct["(comparray)"].setState("filter"),e.filter=Q(10),V(")")),t||(f.funct["(comparray)"].setState("use"),e.right=Q(10)),V("]"),f.funct["(comparray)"].unstack(),e}function jt(){return f.funct["(statement)"]&&f.funct["(statement)"].type==="class"||f.funct["(context)"]&&f.funct["(context)"]["(verb)"]==="class"}function Ft(e){return e.identifier||e.id==="(string)"||e.id==="(number)"}function It(e){var t,n=!0;return typeof e=="object"?t=e:(n=e,t=Nt(!1,!0,n)),t?typeof t=="object"&&(t.id==="(string)"||t.id==="(identifier)"?t=t.value:t.id==="(number)"&&(t=t.value.toString())):f.tokens.next.id==="(string)"?(t=f.tokens.next.value,n||V()):f.tokens.next.id==="(number)"&&(t=f.tokens.next.value.toString(),n||V()),t==="hasOwnProperty"&&F("W001"),t}function qt(e){function h(e){f.funct["(scope)"].addParam.apply(f.funct["(scope)"],e)}var t,n=[],i,s=[],o,u=!1,a=!1,l=0,c=e&&e.loneArg;if(c&&c.identifier===!0)return f.funct["(scope)"].addParam(c.value,c),{arity:1,params:[c.value]};t=f.tokens.next,(!e||!e.parsedOpening)&&V("(");if(f.tokens.next.id===")"){V(")");return}for(;;){l++;var p=[];if(r.contains(["{","["],f.tokens.next.id)){s=Gt();for(o in s)o=s[o],o.id&&(n.push(o.id),p.push([o.id,o.token]))}else{pn(f.tokens.next,"...")&&(a=!0),i=Ct(!0);if(i)n.push(i),p.push([i,f.tokens.curr]);else while(!hn(f.tokens.next,[",",")"]))V()}u&&f.tokens.next.id!=="="&&q("W138",f.tokens.current),f.tokens.next.id==="="&&(f.inES6()||F("W119",f.tokens.next,"default parameters","6"),V("="),u=!0,Q(10)),p.forEach(h);if(f.tokens.next.id!==",")return V(")",t),{arity:l,params:n};a&&F("W131",f.tokens.next),tt()}}function Rt(e,t,n){var i={"(name)":e,"(breakage)":0,"(loopage)":0,"(tokens)":{},"(properties)":{},"(catch)":!1,"(global)":!1,"(line)":null,"(character)":null,"(metrics)":null,"(statement)":null,"(context)":null,"(scope)":null,"(comparray)":null,"(generator)":null,"(arrow)":null,"(params)":null};return t&&r.extend(i,{"(line)":t.line,"(character)":t.character,"(metrics)":Vt(t)}),r.extend(i,n),i["(context)"]&&(i["(scope)"]=i["(context)"]["(scope)"],i["(comparray)"]=i["(context)"]["(comparray)"]),i}function Ut(e){return"(scope)"in e}function zt(e){return e["(global)"]&&!e["(verb)"]}function Wt(e){function i(){if(f.tokens.curr.template&&f.tokens.curr.tail&&f.tokens.curr.context===t)return!0;var e=f.tokens.next.template&&f.tokens.next.tail&&f.tokens.next.context===t;return e&&V(),e||f.tokens.next.isUnclosed}var t=this.context,n=this.noSubst,r=this.depth;if(!n)while(!i())!f.tokens.next.template||f.tokens.next.depth>r?Q(0):V();return{id:"(template)",type:"(template)",tag:e}}function Xt(e){var t,n,r,i,s,o,u,a,l=f.option,c=f.ignored;e&&(r=e.name,i=e.statement,s=e.classExprBinding,o=e.type==="generator",u=e.type==="arrow",a=e.ignoreLoopFunc),f.option=Object.create(f.option),f.ignored=Object.create(f.ignored),f.funct=Rt(r||f.nameStack.infer(),f.tokens.next,{"(statement)":i,"(context)":f.funct,"(arrow)":u,"(generator)":o}),t=f.funct,n=f.tokens.curr,n.funct=f.funct,v.push(f.funct),f.funct["(scope)"].stack("functionouter");var h=r||s;h&&f.funct["(scope)"].block.add(h,s?"class":"function",f.tokens.curr,!1),f.funct["(scope)"].stack("functionparams");var p=qt(e);return p?(f.funct["(params)"]=p.params,f.funct["(metrics)"].arity=p.arity,f.funct["(metrics)"].verifyMaxParametersPerFunction()):f.funct["(metrics)"].arity=0,u&&(f.inES6(!0)||F("W119",f.tokens.curr,"arrow function syntax (=>)","6"),e.loneArg||V("=>")),_t(!1,!0,!0,u),!f.option.noyield&&o&&f.funct["(generator)"]!=="yielded"&&F("W124",f.tokens.curr),f.funct["(metrics)"].verifyMaxStatementsPerFunction(),f.funct["(metrics)"].verifyMaxComplexityPerFunction(),f.funct["(unusedOption)"]=f.option.unused,f.option=l,f.ignored=c,f.funct["(last)"]=f.tokens.curr.line,f.funct["(lastcharacter)"]=f.tokens.curr.character,f.funct["(scope)"].unstack(),f.funct["(scope)"].unstack(),f.funct=f.funct["(context)"],!a&&!f.option.loopfunc&&f.funct["(loopage)"]&&t["(isCapturing)"]&&F("W083",n),t}function Vt(e){return{statementCount:0,nestedBlockDepth:-1,ComplexityCount:1,arity:0,verifyMaxStatementsPerFunction:function(){f.option.maxstatements&&this.statementCount>f.option.maxstatements&&F("W071",e,this.statementCount)},verifyMaxParametersPerFunction:function(){r.isNumber(f.option.maxparams)&&this.arity>f.option.maxparams&&F("W072",e,this.arity)},verifyMaxNestedBlockDepthPerFunction:function(){f.option.maxdepth&&this.nestedBlockDepth>0&&this.nestedBlockDepth===f.option.maxdepth+1&&F("W073",null,this.nestedBlockDepth)},verifyMaxComplexityPerFunction:function(){var t=f.option.maxcomplexity,n=this.ComplexityCount;t&&n>t&&F("W074",e,n)}}}function $t(){f.funct["(metrics)"].ComplexityCount+=1}function Jt(e){var t,n;e&&(t=e.id,n=e.paren,t===","&&(e=e.exprs[e.exprs.length-1])&&(t=e.id,n=n||e.paren));switch(t){case"=":case"+=":case"-=":case"*=":case"%=":case"&=":case"|=":case"^=":case"/=":!n&&!f.option.boss&&F("W084")}}function Kt(e){if(f.inES5())for(var t in e)e[t]&&e[t].setterToken&&!e[t].getterToken&&F("W078",e[t].setterToken)}function Qt(e,t){if(pn(f.tokens.next,".")){var n=f.tokens.curr.id;V(".");var r=Ct();return f.tokens.curr.isMetaProperty=!0,e!==r?q("E057",f.tokens.prev,n,r):t(),f.tokens.curr}}function Gt(e){var t=e&&e.assignment;return f.inES6()||F("W104",f.tokens.curr,t?"destructuring assignment":"destructuring binding","6"),Yt(e)}function Yt(e){var t,n=[],r=e&&e.openingParsed,i=e&&e.assignment,s=i?{assignment:i}:null,o=r?f.tokens.curr:f.tokens.next,u=function(){var e;if(hn(f.tokens.next,["[","{"])){t=Yt(s);for(var r in t)r=t[r],n.push({id:r.id,token:r.token})}else if(pn(f.tokens.next,","))n.push({id:null,token:f.tokens.curr});else{if(!pn(f.tokens.next,"(")){var o=pn(f.tokens.next,"...");if(i){var a=o?W(0):f.tokens.next;a.identifier||F("E030",a,a.value);var l=Q(155);l&&(wt(l),l.identifier&&(e=l.value))}else e=Ct();return e&&n.push({id:e,token:f.tokens.curr}),o}V("("),u(),V(")")}return!1},a=function(){var e;pn(f.tokens.next,"[")?(V("["),Q(10),V("]"),V(":"),u()):f.tokens.next.id==="(string)"||f.tokens.next.id==="(number)"?(V(),V(":"),u()):(e=Ct(),pn(f.tokens.next,":")?(V(":"),u()):e&&(i&&wt(f.tokens.curr),n.push({id:e,token:f.tokens.curr})))};if(pn(o,"[")){r||V("["),pn(f.tokens.next,"]")&&F("W137",f.tokens.curr);var l=!1;while(!pn(f.tokens.next,"]"))u()&&!l&&pn(f.tokens.next,",")&&(F("W130",f.tokens.next),l=!0),pn(f.tokens.next,"=")&&(pn(f.tokens.prev,"...")?V("]"):V("="),f.tokens.next.id==="undefined"&&F("W080",f.tokens.prev,f.tokens.prev.value),Q(10)),pn(f.tokens.next,"]")||V(",");V("]")}else if(pn(o,"{")){r||V("{"),pn(f.tokens.next,"}")&&F("W137",f.tokens.curr);while(!pn(f.tokens.next,"}")){a(),pn(f.tokens.next,"=")&&(V("="),f.tokens.next.id==="undefined"&&F("W080",f.tokens.prev,f.tokens.prev.value),Q(10));if(!pn(f.tokens.next,"}")){V(",");if(pn(f.tokens.next,"}"))break}}V("}")}return n}function Zt(e,t){var n=t.first;if(!n)return;r.zip(e,Array.isArray(n)?n:[n]).forEach(function(e){var t=e[0],n=e[1];t&&n?t.first=n:t&&t.first&&!n&&F("W080",t.first,t.first.value)})}function en(e,t,n){var i=n&&n.prefix,s=n&&n.inexport,o=e==="let",u=e==="const",a,l,c,h;f.inES6()||F("W104",f.tokens.curr,e,"6"),o&&f.tokens.next.value==="("?(f.inMoz()||F("W118",f.tokens.next,"let block"),V("("),f.funct["(scope)"].stack(),h=!0):f.funct["(noblockscopedvar)"]&&q("E048",f.tokens.curr,u?"Const":"Let"),t.first=[];for(;;){var p=[];r.contains(["{","["],f.tokens.next.value)?(a=Gt(),l=!1):(a=[{id:Ct(),token:f.tokens.curr}],l=!0),!i&&u&&f.tokens.next.id!=="="&&F("E012",f.tokens.curr,f.tokens.curr.value);for(var d in a)a.hasOwnProperty(d)&&(d=a[d],f.funct["(scope)"].block.isGlobal()&&S[d.id]===!1&&F("W079",d.token,d.id),d.id&&!f.funct["(noblockscopedvar)"]&&(f.funct["(scope)"].addlabel(d.id,{type:e,token:d.token}),p.push(d.token),l&&s&&f.funct["(scope)"].setExported(d.token.value,d.token)));f.tokens.next.id==="="&&(V("="),!i&&f.tokens.next.id==="undefined"&&F("W080",f.tokens.prev,f.tokens.prev.value),!i&&W(0).id==="="&&f.tokens.next.identifier&&F("W120",f.tokens.next,f.tokens.next.value),c=Q(i?120:10),l?a[0].first=c:Zt(p,c)),t.first=t.first.concat(p);if(f.tokens.next.id!==",")break;tt()}return h&&(V(")"),_t(!0,!0),t.block=!0,f.funct["(scope)"].unstack()),t}function sn(e){return f.inES6()||F("W104",f.tokens.curr,"class","6"),e?(this.name=Ct(),f.funct["(scope)"].addlabel(this.name,{type:"class",token:f.tokens.curr})):f.tokens.next.identifier&&f.tokens.next.value!=="extends"?(this.name=Ct(),this.namedExpr=!0):this.name=f.nameStack.infer(),on(this),this}function on(e){var t=f.inClassBody;f.tokens.next.value==="extends"&&(V("extends"),e.heritage=Q(10)),f.inClassBody=!0,V("{"),e.body=un(e),V("}"),f.inClassBody=t}function un(e){var t,n,r,i,s=Object.create(null),o=Object.create(null),u;for(var a=0;f.tokens.next.id!=="}";++a){t=f.tokens.next,n=!1,r=!1,i=null;if(t.id===";"){F("W032"),V(";");continue}t.id==="*"&&(r=!0,V("*"),t=f.tokens.next);if(t.id==="[")t=cn(),u=!0;else{if(!Ft(t)){F("W052",f.tokens.next,f.tokens.next.value||f.tokens.next.type),V();continue}V(),u=!1;if(t.identifier&&t.value==="static"){pn(f.tokens.next,"*")&&(r=!0,V("*"));if(Ft(f.tokens.next)||f.tokens.next.id==="[")u=f.tokens.next.id==="[",n=!0,t=f.tokens.next,f.tokens.next.id==="["?t=cn():V()}t.identifier&&(t.value==="get"||t.value==="set")&&(Ft(f.tokens.next)||f.tokens.next.id==="[")&&(u=f.tokens.next.id==="[",i=t,t=f.tokens.next,f.tokens.next.id==="["?t=cn():V())}if(!pn(f.tokens.next,"(")){q("E054",f.tokens.next,f.tokens.next.value);while(f.tokens.next.id!=="}"&&!pn(f.tokens.next,"("))V();f.tokens.next.value!=="("&&Xt({statement:e})}u||(i?ln(i.value,n?o:s,t.value,t,!0,n):(t.value==="constructor"?f.nameStack.set(e):f.nameStack.set(t),fn(n?o:s,t.value,t,!0,n)));if(i&&t.value==="constructor"){var l=i.value==="get"?"class getter method":"class setter method";q("E049",t,l,"constructor")}else t.value==="prototype"&&q("E049",t,"class method","prototype");It(t),Xt({statement:e,type:r?"generator":null,classExprBinding:e.namedExpr?e.name:null})}Kt(s)}function fn(e,t,n,r,i){var s=["key","class method","static class method"];s=s[(r||!1)+(i||!1)],n.identifier&&(t=n.value),e[t]&&t!=="__proto__"?F("W075",f.tokens.next,s,t):e[t]=Object.create(null),e[t].basic=!0,e[t].basictkn=n}function ln(e,t,n,r,i,s){var o=e==="get"?"getterToken":"setterToken",u="";i?(s&&(u+="static "),u+=e+"ter method"):u="key",f.tokens.curr.accessorType=e,f.nameStack.set(r),t[n]?(t[n].basic||t[n][o])&&n!=="__proto__"&&F("W075",f.tokens.next,u,n):t[n]=Object.create(null),t[n][o]=r}function cn(){V("["),f.inES6()||F("W119",f.tokens.curr,"computed property names","6");var e=Q(10);return V("]"),e}function hn(e,t){return e.type==="(punctuator)"?r.contains(t,e.value):!1}function pn(e,t){return e.type==="(punctuator)"&&e.value===t}function dn(){var e=an();e.notJson?(!f.inES6()&&e.isDestAssign&&F("W104",f.tokens.curr,"destructuring assignment","6"),Ot()):(f.option.laxbreak=!0,f.jsonMode=!0,mn())}function mn(){function e(){var e={},t=f.tokens.next;V("{");if(f.tokens.next.id!=="}")for(;;){if(f.tokens.next.id==="(end)")q("E026",f.tokens.next,t.line);else{if(f.tokens.next.id==="}"){F("W094",f.tokens.curr);break}f.tokens.next.id===","?q("E028",f.tokens.next):f.tokens.next.id!=="(string)"&&F("W095",f.tokens.next,f.tokens.next.value)}e[f.tokens.next.value]===!0?F("W075",f.tokens.next,"key",f.tokens.next.value):f.tokens.next.value==="__proto__"&&!f.option.proto||f.tokens.next.value==="__iterator__"&&!f.option.iterator?F("W096",f.tokens.next,f.tokens.next.value):e[f.tokens.next.value]=!0,V(),V(":"),mn();if(f.tokens.next.id!==",")break;V(",")}V("}")}function t(){var e=f.tokens.next;V("[");if(f.tokens.next.id!=="]")for(;;){if(f.tokens.next.id==="(end)")q("E027",f.tokens.next,e.line);else{if(f.tokens.next.id==="]"){F("W094",f.tokens.curr);break}f.tokens.next.id===","&&q("E028",f.tokens.next)}mn();if(f.tokens.next.id!==",")break;V(",")}V("]")}switch(f.tokens.next.id){case"{":e();break;case"[":t();break;case"true":case"false":case"null":case"(number)":case"(string)":V();break;case"-":V("-"),V("(number)");break;default:q("E003",f.tokens.next)}}var e,t={"<":!0,"<=":!0,"==":!0,"===":!0,"!==":!0,"!=":!0,">":!0,">=":!0,"+":!0,"-":!0,"*":!0,"/":!0,"%":!0},n,d=["closure","exception","global","label","outer","unused","var"],v,m,g,y,b,w,E,S,x,T,N=[],C=new i.EventEmitter,mt={};mt.legacy=["xml","unknown"],mt.es3=["undefined","boolean","number","string","function","object"],mt.es3=mt.es3.concat(mt.legacy),mt.es6=mt.es3.concat("symbol"),at("(number)",function(){return this}),at("(string)",function(){return this}),f.syntax["(identifier)"]={type:"(identifier)",lbp:0,identifier:!0,nud:function(){var e=this.value;return f.tokens.next.id==="=>"?this:(f.funct["(comparray)"].check(e)||f.funct["(scope)"].block.use(e,f.tokens.curr),this)},led:function(){q("E033",f.tokens.next,f.tokens.next.value)}};var Pt={lbp:0,identifier:!1,template:!0};f.syntax["(template)"]=r.extend({type:"(template)",nud:Wt,led:Wt,noSubst:!1},Pt),f.syntax["(template middle)"]=r.extend({type:"(template middle)",middle:!0,noSubst:!1},Pt),f.syntax["(template tail)"]=r.extend({type:"(template tail)",tail:!0,noSubst:!1},Pt),f.syntax["(no subst template)"]=r.extend({type:"(template)",nud:Wt,led:Wt,noSubst:!0,tail:!0},Pt),at("(regexp)",function(){return this}),rt("(endline)"),rt("(begin)"),rt("(end)").reach=!0,rt("(error)").reach=!0,rt("}").reach=!0,rt(")"),rt("]"),rt('"').reach=!0,rt("'").reach=!0,rt(";"),rt(":").reach=!0,rt("#"),ft("else"),ft("case").reach=!0,ft("catch"),ft("default").reach=!0,ft("finally"),ct("arguments",function(e){f.isStrict()&&f.funct["(global)"]&&F("E008",e)}),ct("eval"),ct("false"),ct("Infinity"),ct("null"),ct("this",function(e){f.isStrict()&&!jt()&&!f.option.validthis&&(f.funct["(statement)"]&&f.funct["(name)"].charAt(0)>"Z"||f.funct["(global)"])&&F("W040",e)}),ct("true"),ct("undefined"),Et("=","assign",20),Et("+=","assignadd",20),Et("-=","assignsub",20),Et("*=","assignmult",20),Et("/=","assigndiv",20).nud=function(){q("E014")},Et("%=","assignmod",20),xt("&="),xt("|="),xt("^="),xt("<<="),xt(">>="),xt(">>>="),ht(",",function(e,t){var n;t.exprs=[e],f.option.nocomma&&F("W127");if(!tt({peek:!0}))return t;for(;;){if(!(n=Q(10)))break;t.exprs.push(n);if(f.tokens.next.value!==","||!tt())break}return t},10,!0),ht("?",function(e,t){return $t(),t.left=e,t.right=Q(10),V(":"),t["else"]=Q(10),t},30);var Ht=40;ht("||",function(e,t){return $t(),t.left=e,t.right=Q(Ht),t},Ht),ht("&&","and",50),St("|","bitor",70),St("^","bitxor",80),St("&","bitand",90),dt("==",function(e,t){var n=f.option.eqnull&&((e&&e.value)==="null"||(t&&t.value)==="null");switch(!0){case!n&&f.option.eqeqeq:this.from=this.character,F("W116",this,"===","==");break;case vt(e):F("W041",this,"===",e.value);break;case vt(t):F("W041",this,"===",t.value);break;case gt(t,e,f):F("W122",this,t.value);break;case gt(e,t,f):F("W122",this,e.value)}return this}),dt("===",function(e,t){return gt(t,e,f)?F("W122",this,t.value):gt(e,t,f)&&F("W122",this,e.value),this}),dt("!=",function(e,t){var n=f.option.eqnull&&((e&&e.value)==="null"||(t&&t.value)==="null");return!n&&f.option.eqeqeq?(this.from=this.character,F("W116",this,"!==","!=")):vt(e)?F("W041",this,"!==",e.value):vt(t)?F("W041",this,"!==",t.value):gt(t,e,f)?F("W122",this,t.value):gt(e,t,f)&&F("W122",this,e.value),this}),dt("!==",function(e,t){return gt(t,e,f)?F("W122",this,t.value):gt(e,t,f)&&F("W122",this,e.value),this}),dt("<"),dt(">"),dt("<="),dt(">="),St("<<","shiftleft",120),St(">>","shiftright",120),St(">>>","shiftrightunsigned",120),ht("in","in",120),ht("instanceof","instanceof",120),ht("+",function(e,t){var n;return t.left=e,t.right=n=Q(130),e&&n&&e.id==="(string)"&&n.id==="(string)"?(e.value+=n.value,e.character=n.character,!f.option.scripturl&&a.javascriptURL.test(e.value)&&F("W050",e),e):t},130),ut("+","num"),ut("+++",function(){return F("W007"),this.arity="unary",this.right=Q(150),this}),ht("+++",function(e){return F("W007"),this.left=e,this.right=Q(130),this},130),ht("-","sub",130),ut("-","neg"),ut("---",function(){return F("W006"),this.arity="unary",this.right=Q(150),this}),ht("---",function(e){return F("W006"),this.left=e,this.right=Q(130),this},130),ht("*","mult",140),ht("/","div",140),ht("%","mod",140),Tt("++"),ut("++","preinc"),f.syntax["++"].exps=!0,Tt("--"),ut("--","predec"),f.syntax["--"].exps=!0,ut("delete",function(){var e=Q(10);return e?(e.id!=="."&&e.id!=="["&&F("W051"),this.first=e,e.identifier&&!f.isStrict()&&(e.forgiveUndef=!0),this):this}).exps=!0,ut("~",function(){return f.option.bitwise&&F("W016",this,"~"),this.arity="unary",this.right=Q(150),this}),ut("...",function(){return f.inES6(!0)||F("W119",this,"spread/rest operator","6"),!f.tokens.next.identifier&&f.tokens.next.type!=="(string)"&&!hn(f.tokens.next,["[","("])&&q("E030",f.tokens.next,f.tokens.next.value),Q(150),this}),ut("!",function(){return this.arity="unary",this.right=Q(150),this.right||B("E041",this.line||0),t[this.right.id]===!0&&F("W018",this,"!"),this}),ut("typeof",function(){var e=Q(150);return this.first=this.right=e,e||B("E041",this.line||0,this.character||0),e.identifier&&(e.forgiveUndef=!0),this}),ut("new",function(){var e=Qt("target",function(){f.inES6(!0)||F("W119",f.tokens.prev,"new.target","6");var e,t=f.funct;while(t){e=!t["(global)"];if(!t["(arrow)"])break;t=t["(context)"]}e||F("W136",f.tokens.prev,"new.target")});if(e)return e;var t=Q(155),n;if(t&&t.id!=="function")if(t.identifier){t["new"]=!0;switch(t.value){case"Number":case"String":case"Boolean":case"Math":case"JSON":F("W053",f.tokens.prev,t.value);break;case"Symbol":f.inES6()&&F("W053",f.tokens.prev,t.value);break;case"Function":f.option.evil||F("W054");break;case"Date":case"RegExp":case"this":break;default:t.id!=="function"&&(n=t.value.substr(0,1),f.option.newcap&&(n<"A"||n>"Z")&&!f.funct["(scope)"].isPredefined(t.value)&&F("W055",f.tokens.curr))}}else t.id!=="."&&t.id!=="["&&t.id!=="("&&F("W056",f.tokens.curr);else f.option.supernew||F("W057",this);return f.tokens.next.id!=="("&&!f.option.supernew&&F("W058",f.tokens.curr,f.tokens.curr.value),this.first=this.right=t,this}),f.syntax["new"].exps=!0,ut("void").exps=!0,ht(".",function(e,t){var n=Ct(!1,!0);return typeof n=="string"&&Dt(n),t.left=e,t.right=n,n&&n==="hasOwnProperty"&&f.tokens.next.value==="="&&F("W001"),!e||e.value!=="arguments"||n!=="callee"&&n!=="caller"?!f.option.evil&&e&&e.value==="document"&&(n==="write"||n==="writeln")&&F("W060",e):f.option.noarg?F("W059",e,n):f.isStrict()&&q("E008"),!f.option.evil&&(n==="eval"||n==="execScript")&&yt(e,f)&&F("W061"),t},160,!0),ht("(",function(e,t){f.option.immed&&e&&!e.immed&&e.id==="function"&&F("W062");var n=0,r=[];e&&e.type==="(identifier)"&&e.value.match(/^[A-Z]([A-Z0-9_$]*[a-z][A-Za-z0-9_$]*)?$/)&&"Array Number String Boolean Date Object Error Symbol".indexOf(e.value)===-1&&(e.value==="Math"?F("W063",e):f.option.newcap&&F("W064",e));if(f.tokens.next.id!==")")for(;;){r[r.length]=Q(10),n+=1;if(f.tokens.next.id!==",")break;tt()}return V(")"),typeof e=="object"&&(!f.inES5()&&e.value==="parseInt"&&n===1&&F("W065",f.tokens.curr),f.option.evil||(e.value==="eval"||e.value==="Function"||e.value==="execScript"?(F("W061",e),r[0]&&[0].id==="(string)"&&U(e,r[0].value)):!r[0]||r[0].id!=="(string)"||e.value!=="setTimeout"&&e.value!=="setInterval"?r[0]&&r[0].id==="(string)"&&e.value==="."&&e.left.value==="window"&&(e.right==="setTimeout"||e.right==="setInterval")&&(F("W066",e),U(e,r[0].value)):(F("W066",e),U(e,r[0].value))),!e.identifier&&e.id!=="."&&e.id!=="["&&e.id!=="=>"&&e.id!=="("&&e.id!=="&&"&&e.id!=="||"&&e.id!=="?"&&(!f.inES6()||!e["(name)"])&&F("W067",t)),t.left=e,t},155,!0).exps=!0,ut("(",function(){var e=f.tokens.next,t,n=-1,r,i,s,o,u=1,a=f.tokens.curr,l=f.tokens.prev,c=!f.option.singleGroups;do e.value==="("?u+=1:e.value===")"&&(u-=1),n+=1,t=e,e=W(n);while((u!==0||t.value!==")")&&e.value!==";"&&e.type!=="(end)");f.tokens.next.id==="function"&&(i=f.tokens.next.immed=!0);if(e.value==="=>")return Xt({type:"arrow",parsedOpening:!0});var h=[];if(f.tokens.next.id!==")")for(;;){h.push(Q(10));if(f.tokens.next.id!==",")break;f.option.nocomma&&F("W127"),tt()}V(")",this),f.option.immed&&h[0]&&h[0].id==="function"&&f.tokens.next.id!=="("&&f.tokens.next.id!=="."&&f.tokens.next.id!=="["&&F("W068",this);if(!h.length)return;return h.length>1?(r=Object.create(f.syntax[","]),r.exprs=h,s=h[0],o=h[h.length-1],c||(c=l.assign||l.delim)):(r=s=o=h[0],c||(c=a.beginsStmt&&(r.id==="{"||i||Ut(r))||i&&(!J()||f.tokens.prev.id!=="}")||Ut(r)&&!J()||r.id==="{"&&l.id==="=>"||r.type==="(number)"&&pn(e,".")&&/^\d+$/.test(r.value))),r&&(!c&&(s.left||s.right||r.exprs)&&(c=!K(l)&&s.lbp<=l.lbp||!J()&&o.lbp"),ht("[",function(e,t){var n=Q(10),r;return n&&n.type==="(string)"&&(!f.option.evil&&(n.value==="eval"||n.value==="execScript")&&yt(e,f)&&F("W061"),Dt(n.value),!f.option.sub&&a.identifier.test(n.value)&&(r=f.syntax[n.value],(!r||!O(r))&&F("W069",f.tokens.prev,n.value))),V("]",t),n&&n.value==="hasOwnProperty"&&f.tokens.next.value==="="&&F("W001"),t.left=e,t.right=n,t},160,!0),ut("[",function(){var e=an();if(e.isCompArray)return!f.option.esnext&&!f.inMoz()&&F("W118",f.tokens.curr,"array comprehension"),Bt();if(e.isDestAssign)return this.destructAssign=Gt({openingParsed:!0,assignment:!0}),this;var t=f.tokens.curr.line!==G(f.tokens.next);this.first=[],t&&(g+=f.option.indent,f.tokens.next.from===g+f.option.indent&&(g+=f.option.indent));while(f.tokens.next.id!=="(end)"){while(f.tokens.next.id===","){if(!f.option.elision){if(!!f.inES5()){F("W128");do V(",");while(f.tokens.next.id===",");continue}F("W070")}V(",")}if(f.tokens.next.id==="]")break;this.first.push(Q(10));if(f.tokens.next.id!==",")break;tt({allowTrailing:!0});if(f.tokens.next.id==="]"&&!f.inES5()){F("W070",f.tokens.curr);break}}return t&&(g-=f.option.indent),V("]",this),this}),function(e){e.nud=function(){var e,t,n,r,i,s=!1,o,u=Object.create(null);e=f.tokens.curr.line!==G(f.tokens.next),e&&(g+=f.option.indent,f.tokens.next.from===g+f.option.indent&&(g+=f.option.indent));var a=an();if(a.isDestAssign)return this.destructAssign=Gt({openingParsed:!0,assignment:!0}),this;for(;;){if(f.tokens.next.id==="}")break;o=f.tokens.next.value;if(!f.tokens.next.identifier||X().id!==","&&X().id!=="}")if(W().id===":"||o!=="get"&&o!=="set"){f.tokens.next.value==="*"&&f.tokens.next.type==="(punctuator)"?(f.inES6()||F("W104",f.tokens.next,"generator functions","6"),V("*"),s=!0):s=!1;if(f.tokens.next.id==="[")n=cn(),f.nameStack.set(n);else{f.nameStack.set(f.tokens.next),n=It(),fn(u,n,f.tokens.next);if(typeof n!="string")break}f.tokens.next.value==="("?(f.inES6()||F("W104",f.tokens.curr,"concise methods","6"),Xt({type:s?"generator":null})):(V(":"),Q(10))}else V(o),f.inES5()||q("E034"),n=It(),!n&&!f.inES6()&&q("E035"),n&&ln(o,u,n,f.tokens.curr),i=f.tokens.next,t=Xt(),r=t["(params)"],o==="get"&&n&&r?F("W076",i,r[0],n):o==="set"&&n&&(!r||r.length!==1)&&F("W077",i,n);else f.inES6()||F("W104",f.tokens.next,"object short notation","6"),n=It(!0),fn(u,n,f.tokens.next),Q(10);Dt(n);if(f.tokens.next.id!==",")break;tt({allowTrailing:!0,property:!0}),f.tokens.next.id===","?F("W070",f.tokens.curr):f.tokens.next.id==="}"&&!f.inES5()&&F("W070",f.tokens.curr)}return e&&(g-=f.option.indent),V("}",this),Kt(u),this},e.fud=function(){q("E036",f.tokens.curr)}}(rt("{"));var tn=it("const",function(e){return en("const",this,e)});tn.exps=!0;var nn=it("let",function(e){return en("let",this,e)});nn.exps=!0;var rn=it("var",function(e){var t=e&&e.prefix,n=e&&e.inexport,i,o,u,a=e&&e.implied,l=!e||!e.ignore;this.first=[];for(;;){var c=[];r.contains(["{","["],f.tokens.next.value)?(i=Gt(),o=!1):(i=[{id:Ct(),token:f.tokens.curr}],o=!0),(!t||!a)&&l&&f.option.varstmt&&F("W132",this),this.first=this.first.concat(c);for(var h in i)i.hasOwnProperty(h)&&(h=i[h],!a&&f.funct["(global)"]&&(S[h.id]===!1?F("W079",h.token,h.id):f.option.futurehostile===!1&&(!f.inES5()&&s.ecmaIdentifiers[5][h.id]===!1||!f.inES6()&&s.ecmaIdentifiers[6][h.id]===!1)&&F("W129",h.token,h.id)),h.id&&(a==="for"?(f.funct["(scope)"].has(h.id)||l&&F("W088",h.token,h.id),f.funct["(scope)"].block.use(h.id,h.token)):(f.funct["(scope)"].addlabel(h.id,{type:"var",token:h.token}),o&&n&&f.funct["(scope)"].setExported(h.id,h.token)),c.push(h.token)));f.tokens.next.id==="="&&(f.nameStack.set(f.tokens.curr),V("="),!t&&l&&!f.funct["(loopage)"]&&f.tokens.next.id==="undefined"&&F("W080",f.tokens.prev,f.tokens.prev.value),W(0).id==="="&&f.tokens.next.identifier&&(!t&&l&&!f.funct["(params)"]||f.funct["(params)"].indexOf(f.tokens.next.value)===-1)&&F("W120",f.tokens.next,f.tokens.next.value),u=Q(t?120:10),o?i[0].first=u:Zt(c,u));if(f.tokens.next.id!==",")break;tt()}return this});rn.exps=!0,st("class",function(){return sn.call(this,!0)}),st("function",function(e){var t=e&&e.inexport,n=!1;f.tokens.next.value==="*"&&(V("*"),f.inES6({strict:!0})?n=!0:F("W119",f.tokens.curr,"function*","6")),m&&F("W082",f.tokens.curr);var r=Nt();return f.funct["(scope)"].addlabel(r,{type:"function",token:f.tokens.curr}),r===undefined?F("W025"):t&&f.funct["(scope)"].setExported(r,f.tokens.prev),Xt({name:r,statement:this,type:n?"generator":null,ignoreLoopFunc:m}),f.tokens.next.id==="("&&f.tokens.next.line===f.tokens.curr.line&&q("E039"),this}),ut("function",function(){var e=!1;f.tokens.next.value==="*"&&(f.inES6()||F("W119",f.tokens.curr,"function*","6"),V("*"),e=!0);var t=Nt();return Xt({name:t,type:e?"generator":null}),this}),st("if",function(){var e=f.tokens.next;$t(),f.condition=!0,V("(");var t=Q(0);Jt(t);var n=null;f.option.forin&&f.forinifcheckneeded&&(f.forinifcheckneeded=!1,n=f.forinifchecks[f.forinifchecks.length-1],t.type==="(punctuator)"&&t.value==="!"?n.type="(negative)":n.type="(positive)"),V(")",e),f.condition=!1;var r=_t(!0,!0);return n&&n.type==="(negative)"&&r&&r[0]&&r[0].type==="(identifier)"&&r[0].value==="continue"&&(n.type="(negative-with-continue)"),f.tokens.next.id==="else"&&(V("else"),f.tokens.next.id==="if"||f.tokens.next.id==="switch"?At():_t(!0,!0)),this}),st("try",function(){function t(){V("catch"),V("("),f.funct["(scope)"].stack("catchparams");if(hn(f.tokens.next,["[","{"])){var e=Gt();r.each(e,function(e){e.id&&f.funct["(scope)"].addParam(e.id,e,"exception")})}else f.tokens.next.type!=="(identifier)"?F("E030",f.tokens.next,f.tokens.next.value):f.funct["(scope)"].addParam(Ct(),f.tokens.curr,"exception");f.tokens.next.value==="if"&&(f.inMoz()||F("W118",f.tokens.curr,"catch filter"),V("if"),Q(0)),V(")"),_t(!1),f.funct["(scope)"].unstack()}var e;_t(!0);while(f.tokens.next.id==="catch")$t(),e&&!f.inMoz()&&F("W118",f.tokens.next,"multiple catch blocks"),t(),e=!0;if(f.tokens.next.id==="finally"){V("finally"),_t(!0);return}return e||q("E021",f.tokens.next,"catch",f.tokens.next.value),this}),st("while",function(){var e=f.tokens.next;return f.funct["(breakage)"]+=1,f.funct["(loopage)"]+=1,$t(),V("("),Jt(Q(0)),V(")",e),_t(!0,!0),f.funct["(breakage)"]-=1,f.funct["(loopage)"]-=1,this}).labelled=!0,st("with",function(){var e=f.tokens.next;return f.isStrict()?q("E010",f.tokens.curr):f.option.withstmt||F("W085",f.tokens.curr),V("("),Q(0),V(")",e),_t(!0,!0),this}),st("switch",function(){var e=f.tokens.next,t=!1,n=!1;f.funct["(breakage)"]+=1,V("("),Jt(Q(0)),V(")",e),e=f.tokens.next,V("{"),f.tokens.next.from===g&&(n=!0),n||(g+=f.option.indent),this.cases=[];for(;;)switch(f.tokens.next.id){case"case":switch(f.funct["(verb)"]){case"yield":case"break":case"case":case"continue":case"return":case"switch":case"throw":break;default:f.tokens.curr.caseFallsThrough||F("W086",f.tokens.curr,"case")}V("case"),this.cases.push(Q(0)),$t(),t=!0,V(":"),f.funct["(verb)"]="case";break;case"default":switch(f.funct["(verb)"]){case"yield":case"break":case"continue":case"return":case"throw":break;default:this.cases.length&&(f.tokens.curr.caseFallsThrough||F("W086",f.tokens.curr,"default"))}V("default"),t=!0,V(":");break;case"}":n||(g-=f.option.indent),V("}",e),f.funct["(breakage)"]-=1,f.funct["(verb)"]=undefined;return;case"(end)":q("E023",f.tokens.next,"}");return;default:g+=f.option.indent;if(t)switch(f.tokens.curr.id){case",":q("E040");return;case":":t=!1,Ot();break;default:q("E025",f.tokens.curr);return}else{if(f.tokens.curr.id!==":"){q("E021",f.tokens.next,"case",f.tokens.next.value);return}V(":"),q("E024",f.tokens.curr,":"),Ot()}g-=f.option.indent}return this}).labelled=!0,it("debugger",function(){return f.option.debug||F("W087",this),this}).exps=!0,function(){var e=it("do",function(){f.funct["(breakage)"]+=1,f.funct["(loopage)"]+=1,$t(),this.first=_t(!0,!0),V("while");var e=f.tokens.next;return V("("),Jt(Q(0)),V(")",e),f.funct["(breakage)"]-=1,f.funct["(loopage)"]-=1,this});e.labelled=!0,e.exps=!0}(),st("for",function(){var e,t=f.tokens.next,n=!1,i=null;t.value==="each"&&(i=t,V("each"),f.inMoz()||F("W118",f.tokens.curr,"for each")),$t(),V("(");var s,o=0,u=["in","of"],a=0,l,c;hn(f.tokens.next,["{","["])&&++a;do{s=W(o),++o,hn(s,["{","["])?++a:hn(s,["}","]"])&&--a;if(a<0)break;a===0&&(!l&&pn(s,",")?l=s:!c&&pn(s,"=")&&(c=s))}while(a>0||!r.contains(u,s.value)&&s.value!==";"&&s.type!=="(end)");if(r.contains(u,s.value)){!f.inES6()&&s.value==="of"&&F("W104",s,"for of","6");var h=!c&&!l;c&&q("W133",l,s.value,"initializer is forbidden"),l&&q("W133",l,s.value,"more than one ForBinding"),f.tokens.next.id==="var"?(V("var"),f.tokens.curr.fud({prefix:!0})):f.tokens.next.id==="let"||f.tokens.next.id==="const"?(V(f.tokens.next.id),n=!0,f.funct["(scope)"].stack(),f.tokens.curr.fud({prefix:!0})):Object.create(rn).fud({prefix:!0,implied:"for",ignore:!h}),V(s.value),Q(20),V(")",t),s.value==="in"&&f.option.forin&&(f.forinifcheckneeded=!0,f.forinifchecks===undefined&&(f.forinifchecks=[]),f.forinifchecks.push({type:"(none)"})),f.funct["(breakage)"]+=1,f.funct["(loopage)"]+=1,e=_t(!0,!0);if(s.value==="in"&&f.option.forin){if(f.forinifchecks&&f.forinifchecks.length>0){var p=f.forinifchecks.pop();(e&&e.length>0&&(typeof e[0]!="object"||e[0].value!=="if")||p.type==="(positive)"&&e.length>1||p.type==="(negative)")&&F("W089",this)}f.forinifcheckneeded=!1}f.funct["(breakage)"]-=1,f.funct["(loopage)"]-=1}else{i&&q("E045",i);if(f.tokens.next.id!==";")if(f.tokens.next.id==="var")V("var"),f.tokens.curr.fud();else if(f.tokens.next.id==="let")V("let"),n=!0,f.funct["(scope)"].stack(),f.tokens.curr.fud();else for(;;){Q(0,"for");if(f.tokens.next.id!==",")break;l()}Z(f.tokens.curr),V(";"),f.funct["(loopage)"]+=1,f.tokens.next.id!==";"&&Jt(Q(0)),Z(f.tokens.curr),V(";"),f.tokens.next.id===";"&&q("E021",f.tokens.next,")",";");if(f.tokens.next.id!==")")for(;;){Q(0,"for");if(f.tokens.next.id!==",")break;l()}V(")",t),f.funct["(breakage)"]+=1,_t(!0,!0),f.funct["(breakage)"]-=1,f.funct["(loopage)"]-=1}return n&&f.funct["(scope)"].unstack(),this}).labelled=!0,it("break",function(){var e=f.tokens.next.value;return f.option.asi||Z(this),f.tokens.next.id!==";"&&!f.tokens.next.reach&&f.tokens.curr.line===G(f.tokens.next)?(f.funct["(scope)"].funct.hasBreakLabel(e)||F("W090",f.tokens.next,e),this.first=f.tokens.next,V()):f.funct["(breakage)"]===0&&F("W052",f.tokens.next,this.value),kt(this),this}).exps=!0,it("continue",function(){var e=f.tokens.next.value;return f.funct["(breakage)"]===0&&F("W052",f.tokens.next,this.value),f.funct["(loopage)"]||F("W052",f.tokens.next,this.value),f.option.asi||Z(this),f.tokens.next.id!==";"&&!f.tokens.next.reach&&f.tokens.curr.line===G(f.tokens.next)&&(f.funct["(scope)"].funct.hasBreakLabel(e)||F("W090",f.tokens.next,e),this.first=f.tokens.next,V()),kt(this),this}).exps=!0,it("return",function(){return this.line===G(f.tokens.next)?f.tokens.next.id!==";"&&!f.tokens.next.reach&&(this.first=Q(0),this.first&&this.first.type==="(punctuator)"&&this.first.value==="="&&!this.first.paren&&!f.option.boss&&I("W093",this.first.line,this.first.character)):f.tokens.next.type==="(punctuator)"&&["[","{","+","-"].indexOf(f.tokens.next.value)>-1&&Z(this),kt(this),this}).exps=!0,function(e){e.exps=!0,e.lbp=25}(ut("yield",function(){var e=f.tokens.prev;f.inES6(!0)&&!f.funct["(generator)"]?("(catch)"!==f.funct["(name)"]||!f.funct["(context)"]["(generator)"])&&q("E046",f.tokens.curr,"yield"):f.inES6()||F("W104",f.tokens.curr,"yield","6"),f.funct["(generator)"]="yielded";var t=!1;f.tokens.next.value==="*"&&(t=!0,V("*"));if(this.line===G(f.tokens.next)||!f.inMoz()){if(t||f.tokens.next.id!==";"&&!f.option.asi&&!f.tokens.next.reach&&f.tokens.next.nud)Y(f.tokens.curr,f.tokens.next),this.first=Q(10),this.first.type==="(punctuator)"&&this.first.value==="="&&!this.first.paren&&!f.option.boss&&I("W093",this.first.line,this.first.character);f.inMoz()&&f.tokens.next.id!==")"&&(e.lbp>30||!e.assign&&!J()||e.id==="yield")&&q("E050",this)}else f.option.asi||Z(this);return this})),it("throw",function(){return Z(this),this.first=Q(20),kt(this),this}).exps=!0,it("import",function(){f.inES6()||F("W119",f.tokens.curr,"import","6");if(f.tokens.next.type==="(string)")return V("(string)"),this;if(f.tokens.next.identifier){this.name=Ct(),f.funct["(scope)"].addlabel(this.name,{type:"const",token:f.tokens.curr});if(f.tokens.next.value!==",")return V("from"),V("(string)"),this;V(",")}if(f.tokens.next.id==="*")V("*"),V("as"),f.tokens.next.identifier&&(this.name=Ct(),f.funct["(scope)"].addlabel(this.name,{type:"const",token:f.tokens.curr}));else{V("{");for(;;){if(f.tokens.next.value==="}"){V("}");break}var e;f.tokens.next.type==="default"?(e="default",V("default")):e=Ct(),f.tokens.next.value==="as"&&(V("as"),e=Ct()),f.funct["(scope)"].addlabel(e,{type:"const",token:f.tokens.curr});if(f.tokens.next.value!==","){if(f.tokens.next.value==="}"){V("}");break}q("E024",f.tokens.next,f.tokens.next.value);break}V(",")}}return V("from"),V("(string)"),this}).exps=!0,it("export",function(){var e=!0,t,n;f.inES6()||(F("W119",f.tokens.curr,"export","6"),e=!1),f.funct["(scope)"].block.isGlobal()||(q("E053",f.tokens.curr),e=!1);if(f.tokens.next.value==="*")return V("*"),V("from"),V("(string)"),this;if(f.tokens.next.type==="default"){f.nameStack.set(f.tokens.next),V("default");var r=f.tokens.next.id;if(r==="function"||r==="class")this.block=!0;return t=W(),Q(10),n=t.value,this.block&&(f.funct["(scope)"].addlabel(n,{type:r,token:t}),f.funct["(scope)"].setExported(n,t)),this}if(f.tokens.next.value==="{"){V("{");var i=[];for(;;){f.tokens.next.identifier||q("E030",f.tokens.next,f.tokens.next.value),V(),i.push(f.tokens.curr),f.tokens.next.value==="as"&&(V("as"),f.tokens.next.identifier||q("E030",f.tokens.next,f.tokens.next.value),V());if(f.tokens.next.value!==","){if(f.tokens.next.value==="}"){V("}");break}q("E024",f.tokens.next,f.tokens.next.value);break}V(",")}return f.tokens.next.value==="from"?(V("from"),V("(string)")):e&&i.forEach(function(e){f.funct["(scope)"].setExported(e.value,e)}),this}if(f.tokens.next.id==="var")V("var"),f.tokens.curr.fud({inexport:!0});else if(f.tokens.next.id==="let")V("let"),f.tokens.curr.fud({inexport:!0});else if(f.tokens.next.id==="const")V("const"),f.tokens.curr.fud({inexport:!0});else if(f.tokens.next.id==="function")this.block=!0,V("function"),f.syntax["function"].fud({inexport:!0});else if(f.tokens.next.id==="class"){this.block=!0,V("class");var s=f.tokens.next;f.syntax["class"].fud(),f.funct["(scope)"].setExported(s.value,s)}else q("E024",f.tokens.next,f.tokens.next.value);return this}).exps=!0,lt("abstract"),lt("boolean"),lt("byte"),lt("char"),lt("class",{es5:!0,nud:sn}),lt("double"),lt("enum",{es5:!0}),lt("export",{es5:!0}),lt("extends",{es5:!0}),lt("final"),lt("float"),lt("goto"),lt("implements",{es5:!0,strictOnly:!0}),lt("import",{es5:!0}),lt("int"),lt("interface",{es5:!0,strictOnly:!0}),lt("long"),lt("native"),lt("package",{es5:!0,strictOnly:!0}),lt("private",{es5:!0,strictOnly:!0}),lt("protected",{es5:!0,strictOnly:!0}),lt("public",{es5:!0,strictOnly:!0}),lt("short"),lt("static",{es5:!0,strictOnly:!0}),lt("super",{es5:!0}),lt("synchronized"),lt("transient"),lt("volatile");var an=function(){var e,t,n,r=-1,i=0,s={};hn(f.tokens.curr,["[","{"])&&(i+=1);do{n=r===-1?f.tokens.curr:e,e=r===-1?f.tokens.next:W(r),t=W(r+1),r+=1,hn(e,["[","{"])?i+=1:hn(e,["]","}"])&&(i-=1);if(i===1&&e.identifier&&e.value==="for"&&!pn(n,".")){s.isCompArray=!0,s.notJson=!0;break}if(i===0&&hn(e,["}","]"])){if(t.value==="="){s.isDestAssign=!0,s.notJson=!0;break}if(t.value==="."){s.notJson=!0;break}}pn(e,";")&&(s.isBlock=!0,s.notJson=!0)}while(i>0&&e.id!=="(end)");return s},vn=function(){function i(e){var t=n.variables.filter(function(t){if(t.value===e)return t.undef=!1,e}).length;return t!==0}function s(e){var t=n.variables.filter(function(t){if(t.value===e&&!t.undef)return t.unused===!0&&(t.unused=!1),e}).length;return t===0}var e=function(){this.mode="use",this.variables=[]},t=[],n;return{stack:function(){n=new e,t.push(n)},unstack:function(){n.variables.filter(function(e){e.unused&&F("W098",e.token,e.raw_text||e.value),e.undef&&f.funct["(scope)"].block.use(e.value,e.token)}),t.splice(-1,1),n=t[t.length-1]},setState:function(e){r.contains(["use","define","generate","filter"],e)&&(n.mode=e)},check:function(e){if(!n)return;return n&&n.mode==="use"?(s(e)&&n.variables.push({funct:f.funct,token:f.tokens.curr,value:e,undef:!0,unused:!1}),!0):n&&n.mode==="define"?(i(e)||n.variables.push({funct:f.funct,token:f.tokens.curr,value:e,undef:!1,unused:!0}),!0):n&&n.mode==="generate"?(f.funct["(scope)"].block.use(e,f.tokens.curr),!0):n&&n.mode==="filter"?(s(e)&&f.funct["(scope)"].block.use(e,f.tokens.curr),!0):!1}}},gn=function(e){return e.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&")},yn=function(t,i,o){function U(e,t){if(!e)return;!Array.isArray(e)&&typeof e=="object"&&(e=Object.keys(e)),e.forEach(t)}var a,l,c,d,A,O,M={},P={};i=r.clone(i),f.reset(),i&&i.scope?p.scope=i.scope:(p.errors=[],p.undefs=[],p.internals=[],p.blacklist={},p.scope="(main)"),S=Object.create(null),D(S,s.ecmaIdentifiers[3]),D(S,s.reservedVars),D(S,o||{}),n=Object.create(null);var j=Object.create(null);if(i){U(i.predef||null,function(e){var t,n;e[0]==="-"?(t=e.slice(1),p.blacklist[t]=t,delete S[t]):(n=Object.getOwnPropertyDescriptor(i.predef,e),S[e]=n?n.value:!1)}),U(i.exported||null,function(e){j[e]=!0}),delete i.predef,delete i.exported,O=Object.keys(i);for(c=0;c0&&(e.implieds=u),T.length>0&&(e.urls=T),o=f.funct["(scope)"].getUsedOrDefinedGlobals(),o.length>0&&(e.globals=o);for(r=1;r0&&(e.unused=a);for(s in w)if(typeof w[s]=="number"){e.member=w;break}return e},yn.jshint=yn,yn}();typeof n=="object"&&n&&(n.JSHINT=p)},{"../lodash":"/node_modules/jshint/lodash.js","./lex.js":"/node_modules/jshint/src/lex.js","./messages.js":"/node_modules/jshint/src/messages.js","./options.js":"/node_modules/jshint/src/options.js","./reg.js":"/node_modules/jshint/src/reg.js","./scope-manager.js":"/node_modules/jshint/src/scope-manager.js","./state.js":"/node_modules/jshint/src/state.js","./style.js":"/node_modules/jshint/src/style.js","./vars.js":"/node_modules/jshint/src/vars.js",events:"/node_modules/browserify/node_modules/events/events.js"}],"/node_modules/jshint/src/lex.js":[function(e,t,n){"use strict";function h(){var e=[];return{push:function(t){e.push(t)},check:function(){for(var t=0;t0&&this.context[this.context.length-1].type===e},pushContext:function(e){this.context.push({type:e})},popContext:function(){return this.context.pop()},isContext:function(e){return this.context.length>0&&this.context[this.context.length-1]===e},currentContext:function(){return this.context.length>0&&this.context[this.context.length-1]},getLines:function(){return this._lines=o.lines,this._lines},setLines:function(e){this._lines=e,o.lines=this._lines},peek:function(e){return this.input.charAt(e||0)},skip:function(e){e=e||1,this.char+=e,this.input=this.input.slice(e)},on:function(e,t){e.split(" ").forEach(function(e){this.emitter.on(e,t)}.bind(this))},trigger:function(){this.emitter.emit.apply(this.emitter,Array.prototype.slice.call(arguments))},triggerAsync:function(e,t,n,r){n.push(function(){r()&&this.trigger(e,t)}.bind(this))},scanPunctuator:function(){var e=this.peek(),t,n,r;switch(e){case".":if(/^[0-9]$/.test(this.peek(1)))return null;if(this.peek(1)==="."&&this.peek(2)===".")return{type:l.Punctuator,value:"..."};case"(":case")":case";":case",":case"[":case"]":case":":case"~":case"?":return{type:l.Punctuator,value:e};case"{":return this.pushContext(c.Block),{type:l.Punctuator,value:e};case"}":return this.inContext(c.Block)&&this.popContext(),{type:l.Punctuator,value:e};case"#":return{type:l.Punctuator,value:e};case"":return null}return t=this.peek(1),n=this.peek(2),r=this.peek(3),e===">"&&t===">"&&n===">"&&r==="="?{type:l.Punctuator,value:">>>="}:e==="="&&t==="="&&n==="="?{type:l.Punctuator,value:"==="}:e==="!"&&t==="="&&n==="="?{type:l.Punctuator,value:"!=="}:e===">"&&t===">"&&n===">"?{type:l.Punctuator,value:">>>"}:e==="<"&&t==="<"&&n==="="?{type:l.Punctuator,value:"<<="}:e===">"&&t===">"&&n==="="?{type:l.Punctuator,value:">>="}:e==="="&&t===">"?{type:l.Punctuator,value:e+t}:e===t&&"+-<>&|".indexOf(e)>=0?{type:l.Punctuator,value:e+t}:"<>=!+-*%&|^".indexOf(e)>=0?t==="="?{type:l.Punctuator,value:e+t}:{type:l.Punctuator,value:e}:e==="/"?t==="="?{type:l.Punctuator,value:"/="}:{type:l.Punctuator,value:"/"}:null},scanComments:function(){function u(e,t,n){var r=["jshint","jslint","members","member","globals","global","exported"],i=!1,u=e+t,a="plain";return n=n||{},n.isMultiline&&(u+="*/"),t=t.replace(/\n/g," "),e==="/*"&&s.fallsThrough.test(t)&&(i=!0,a="falls through"),r.forEach(function(n){if(i)return;if(e==="//"&&n!=="jshint")return;t.charAt(n.length)===" "&&t.substr(0,n.length)===n&&(i=!0,e+=n,t=t.substr(n.length)),!i&&t.charAt(0)===" "&&t.charAt(n.length+1)===" "&&t.substr(1,n.length)===n&&(i=!0,e=e+" "+n,t=t.substr(n.length+1));if(!i)return;switch(n){case"member":a="members";break;case"global":a="globals";break;default:var r=t.split(":").map(function(e){return e.replace(/^\s+/,"").replace(/\s+$/,"")});if(r.length===2)switch(r[0]){case"ignore":switch(r[1]){case"start":o.ignoringLinterErrors=!0,i=!1;break;case"end":o.ignoringLinterErrors=!1,i=!1}}a=n}}),{type:l.Comment,commentType:a,value:u,body:t,isSpecial:i,isMultiline:n.isMultiline||!1,isMalformed:n.isMalformed||!1}}var e=this.peek(),t=this.peek(1),n=this.input.substr(2),r=this.line,i=this.char,o=this;if(e==="*"&&t==="/")return this.trigger("error",{code:"E018",line:r,character:i}),this.skip(2),null;if(e!=="/"||t!=="*"&&t!=="/")return null;if(t==="/")return this.skip(this.input.length),u("//",n);var a="";if(t==="*"){this.inComment=!0,this.skip(2);while(this.peek()!=="*"||this.peek(1)!=="/")if(this.peek()===""){a+="\n";if(!this.nextLine())return this.trigger("error",{code:"E017",line:r,character:i}),this.inComment=!1,u("/*",a,{isMultiline:!0,isMalformed:!0})}else a+=this.peek(),this.skip();return this.skip(2),this.inComment=!1,u("/*",a,{isMultiline:!0})}},scanKeyword:function(){var e=/^[a-zA-Z_$][a-zA-Z0-9_$]*/.exec(this.input),t=["if","in","do","var","for","new","try","let","this","else","case","void","with","enum","while","break","catch","throw","const","yield","class","super","return","typeof","delete","switch","export","import","default","finally","extends","function","continue","debugger","instanceof"];return e&&t.indexOf(e[0])>=0?{type:l.Keyword,value:e[0]}:null},scanIdentifier:function(){function i(e){return e>256}function s(e){return e>256}function o(e){return/^[0-9a-fA-F]$/.test(e)}function p(e){return e.replace(/\\u([0-9a-fA-F]{4})/g,function(e,t){return String.fromCharCode(parseInt(t,16))})}var e="",t=0,n,r,u=function(){t+=1;if(this.peek(t)!=="u")return null;var e=this.peek(t+1),n=this.peek(t+2),r=this.peek(t+3),i=this.peek(t+4),u;return o(e)&&o(n)&&o(r)&&o(i)?(u=parseInt(e+n+r+i,16),f[u]||s(u)?(t+=5,"\\u"+e+n+r+i):null):null}.bind(this),c=function(){var e=this.peek(t),n=e.charCodeAt(0);return n===92?u():n<128?a[n]?(t+=1,e):null:i(n)?(t+=1,e):null}.bind(this),h=function(){var e=this.peek(t),n=e.charCodeAt(0);return n===92?u():n<128?f[n]?(t+=1,e):null:s(n)?(t+=1,e):null}.bind(this);r=c();if(r===null)return null;e=r;for(;;){r=h();if(r===null)break;e+=r}switch(e){case"true":case"false":n=l.BooleanLiteral;break;case"null":n=l.NullLiteral;break;default:n=l.Identifier}return{type:n,value:p(e),text:e,tokenLength:e.length}},scanNumericLiteral:function(){function f(e){return/^[0-9]$/.test(e)}function c(e){return/^[0-7]$/.test(e)}function h(e){return/^[01]$/.test(e)}function p(e){return/^[0-9a-fA-F]$/.test(e)}function d(e){return e==="$"||e==="_"||e==="\\"||e>="a"&&e<="z"||e>="A"&&e<="Z"}var e=0,t="",n=this.input.length,r=this.peek(e),i,s=f,u=10,a=!1;if(r!=="."&&!f(r))return null;if(r!=="."){t=this.peek(e),e+=1,r=this.peek(e);if(t==="0"){if(r==="x"||r==="X")s=p,u=16,e+=1,t+=r;if(r==="o"||r==="O")s=c,u=8,o.inES6(!0)||this.trigger("warning",{code:"W119",line:this.line,character:this.char,data:["Octal integer literal","6"]}),e+=1,t+=r;if(r==="b"||r==="B")s=h,u=2,o.inES6(!0)||this.trigger("warning",{code:"W119",line:this.line,character:this.char,data:["Binary integer literal","6"]}),e+=1,t+=r;c(r)&&(s=c,u=8,a=!0,i=!1,e+=1,t+=r),!c(r)&&f(r)&&(e+=1,t+=r)}while(e=0&&i<=7&&o.isStrict()});break;case"u":var s=this.input.substr(1,4),u=parseInt(s,16);isNaN(u)&&this.trigger("warning",{code:"W052",line:this.line,character:this.char,data:["u"+s]}),r=String.fromCharCode(u),n=5;break;case"v":this.triggerAsync("warning",{code:"W114",line:this.line,character:this.char,data:["\\v"]},e,function(){return o.jsonMode}),r=" ";break;case"x":var a=parseInt(this.input.substr(1,2),16);this.triggerAsync("warning",{code:"W114",line:this.line,character:this.char,data:["\\x-"]},e,function(){return o.jsonMode}),r=String.fromCharCode(a),n=3;break;case"\\":r="\\\\";break;case'"':r='\\"';break;case"/":break;case"":t=!0,r=""}return{"char":r,jump:n,allowNewLine:t}},scanTemplateLiteral:function(e){var t,n="",r,i=this.line,s=this.char,u=this.templateStarts.length;if(!o.inES6(!0))return null;if(this.peek()==="`")t=l.TemplateHead,this.templateStarts.push({line:this.line,"char":this.char}),u=this.templateStarts.length,this.skip(1),this.pushContext(c.Template);else{if(!this.inContext(c.Template)||this.peek()!=="}")return null;t=l.TemplateMiddle}while(this.peek()!=="`"){while((r=this.peek())===""){n+="\n";if(!this.nextLine()){var a=this.templateStarts.pop();return this.trigger("error",{code:"E052",line:a.line,character:a.char}),{type:t,value:n,startLine:i,startChar:s,isUnclosed:!0,depth:u,context:this.popContext()}}}if(r==="$"&&this.peek(1)==="{")return n+="${",this.skip(2),{type:t,value:n,startLine:i,startChar:s,isUnclosed:!1,depth:u,context:this.currentContext()};if(r==="\\"){var f=this.scanEscapeSequence(e);n+=f.char,this.skip(f.jump)}else r!=="`"&&(n+=r,this.skip(1))}return t=t===l.TemplateHead?l.NoSubstTemplate:l.TemplateTail,this.skip(1),this.templateStarts.pop(),{type:t,value:n,startLine:i,startChar:s,isUnclosed:!1,depth:u,context:this.popContext()}},scanStringLiteral:function(e){var t=this.peek();if(t!=='"'&&t!=="'")return null;this.triggerAsync("warning",{code:"W108",line:this.line,character:this.char},e,function(){return o.jsonMode&&t!=='"'});var n="",r=this.line,i=this.char,s=!1;this.skip();while(this.peek()!==t)if(this.peek()===""){s?(s=!1,this.triggerAsync("warning",{code:"W043",line:this.line,character:this.char},e,function(){return!o.option.multistr}),this.triggerAsync("warning",{code:"W042",line:this.line,character:this.char},e,function(){return o.jsonMode&&o.option.multistr})):this.trigger("warning",{code:"W112",line:this.line,character:this.char});if(!this.nextLine())return this.trigger("error",{code:"E029",line:r,character:i}),{type:l.StringLiteral,value:n,startLine:r,startChar:i,isUnclosed:!0,quote:t}}else{s=!1;var u=this.peek(),a=1;u<" "&&this.trigger("warning",{code:"W113",line:this.line,character:this.char,data:[""]});if(u==="\\"){var f=this.scanEscapeSequence(e);u=f.char,a=f.jump,s=f.allowNewLine}n+=u,this.skip(a)}return this.skip(),{type:l.StringLiteral,value:n,startLine:r,startChar:i,isUnclosed:!1,quote:t}},scanRegExp:function(){var e=0,t=this.input.length,n=this.peek(),r=n,i="",s=[],o=!1,u=!1,a,f=function(){n<" "&&(o=!0,this.trigger("warning",{code:"W048",line:this.line,character:this.char})),n==="<"&&(o=!0,this.trigger("warning",{code:"W049",line:this.line,character:this.char,data:[n]}))}.bind(this);if(!this.prereg||n!=="/")return null;e+=1,a=!1;while(e=this.getLines().length)return!1;this.input=this.getLines()[this.line],this.line+=1,this.char=1,this.from=1;var t=this.input.trim(),n=function(){return r.some(arguments,function(e){return t.indexOf(e)===0})},i=function(){return r.some(arguments,function(e){return t.indexOf(e,t.length-e.length)!==-1})};this.ignoringLinterErrors===!0&&!n("/*","//")&&(!this.inComment||!i("*/"))&&(this.input=""),e=this.scanNonBreakingSpaces(),e>=0&&this.trigger("warning",{code:"W125",line:this.line,character:e+1}),this.input=this.input.replace(/\t/g,o.tab),e=this.scanUnsafeChars(),e>=0&&this.trigger("warning",{code:"W100",line:this.line,character:e});if(!this.ignoringLinterErrors&&o.option.maxlen&&o.option.maxlen=0;--t){var n=a[t]["(labels)"];if(n[e])return n}}function x(e){for(var t=a.length-1;t>=0;t--){var n=a[t];if(n["(usages)"][e])return n["(usages)"][e];if(n===l)break}return!1}function T(t,n){if(e.option.shadow!=="outer")return;var r=l["(type)"]==="global",i=u["(type)"]==="functionparams",s=!r;for(var o=0;o1?a[a.length-2]:null,n=u===l,i=u["(type)"]==="functionparams",f=u["(type)"]==="functionouter",p,d,g=u["(usages)"],y=u["(labels)"],E=Object.keys(g);g.__proto__&&E.indexOf("__proto__")===-1&&E.push("__proto__");for(p=0;p=0;s--){var o=a[s];if(o["(labels)"][e]&&(!n||o["(labels)"][e]["(blockscoped)"]))return o["(labels)"][e]["(type)"];var u=r?a[s-1]:o;if(u&&u["(type)"]==="functionparams")return null}return null},hasBreakLabel:function(e){for(var t=a.length-1;t>=0;t--){var n=a[t];if(n["(breakLabels)"][e])return!0;if(n["(type)"]==="functionparams")return!1}return!1},has:function(e,t){return Boolean(this.labeltype(e,t))},add:function(e,t,n,r){u["(labels)"][e]={"(type)":t,"(token)":n,"(blockscoped)":!1,"(function)":l,"(unused)":r}}},block:{isGlobal:function(){return u["(type)"]==="global"},use:function(t,n){var r=l["(parent)"];r&&r["(labels)"][t]&&r["(labels)"][t]["(type)"]==="param"&&(C.funct.has(t,{excludeParams:!0,onlyBlockscoped:!0})||(r["(labels)"][t]["(unused)"]=!1)),n&&(e.ignored.W117||e.option.undef===!1)&&(n.ignoreUndef=!0),g(t),n&&(n["(function)"]=l,u["(usages)"][t]["(tokens)"].push(n))},reassign:function(e,t){this.modify(e,t),u["(usages)"][e]["(reassigned)"].push(t)},modify:function(e,t){g(e),u["(usages)"][e]["(modified)"].push(t)},add:function(e,t,n,r){u["(labels)"][e]={"(type)":t,"(token)":n,"(blockscoped)":!0,"(unused)":r}},addBreakLabel:function(t,n){var r=n.token;C.funct.hasBreakLabel(t)?v("E011",r,t):e.option.shadow==="outer"&&(C.funct.has(t)?v("W004",r,t):T(t,r)),u["(breakLabels)"][t]=r}}};return C};t.exports=o},{"../lodash":"/node_modules/jshint/lodash.js",events:"/node_modules/browserify/node_modules/events/events.js"}],"/node_modules/jshint/src/state.js":[function(e,t,n){"use strict";var r=e("./name-stack.js"),i={syntax:{},isStrict:function(){return this.directive["use strict"]||this.inClassBody||this.option.module||this.option.strict==="implied"},inMoz:function(){return this.option.moz},inES6:function(){return this.option.moz||this.option.esversion>=6},inES5:function(e){return e?(!this.option.esversion||this.option.esversion===5)&&!this.option.moz:!this.option.esversion||this.option.esversion>=5||this.option.moz},reset:function(){this.tokens={prev:null,next:null,curr:null},this.option={},this.funct=null,this.ignored={},this.directive={},this.jsonMode=!1,this.jsonWarnings=[],this.lines=[],this.tab="",this.cache={},this.ignoredLines={},this.forinifcheckneeded=!1,this.nameStack=new r,this.inClassBody=!1}};n.state=i},{"./name-stack.js":"/node_modules/jshint/src/name-stack.js"}],"/node_modules/jshint/src/style.js":[function(e,t,n){"use strict";n.register=function(e){e.on("Identifier",function(n){if(e.getOption("proto"))return;n.name==="__proto__"&&e.warn("W103",{line:n.line,"char":n.char,data:[n.name,"6"]})}),e.on("Identifier",function(n){if(e.getOption("iterator"))return;n.name==="__iterator__"&&e.warn("W103",{line:n.line,"char":n.char,data:[n.name]})}),e.on("Identifier",function(n){if(!e.getOption("camelcase"))return;n.name.replace(/^_+|_+$/g,"").indexOf("_")>-1&&!n.name.match(/^[A-Z0-9_]*$/)&&e.warn("W106",{line:n.line,"char":n.from,data:[n.name]})}),e.on("String",function(n){var r=e.getOption("quotmark"),i;if(!r)return;r==="single"&&n.quote!=="'"&&(i="W109"),r==="double"&&n.quote!=='"'&&(i="W108"),r===!0&&(e.getCache("quotmark")||e.setCache("quotmark",n.quote),e.getCache("quotmark")!==n.quote&&(i="W110")),i&&e.warn(i,{line:n.line,"char":n.char})}),e.on("Number",function(n){n.value.charAt(0)==="."&&e.warn("W008",{line:n.line,"char":n.char,data:[n.value]}),n.value.substr(n.value.length-1)==="."&&e.warn("W047",{line:n.line,"char":n.char,data:[n.value]}),/^00+/.test(n.value)&&e.warn("W046",{line:n.line,"char":n.char,data:[n.value]})}),e.on("String",function(n){var r=/^(?:javascript|jscript|ecmascript|vbscript|livescript)\s*:/i;if(e.getOption("scripturl"))return;r.test(n.value)&&e.warn("W107",{line:n.line,"char":n.char})})}},{}],"/node_modules/jshint/src/vars.js":[function(e,t,n){"use strict";n.reservedVars={arguments:!1,NaN:!1},n.ecmaIdentifiers={3:{Array:!1,Boolean:!1,Date:!1,decodeURI:!1,decodeURIComponent:!1,encodeURI:!1,encodeURIComponent:!1,Error:!1,eval:!1,EvalError:!1,Function:!1,hasOwnProperty:!1,isFinite:!1,isNaN:!1,Math:!1,Number:!1,Object:!1,parseInt:!1,parseFloat:!1,RangeError:!1,ReferenceError:!1,RegExp:!1,String:!1,SyntaxError:!1,TypeError:!1,URIError:!1},5:{JSON:!1},6:{Map:!1,Promise:!1,Proxy:!1,Reflect:!1,Set:!1,Symbol:!1,WeakMap:!1,WeakSet:!1}},n.browser={Audio:!1,Blob:!1,addEventListener:!1,applicationCache:!1,atob:!1,blur:!1,btoa:!1,cancelAnimationFrame:!1,CanvasGradient:!1,CanvasPattern:!1,CanvasRenderingContext2D:!1,CSS:!1,clearInterval:!1,clearTimeout:!1,close:!1,closed:!1,Comment:!1,CustomEvent:!1,DOMParser:!1,defaultStatus:!1,Document:!1,document:!1,DocumentFragment:!1,Element:!1,ElementTimeControl:!1,Event:!1,event:!1,fetch:!1,FileReader:!1,FormData:!1,focus:!1,frames:!1,getComputedStyle:!1,HTMLElement:!1,HTMLAnchorElement:!1,HTMLBaseElement:!1,HTMLBlockquoteElement:!1,HTMLBodyElement:!1,HTMLBRElement:!1,HTMLButtonElement:!1,HTMLCanvasElement:!1,HTMLCollection:!1,HTMLDirectoryElement:!1,HTMLDivElement:!1,HTMLDListElement:!1,HTMLFieldSetElement:!1,HTMLFontElement:!1,HTMLFormElement:!1,HTMLFrameElement:!1,HTMLFrameSetElement:!1,HTMLHeadElement:!1,HTMLHeadingElement:!1,HTMLHRElement:!1,HTMLHtmlElement:!1,HTMLIFrameElement:!1,HTMLImageElement:!1,HTMLInputElement:!1,HTMLIsIndexElement:!1,HTMLLabelElement:!1,HTMLLayerElement:!1,HTMLLegendElement:!1,HTMLLIElement:!1,HTMLLinkElement:!1,HTMLMapElement:!1,HTMLMenuElement:!1,HTMLMetaElement:!1,HTMLModElement:!1,HTMLObjectElement:!1,HTMLOListElement:!1,HTMLOptGroupElement:!1,HTMLOptionElement:!1,HTMLParagraphElement:!1,HTMLParamElement:!1,HTMLPreElement:!1,HTMLQuoteElement:!1,HTMLScriptElement:!1,HTMLSelectElement:!1,HTMLStyleElement:!1,HTMLTableCaptionElement:!1,HTMLTableCellElement:!1,HTMLTableColElement:!1,HTMLTableElement:!1,HTMLTableRowElement:!1,HTMLTableSectionElement:!1,HTMLTemplateElement:!1,HTMLTextAreaElement:!1,HTMLTitleElement:!1,HTMLUListElement:!1,HTMLVideoElement:!1,history:!1,Image:!1,Intl:!1,length:!1,localStorage:!1,location:!1,matchMedia:!1,MessageChannel:!1,MessageEvent:!1,MessagePort:!1,MouseEvent:!1,moveBy:!1,moveTo:!1,MutationObserver:!1,name:!1,Node:!1,NodeFilter:!1,NodeList:!1,Notification:!1,navigator:!1,onbeforeunload:!0,onblur:!0,onerror:!0,onfocus:!0,onload:!0,onresize:!0,onunload:!0,open:!1,openDatabase:!1,opener:!1,Option:!1,parent:!1,performance:!1,print:!1,Range:!1,requestAnimationFrame:!1,removeEventListener:!1,resizeBy:!1,resizeTo:!1,screen:!1,scroll:!1,scrollBy:!1,scrollTo:!1,sessionStorage:!1,setInterval:!1,setTimeout:!1,SharedWorker:!1,status:!1,SVGAElement:!1,SVGAltGlyphDefElement:!1,SVGAltGlyphElement:!1,SVGAltGlyphItemElement:!1,SVGAngle:!1,SVGAnimateColorElement:!1,SVGAnimateElement:!1,SVGAnimateMotionElement:!1,SVGAnimateTransformElement:!1,SVGAnimatedAngle:!1,SVGAnimatedBoolean:!1,SVGAnimatedEnumeration:!1,SVGAnimatedInteger:!1,SVGAnimatedLength:!1,SVGAnimatedLengthList:!1,SVGAnimatedNumber:!1,SVGAnimatedNumberList:!1,SVGAnimatedPathData:!1,SVGAnimatedPoints:!1,SVGAnimatedPreserveAspectRatio:!1,SVGAnimatedRect:!1,SVGAnimatedString:!1,SVGAnimatedTransformList:!1,SVGAnimationElement:!1,SVGCSSRule:!1,SVGCircleElement:!1,SVGClipPathElement:!1,SVGColor:!1,SVGColorProfileElement:!1,SVGColorProfileRule:!1,SVGComponentTransferFunctionElement:!1,SVGCursorElement:!1,SVGDefsElement:!1,SVGDescElement:!1,SVGDocument:!1,SVGElement:!1,SVGElementInstance:!1,SVGElementInstanceList:!1,SVGEllipseElement:!1,SVGExternalResourcesRequired:!1,SVGFEBlendElement:!1,SVGFEColorMatrixElement:!1,SVGFEComponentTransferElement:!1,SVGFECompositeElement:!1,SVGFEConvolveMatrixElement:!1,SVGFEDiffuseLightingElement:!1,SVGFEDisplacementMapElement:!1,SVGFEDistantLightElement:!1,SVGFEFloodElement:!1,SVGFEFuncAElement:!1,SVGFEFuncBElement:!1,SVGFEFuncGElement:!1,SVGFEFuncRElement:!1,SVGFEGaussianBlurElement:!1,SVGFEImageElement:!1,SVGFEMergeElement:!1,SVGFEMergeNodeElement:!1,SVGFEMorphologyElement:!1,SVGFEOffsetElement:!1,SVGFEPointLightElement:!1,SVGFESpecularLightingElement:!1,SVGFESpotLightElement:!1,SVGFETileElement:!1,SVGFETurbulenceElement:!1,SVGFilterElement:!1,SVGFilterPrimitiveStandardAttributes:!1,SVGFitToViewBox:!1,SVGFontElement:!1,SVGFontFaceElement:!1,SVGFontFaceFormatElement:!1,SVGFontFaceNameElement:!1,SVGFontFaceSrcElement:!1,SVGFontFaceUriElement:!1,SVGForeignObjectElement:!1,SVGGElement:!1,SVGGlyphElement:!1,SVGGlyphRefElement:!1,SVGGradientElement:!1,SVGHKernElement:!1,SVGICCColor:!1,SVGImageElement:!1,SVGLangSpace:!1,SVGLength:!1,SVGLengthList:!1,SVGLineElement:!1,SVGLinearGradientElement:!1,SVGLocatable:!1,SVGMPathElement:!1,SVGMarkerElement:!1,SVGMaskElement:!1,SVGMatrix:!1,SVGMetadataElement:!1,SVGMissingGlyphElement:!1,SVGNumber:!1,SVGNumberList:!1,SVGPaint:!1,SVGPathElement:!1,SVGPathSeg:!1,SVGPathSegArcAbs:!1,SVGPathSegArcRel:!1,SVGPathSegClosePath:!1,SVGPathSegCurvetoCubicAbs:!1,SVGPathSegCurvetoCubicRel:!1,SVGPathSegCurvetoCubicSmoothAbs:!1,SVGPathSegCurvetoCubicSmoothRel:!1,SVGPathSegCurvetoQuadraticAbs:!1,SVGPathSegCurvetoQuadraticRel:!1,SVGPathSegCurvetoQuadraticSmoothAbs:!1,SVGPathSegCurvetoQuadraticSmoothRel:!1,SVGPathSegLinetoAbs:!1,SVGPathSegLinetoHorizontalAbs:!1,SVGPathSegLinetoHorizontalRel:!1,SVGPathSegLinetoRel:!1,SVGPathSegLinetoVerticalAbs:!1,SVGPathSegLinetoVerticalRel:!1,SVGPathSegList:!1,SVGPathSegMovetoAbs:!1,SVGPathSegMovetoRel:!1,SVGPatternElement:!1,SVGPoint:!1,SVGPointList:!1,SVGPolygonElement:!1,SVGPolylineElement:!1,SVGPreserveAspectRatio:!1,SVGRadialGradientElement:!1,SVGRect:!1,SVGRectElement:!1,SVGRenderingIntent:!1,SVGSVGElement:!1,SVGScriptElement:!1,SVGSetElement:!1,SVGStopElement:!1,SVGStringList:!1,SVGStylable:!1,SVGStyleElement:!1,SVGSwitchElement:!1,SVGSymbolElement:!1,SVGTRefElement:!1,SVGTSpanElement:!1,SVGTests:!1,SVGTextContentElement:!1,SVGTextElement:!1,SVGTextPathElement:!1,SVGTextPositioningElement:!1,SVGTitleElement:!1,SVGTransform:!1,SVGTransformList:!1,SVGTransformable:!1,SVGURIReference:!1,SVGUnitTypes:!1,SVGUseElement:!1,SVGVKernElement:!1,SVGViewElement:!1,SVGViewSpec:!1,SVGZoomAndPan:!1,Text:!1,TextDecoder:!1,TextEncoder:!1,TimeEvent:!1,top:!1,URL:!1,WebGLActiveInfo:!1,WebGLBuffer:!1,WebGLContextEvent:!1,WebGLFramebuffer:!1,WebGLProgram:!1,WebGLRenderbuffer:!1,WebGLRenderingContext:!1,WebGLShader:!1,WebGLShaderPrecisionFormat:!1,WebGLTexture:!1,WebGLUniformLocation:!1,WebSocket:!1,window:!1,Window:!1,Worker:!1,XDomainRequest:!1,XMLHttpRequest:!1,XMLSerializer:!1,XPathEvaluator:!1,XPathException:!1,XPathExpression:!1,XPathNamespace:!1,XPathNSResolver:!1,XPathResult:!1},n.devel={alert:!1,confirm:!1,console:!1,Debug:!1,opera:!1,prompt:!1},n.worker={importScripts:!0,postMessage:!0,self:!0,FileReaderSync:!0},n.nonstandard={escape:!1,unescape:!1},n.couch={require:!1,respond:!1,getRow:!1,emit:!1,send:!1,start:!1,sum:!1,log:!1,exports:!1,module:!1,provides:!1},n.node={__filename:!1,__dirname:!1,GLOBAL:!1,global:!1,module:!1,require:!1,Buffer:!0,console:!0,exports:!0,process:!0,setTimeout:!0,clearTimeout:!0,setInterval:!0,clearInterval:!0,setImmediate:!0,clearImmediate:!0},n.browserify={__filename:!1,__dirname:!1,global:!1,module:!1,require:!1,Buffer:!0,exports:!0,process:!0},n.phantom={phantom:!0,require:!0,WebPage:!0,console:!0,exports:!0},n.qunit={asyncTest:!1,deepEqual:!1,equal:!1,expect:!1,module:!1,notDeepEqual:!1,notEqual:!1,notPropEqual:!1,notStrictEqual:!1,ok:!1,propEqual:!1,QUnit:!1,raises:!1,start:!1,stop:!1,strictEqual:!1,test:!1,"throws":!1},n.rhino={defineClass:!1,deserialize:!1,gc:!1,help:!1,importClass:!1,importPackage:!1,java:!1,load:!1,loadClass:!1,Packages:!1,print:!1,quit:!1,readFile:!1,readUrl:!1,runCommand:!1,seal:!1,serialize:!1,spawn:!1,sync:!1,toint32:!1,version:!1},n.shelljs={target:!1,echo:!1,exit:!1,cd:!1,pwd:!1,ls:!1,find:!1,cp:!1,rm:!1,mv:!1,mkdir:!1,test:!1,cat:!1,sed:!1,grep:!1,which:!1,dirs:!1,pushd:!1,popd:!1,env:!1,exec:!1,chmod:!1,config:!1,error:!1,tempdir:!1},n.typed={ArrayBuffer:!1,ArrayBufferView:!1,DataView:!1,Float32Array:!1,Float64Array:!1,Int16Array:!1,Int32Array:!1,Int8Array:!1,Uint16Array:!1,Uint32Array:!1,Uint8Array:!1,Uint8ClampedArray:!1},n.wsh={ActiveXObject:!0,Enumerator:!0,GetObject:!0,ScriptEngine:!0,ScriptEngineBuildVersion:!0,ScriptEngineMajorVersion:!0,ScriptEngineMinorVersion:!0,VBArray:!0,WSH:!0,WScript:!0,XDomainRequest:!0},n.dojo={dojo:!1,dijit:!1,dojox:!1,define:!1,require:!1},n.jquery={$:!1,jQuery:!1},n.mootools={$:!1,$$:!1,Asset:!1,Browser:!1,Chain:!1,Class:!1,Color:!1,Cookie:!1,Core:!1,Document:!1,DomReady:!1,DOMEvent:!1,DOMReady:!1,Drag:!1,Element:!1,Elements:!1,Event:!1,Events:!1,Fx:!1,Group:!1,Hash:!1,HtmlTable:!1,IFrame:!1,IframeShim:!1,InputValidator:!1,instanceOf:!1,Keyboard:!1,Locale:!1,Mask:!1,MooTools:!1,Native:!1,Options:!1,OverText:!1,Request:!1,Scroller:!1,Slick:!1,Slider:!1,Sortables:!1,Spinner:!1,Swiff:!1,Tips:!1,Type:!1,typeOf:!1,URI:!1,Window:!1},n.prototypejs={$:!1,$$:!1,$A:!1,$F:!1,$H:!1,$R:!1,$break:!1,$continue:!1,$w:!1,Abstract:!1,Ajax:!1,Class:!1,Enumerable:!1,Element:!1,Event:!1,Field:!1,Form:!1,Hash:!1,Insertion:!1,ObjectRange:!1,PeriodicalExecuter:!1,Position:!1,Prototype:!1,Selector:!1,Template:!1,Toggle:!1,Try:!1,Autocompleter:!1,Builder:!1,Control:!1,Draggable:!1,Draggables:!1,Droppables:!1,Effect:!1,Sortable:!1,SortableObserver:!1,Sound:!1,Scriptaculous:!1},n.yui={YUI:!1,Y:!1,YUI_config:!1},n.mocha={mocha:!1,describe:!1,xdescribe:!1,it:!1,xit:!1,context:!1,xcontext:!1,before:!1,after:!1,beforeEach:!1,afterEach:!1,suite:!1,test:!1,setup:!1,teardown:!1,suiteSetup:!1,suiteTeardown:!1},n.jasmine={jasmine:!1,describe:!1,xdescribe:!1,it:!1,xit:!1,beforeEach:!1,afterEach:!1,setFixtures:!1,loadFixtures:!1,spyOn:!1,expect:!1,runs:!1,waitsFor:!1,waits:!1,beforeAll:!1,afterAll:!1,fail:!1,fdescribe:!1,fit:!1,pending:!1}},{}]},{},["/node_modules/jshint/src/jshint.js"])}),ace.define("ace/mode/javascript_worker",["require","exports","module","ace/lib/oop","ace/worker/mirror","ace/mode/javascript/jshint"],function(require,exports,module){"use strict";function startRegex(e){return RegExp("^("+e.join("|")+")")}var oop=require("../lib/oop"),Mirror=require("../worker/mirror").Mirror,lint=require("./javascript/jshint").JSHINT,disabledWarningsRe=startRegex(["Bad for in variable '(.+)'.",'Missing "use strict"']),errorsRe=startRegex(["Unexpected","Expected ","Confusing (plus|minus)","\\{a\\} unterminated regular expression","Unclosed ","Unmatched ","Unbegun comment","Bad invocation","Missing space after","Missing operator at"]),infoRe=startRegex(["Expected an assignment","Bad escapement of EOL","Unexpected comma","Unexpected space","Missing radix parameter.","A leading decimal point can","\\['{a}'\\] is better written in dot notation.","'{a}' used out of scope"]),JavaScriptWorker=exports.JavaScriptWorker=function(e){Mirror.call(this,e),this.setTimeout(500),this.setOptions()};oop.inherits(JavaScriptWorker,Mirror),function(){this.setOptions=function(e){this.options=e||{esnext:!0,moz:!0,devel:!0,browser:!0,node:!0,laxcomma:!0,laxbreak:!0,lastsemic:!0,onevar:!1,passfail:!1,maxerr:100,expr:!0,multistr:!0,globalstrict:!0},this.doc.getValue()&&this.deferredUpdate.schedule(100)},this.changeOptions=function(e){oop.mixin(this.options,e),this.doc.getValue()&&this.deferredUpdate.schedule(100)},this.isValidJS=function(str){try{eval("throw 0;"+str)}catch(e){if(e===0)return!0}return!1},this.onUpdate=function(){var e=this.doc.getValue();e=e.replace(/^#!.*\n/,"\n");if(!e)return this.sender.emit("annotate",[]);var t=[],n=this.isValidJS(e)?"warning":"error";lint(e,this.options);var r=lint.errors,i=!1;for(var s=0;s0||-1)*Math.floor(Math.abs(e))),e}function B(e){var t=typeof e;return e===null||t==="undefined"||t==="boolean"||t==="number"||t==="string"}function j(e){var t,n,r;if(B(e))return e;n=e.valueOf;if(typeof n=="function"){t=n.call(e);if(B(t))return t}r=e.toString;if(typeof r=="function"){t=r.call(e);if(B(t))return t}throw new TypeError}Function.prototype.bind||(Function.prototype.bind=function(t){var n=this;if(typeof n!="function")throw new TypeError("Function.prototype.bind called on incompatible "+n);var i=u.call(arguments,1),s=function(){if(this instanceof s){var e=n.apply(this,i.concat(u.call(arguments)));return Object(e)===e?e:this}return n.apply(t,i.concat(u.call(arguments)))};return n.prototype&&(r.prototype=n.prototype,s.prototype=new r,r.prototype=null),s});var i=Function.prototype.call,s=Array.prototype,o=Object.prototype,u=s.slice,a=i.bind(o.toString),f=i.bind(o.hasOwnProperty),l,c,h,p,d;if(d=f(o,"__defineGetter__"))l=i.bind(o.__defineGetter__),c=i.bind(o.__defineSetter__),h=i.bind(o.__lookupGetter__),p=i.bind(o.__lookupSetter__);if([1,2].splice(0).length!=2)if(!function(){function e(e){var t=new Array(e+2);return t[0]=t[1]=0,t}var t=[],n;t.splice.apply(t,e(20)),t.splice.apply(t,e(26)),n=t.length,t.splice(5,0,"XXX"),n+1==t.length;if(n+1==t.length)return!0}())Array.prototype.splice=function(e,t){var n=this.length;e>0?e>n&&(e=n):e==void 0?e=0:e<0&&(e=Math.max(n+e,0)),e+ta)for(h=l;h--;)this[f+h]=this[a+h];if(s&&e===c)this.length=c,this.push.apply(this,i);else{this.length=c+s;for(h=0;h>>0;if(a(t)!="[object Function]")throw new TypeError;while(++s>>0,s=Array(i),o=arguments[1];if(a(t)!="[object Function]")throw new TypeError(t+" is not a function");for(var u=0;u>>0,s=[],o,u=arguments[1];if(a(t)!="[object Function]")throw new TypeError(t+" is not a function");for(var f=0;f>>0,s=arguments[1];if(a(t)!="[object Function]")throw new TypeError(t+" is not a function");for(var o=0;o>>0,s=arguments[1];if(a(t)!="[object Function]")throw new TypeError(t+" is not a function");for(var o=0;o>>0;if(a(t)!="[object Function]")throw new TypeError(t+" is not a function");if(!i&&arguments.length==1)throw new TypeError("reduce of empty array with no initial value");var s=0,o;if(arguments.length>=2)o=arguments[1];else do{if(s in r){o=r[s++];break}if(++s>=i)throw new TypeError("reduce of empty array with no initial value")}while(!0);for(;s>>0;if(a(t)!="[object Function]")throw new TypeError(t+" is not a function");if(!i&&arguments.length==1)throw new TypeError("reduceRight of empty array with no initial value");var s,o=i-1;if(arguments.length>=2)s=arguments[1];else do{if(o in r){s=r[o--];break}if(--o<0)throw new TypeError("reduceRight of empty array with no initial value")}while(!0);do o in this&&(s=t.call(void 0,s,r[o],o,n));while(o--);return s});if(!Array.prototype.indexOf||[0,1].indexOf(1,2)!=-1)Array.prototype.indexOf=function(t){var n=g&&a(this)=="[object String]"?this.split(""):F(this),r=n.length>>>0;if(!r)return-1;var i=0;arguments.length>1&&(i=H(arguments[1])),i=i>=0?i:Math.max(0,r+i);for(;i>>0;if(!r)return-1;var i=r-1;arguments.length>1&&(i=Math.min(i,H(arguments[1]))),i=i>=0?i:r-Math.abs(i);for(;i>=0;i--)if(i in n&&t===n[i])return i;return-1};Object.getPrototypeOf||(Object.getPrototypeOf=function(t){return t.__proto__||(t.constructor?t.constructor.prototype:o)});if(!Object.getOwnPropertyDescriptor){var y="Object.getOwnPropertyDescriptor called on a non-object: ";Object.getOwnPropertyDescriptor=function(t,n){if(typeof t!="object"&&typeof t!="function"||t===null)throw new TypeError(y+t);if(!f(t,n))return;var r,i,s;r={enumerable:!0,configurable:!0};if(d){var u=t.__proto__;t.__proto__=o;var i=h(t,n),s=p(t,n);t.__proto__=u;if(i||s)return i&&(r.get=i),s&&(r.set=s),r}return r.value=t[n],r}}Object.getOwnPropertyNames||(Object.getOwnPropertyNames=function(t){return Object.keys(t)});if(!Object.create){var b;Object.prototype.__proto__===null?b=function(){return{__proto__:null}}:b=function(){var e={};for(var t in e)e[t]=null;return e.constructor=e.hasOwnProperty=e.propertyIsEnumerable=e.isPrototypeOf=e.toLocaleString=e.toString=e.valueOf=e.__proto__=null,e},Object.create=function(t,n){var r;if(t===null)r=b();else{if(typeof t!="object")throw new TypeError("typeof prototype["+typeof t+"] != 'object'");var i=function(){};i.prototype=t,r=new i,r.__proto__=t}return n!==void 0&&Object.defineProperties(r,n),r}}if(Object.defineProperty){var E=w({}),S=typeof document=="undefined"||w(document.createElement("div"));if(!E||!S)var x=Object.defineProperty}if(!Object.defineProperty||x){var T="Property description must be an object: ",N="Object.defineProperty called on non-object: ",C="getters & setters can not be defined on this javascript engine";Object.defineProperty=function(t,n,r){if(typeof t!="object"&&typeof t!="function"||t===null)throw new TypeError(N+t);if(typeof r!="object"&&typeof r!="function"||r===null)throw new TypeError(T+r);if(x)try{return x.call(Object,t,n,r)}catch(i){}if(f(r,"value"))if(d&&(h(t,n)||p(t,n))){var s=t.__proto__;t.__proto__=o,delete t[n],t[n]=r.value,t.__proto__=s}else t[n]=r.value;else{if(!d)throw new TypeError(C);f(r,"get")&&l(t,n,r.get),f(r,"set")&&c(t,n,r.set)}return t}}Object.defineProperties||(Object.defineProperties=function(t,n){for(var r in n)f(n,r)&&Object.defineProperty(t,r,n[r]);return t}),Object.seal||(Object.seal=function(t){return t}),Object.freeze||(Object.freeze=function(t){return t});try{Object.freeze(function(){})}catch(k){Object.freeze=function(t){return function(n){return typeof n=="function"?n:t(n)}}(Object.freeze)}Object.preventExtensions||(Object.preventExtensions=function(t){return t}),Object.isSealed||(Object.isSealed=function(t){return!1}),Object.isFrozen||(Object.isFrozen=function(t){return!1}),Object.isExtensible||(Object.isExtensible=function(t){if(Object(t)===t)throw new TypeError;var n="";while(f(t,n))n+="?";t[n]=!0;var r=f(t,n);return delete t[n],r});if(!Object.keys){var L=!0,A=["toString","toLocaleString","valueOf","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","constructor"],O=A.length;for(var M in{toString:null})L=!1;Object.keys=function I(e){if(typeof e!="object"&&typeof e!="function"||e===null)throw new TypeError("Object.keys called on a non-object");var I=[];for(var t in e)f(e,t)&&I.push(t);if(L)for(var n=0,r=O;n ["+this.end.row+"/"+this.end.column+"]"},this.contains=function(e,t){return this.compare(e,t)==0},this.compareRange=function(e){var t,n=e.end,r=e.start;return t=this.compare(n.row,n.column),t==1?(t=this.compare(r.row,r.column),t==1?2:t==0?1:0):t==-1?-2:(t=this.compare(r.row,r.column),t==-1?-1:t==1?42:0)},this.comparePoint=function(e){return this.compare(e.row,e.column)},this.containsRange=function(e){return this.comparePoint(e.start)==0&&this.comparePoint(e.end)==0},this.intersects=function(e){var t=this.compareRange(e);return t==-1||t==0||t==1},this.isEnd=function(e,t){return this.end.row==e&&this.end.column==t},this.isStart=function(e,t){return this.start.row==e&&this.start.column==t},this.setStart=function(e,t){typeof e=="object"?(this.start.column=e.column,this.start.row=e.row):(this.start.row=e,this.start.column=t)},this.setEnd=function(e,t){typeof e=="object"?(this.end.column=e.column,this.end.row=e.row):(this.end.row=e,this.end.column=t)},this.inside=function(e,t){return this.compare(e,t)==0?this.isEnd(e,t)||this.isStart(e,t)?!1:!0:!1},this.insideStart=function(e,t){return this.compare(e,t)==0?this.isEnd(e,t)?!1:!0:!1},this.insideEnd=function(e,t){return this.compare(e,t)==0?this.isStart(e,t)?!1:!0:!1},this.compare=function(e,t){return!this.isMultiLine()&&e===this.start.row?tthis.end.column?1:0:ethis.end.row?1:this.start.row===e?t>=this.start.column?0:-1:this.end.row===e?t<=this.end.column?0:1:0},this.compareStart=function(e,t){return this.start.row==e&&this.start.column==t?-1:this.compare(e,t)},this.compareEnd=function(e,t){return this.end.row==e&&this.end.column==t?1:this.compare(e,t)},this.compareInside=function(e,t){return this.end.row==e&&this.end.column==t?1:this.start.row==e&&this.start.column==t?-1:this.compare(e,t)},this.clipRows=function(e,t){if(this.end.row>t)var n={row:t+1,column:0};else if(this.end.rowt)var r={row:t+1,column:0};else if(this.start.row=0&&t.row=0&&t.column<=e[t.row].length}function s(e,t){t.action!="insert"&&t.action!="remove"&&r(t,"delta.action must be 'insert' or 'remove'"),t.lines instanceof Array||r(t,"delta.lines must be an Array"),(!t.start||!t.end)&&r(t,"delta.start/end must be an present");var n=t.start;i(e,t.start)||r(t,"delta.start must be contained in document");var s=t.end;t.action=="remove"&&!i(e,s)&&r(t,"delta.end must contained in document for 'remove' actions");var o=s.row-n.row,u=s.column-(o==0?n.column:0);(o!=t.lines.length-1||t.lines[o].length!=u)&&r(t,"delta.range must match delta lines")}t.applyDelta=function(e,t,n){var r=t.start.row,i=t.start.column,s=e[r]||"";switch(t.action){case"insert":var o=t.lines;if(o.length===1)e[r]=s.substring(0,i)+t.lines[0]+s.substring(i);else{var u=[r,1].concat(t.lines);e.splice.apply(e,u),e[r]=s.substring(0,i)+e[r],e[r+t.lines.length-1]+=s.substring(i)}break;case"remove":var a=t.end.column,f=t.end.row;r===f?e[r]=s.substring(0,i)+s.substring(a):e.splice(r,f-r+1,s.substring(0,i)+e[f].substring(a))}}}),ace.define("ace/lib/event_emitter",["require","exports","module"],function(e,t,n){"use strict";var r={},i=function(){this.propagationStopped=!0},s=function(){this.defaultPrevented=!0};r._emit=r._dispatchEvent=function(e,t){this._eventRegistry||(this._eventRegistry={}),this._defaultHandlers||(this._defaultHandlers={});var n=this._eventRegistry[e]||[],r=this._defaultHandlers[e];if(!n.length&&!r)return;if(typeof t!="object"||!t)t={};t.type||(t.type=e),t.stopPropagation||(t.stopPropagation=i),t.preventDefault||(t.preventDefault=s),n=n.slice();for(var o=0;othis.row)return;var n=t(e,{row:this.row,column:this.column},this.$insertRight);this.setPosition(n.row,n.column,!0)},this.setPosition=function(e,t,n){var r;n?r={row:e,column:t}:r=this.$clipPositionToDocument(e,t);if(this.row==r.row&&this.column==r.column)return;var i={row:this.row,column:this.column};this.row=r.row,this.column=r.column,this._signal("change",{old:i,value:r})},this.detach=function(){this.document.removeEventListener("change",this.$onChange)},this.attach=function(e){this.document=e||this.document,this.document.on("change",this.$onChange)},this.$clipPositionToDocument=function(e,t){var n={};return e>=this.document.getLength()?(n.row=Math.max(0,this.document.getLength()-1),n.column=this.document.getLine(n.row).length):e<0?(n.row=0,n.column=0):(n.row=e,n.column=Math.min(this.document.getLine(n.row).length,Math.max(0,t))),t<0&&(n.column=0),n}}).call(s.prototype)}),ace.define("ace/document",["require","exports","module","ace/lib/oop","ace/apply_delta","ace/lib/event_emitter","ace/range","ace/anchor"],function(e,t,n){"use strict";var r=e("./lib/oop"),i=e("./apply_delta").applyDelta,s=e("./lib/event_emitter").EventEmitter,o=e("./range").Range,u=e("./anchor").Anchor,a=function(e){this.$lines=[""],e.length===0?this.$lines=[""]:Array.isArray(e)?this.insertMergedLines({row:0,column:0},e):this.insert({row:0,column:0},e)};(function(){r.implement(this,s),this.setValue=function(e){var t=this.getLength()-1;this.remove(new o(0,0,t,this.getLine(t).length)),this.insert({row:0,column:0},e)},this.getValue=function(){return this.getAllLines().join(this.getNewLineCharacter())},this.createAnchor=function(e,t){return new u(this,e,t)},"aaa".split(/a/).length===0?this.$split=function(e){return e.replace(/\r\n|\r/g,"\n").split("\n")}:this.$split=function(e){return e.split(/\r\n|\r|\n/)},this.$detectNewLine=function(e){var t=e.match(/^.*?(\r\n|\r|\n)/m);this.$autoNewLine=t?t[1]:"\n",this._signal("changeNewLineMode")},this.getNewLineCharacter=function(){switch(this.$newLineMode){case"windows":return"\r\n";case"unix":return"\n";default:return this.$autoNewLine||"\n"}},this.$autoNewLine="",this.$newLineMode="auto",this.setNewLineMode=function(e){if(this.$newLineMode===e)return;this.$newLineMode=e,this._signal("changeNewLineMode")},this.getNewLineMode=function(){return this.$newLineMode},this.isNewLine=function(e){return e=="\r\n"||e=="\r"||e=="\n"},this.getLine=function(e){return this.$lines[e]||""},this.getLines=function(e,t){return this.$lines.slice(e,t+1)},this.getAllLines=function(){return this.getLines(0,this.getLength())},this.getLength=function(){return this.$lines.length},this.getTextRange=function(e){return this.getLinesForRange(e).join(this.getNewLineCharacter())},this.getLinesForRange=function(e){var t;if(e.start.row===e.end.row)t=[this.getLine(e.start.row).substring(e.start.column,e.end.column)];else{t=this.getLines(e.start.row,e.end.row),t[0]=(t[0]||"").substring(e.start.column);var n=t.length-1;e.end.row-e.start.row==n&&(t[n]=t[n].substring(0,e.end.column))}return t},this.insertLines=function(e,t){return console.warn("Use of document.insertLines is deprecated. Use the insertFullLines method instead."),this.insertFullLines(e,t)},this.removeLines=function(e,t){return console.warn("Use of document.removeLines is deprecated. Use the removeFullLines method instead."),this.removeFullLines(e,t)},this.insertNewLine=function(e){return console.warn("Use of document.insertNewLine is deprecated. Use insertMergedLines(position, ['', '']) instead."),this.insertMergedLines(e,["",""])},this.insert=function(e,t){return this.getLength()<=1&&this.$detectNewLine(t),this.insertMergedLines(e,this.$split(t))},this.insertInLine=function(e,t){var n=this.clippedPos(e.row,e.column),r=this.pos(e.row,e.column+t.length);return this.applyDelta({start:n,end:r,action:"insert",lines:[t]},!0),this.clonePos(r)},this.clippedPos=function(e,t){var n=this.getLength();e===undefined?e=n:e<0?e=0:e>=n&&(e=n-1,t=undefined);var r=this.getLine(e);return t==undefined&&(t=r.length),t=Math.min(Math.max(t,0),r.length),{row:e,column:t}},this.clonePos=function(e){return{row:e.row,column:e.column}},this.pos=function(e,t){return{row:e,column:t}},this.$clipPosition=function(e){var t=this.getLength();return e.row>=t?(e.row=Math.max(0,t-1),e.column=this.getLine(t-1).length):(e.row=Math.max(0,e.row),e.column=Math.min(Math.max(e.column,0),this.getLine(e.row).length)),e},this.insertFullLines=function(e,t){e=Math.min(Math.max(e,0),this.getLength());var n=0;e0,r=t=0&&this.applyDelta({start:this.pos(e,this.getLine(e).length),end:this.pos(e+1,0),action:"remove",lines:["",""]})},this.replace=function(e,t){e instanceof o||(e=o.fromPoints(e.start,e.end));if(t.length===0&&e.isEmpty())return e.start;if(t==this.getTextRange(e))return e.end;this.remove(e);var n;return t?n=this.insert(e.start,t):n=e.start,n},this.applyDeltas=function(e){for(var t=0;t=0;t--)this.revertDelta(e[t])},this.applyDelta=function(e,t){var n=e.action=="insert";if(n?e.lines.length<=1&&!e.lines[0]:!o.comparePoints(e.start,e.end))return;n&&e.lines.length>2e4&&this.$splitAndapplyLargeDelta(e,2e4),i(this.$lines,e,t),this._signal("change",e)},this.$splitAndapplyLargeDelta=function(e,t){var n=e.lines,r=n.length,i=e.start.row,s=e.start.column,o=0,u=0;do{o=u,u+=t-1;var a=n.slice(o,u);if(u>r){e.lines=a,e.start.row=i+o,e.start.column=s;break}a.push(""),this.applyDelta({start:this.pos(i+o,s),end:this.pos(i+u,s=0),action:e.action,lines:a},!0)}while(!0)},this.revertDelta=function(e){this.applyDelta({start:this.clonePos(e.start),end:this.clonePos(e.end),action:e.action=="insert"?"remove":"insert",lines:e.lines.slice()})},this.indexToPosition=function(e,t){var n=this.$lines||this.getAllLines(),r=this.getNewLineCharacter().length;for(var i=t||0,s=n.length;i0){t&1&&(n+=e);if(t>>=1)e+=e}return n};var r=/^\s\s*/,i=/\s\s*$/;t.stringTrimLeft=function(e){return e.replace(r,"")},t.stringTrimRight=function(e){return e.replace(i,"")},t.copyObject=function(e){var t={};for(var n in e)t[n]=e[n];return t},t.copyArray=function(e){var t=[];for(var n=0,r=e.length;n="0"&&i<="9")t+=i,a();if(i==="."){t+=".";while(a()&&i>="0"&&i<="9")t+=i}if(i==="e"||i==="E"){t+=i,a();if(i==="-"||i==="+")t+=i,a();while(i>="0"&&i<="9")t+=i,a()}e=+t;if(!isNaN(e))return e;u("Bad number")},l=function(){var e,t,n="",r;if(i==='"')while(a()){if(i==='"')return a(),n;if(i==="\\"){a();if(i==="u"){r=0;for(t=0;t<4;t+=1){e=parseInt(a(),16);if(!isFinite(e))break;r=r*16+e}n+=String.fromCharCode(r)}else{if(typeof s[i]!="string")break;n+=s[i]}}else n+=i}u("Bad string")},c=function(){while(i&&i<=" ")a()},h=function(){switch(i){case"t":return a("t"),a("r"),a("u"),a("e"),!0;case"f":return a("f"),a("a"),a("l"),a("s"),a("e"),!1;case"n":return a("n"),a("u"),a("l"),a("l"),null}u("Unexpected '"+i+"'")},p,d=function(){var e=[];if(i==="["){a("["),c();if(i==="]")return a("]"),e;while(i){e.push(p()),c();if(i==="]")return a("]"),e;a(","),c()}}u("Bad array")},v=function(){var e,t={};if(i==="{"){a("{"),c();if(i==="}")return a("}"),t;while(i){e=l(),c(),a(":"),Object.hasOwnProperty.call(t,e)&&u('Duplicate key "'+e+'"'),t[e]=p(),c();if(i==="}")return a("}"),t;a(","),c()}}u("Bad object")};return p=function(){c();switch(i){case"{":return v();case"[":return d();case'"':return l();case"-":return f();default:return i>="0"&&i<="9"?f():h()}},function(e,t){var n;return o=e,r=0,i=" ",n=p(),c(),i&&u("Syntax error"),typeof t=="function"?function s(e,n){var r,i,o=e[n];if(o&&typeof o=="object")for(r in o)Object.hasOwnProperty.call(o,r)&&(i=s(o,r),i!==undefined?o[r]=i:delete o[r]);return t.call(e,n,o)}({"":n},""):n}}),ace.define("ace/mode/json_worker",["require","exports","module","ace/lib/oop","ace/worker/mirror","ace/mode/json/json_parse"],function(e,t,n){"use strict";var r=e("../lib/oop"),i=e("../worker/mirror").Mirror,s=e("./json/json_parse"),o=t.JsonWorker=function(e){i.call(this,e),this.setTimeout(200)};r.inherits(o,i),function(){this.onUpdate=function(){var e=this.doc.getValue(),t=[];try{e&&s(e)}catch(n){var r=this.doc.indexToPosition(n.at-1);t.push({row:r.row,column:r.column,text:n.message,type:"error"})}this.sender.emit("annotate",t)}}.call(o.prototype)}),ace.define("ace/lib/es5-shim",["require","exports","module"],function(e,t,n){function r(){}function w(e){try{return Object.defineProperty(e,"sentinel",{}),"sentinel"in e}catch(t){}}function H(e){return e=+e,e!==e?e=0:e!==0&&e!==1/0&&e!==-1/0&&(e=(e>0||-1)*Math.floor(Math.abs(e))),e}function B(e){var t=typeof e;return e===null||t==="undefined"||t==="boolean"||t==="number"||t==="string"}function j(e){var t,n,r;if(B(e))return e;n=e.valueOf;if(typeof n=="function"){t=n.call(e);if(B(t))return t}r=e.toString;if(typeof r=="function"){t=r.call(e);if(B(t))return t}throw new TypeError}Function.prototype.bind||(Function.prototype.bind=function(t){var n=this;if(typeof n!="function")throw new TypeError("Function.prototype.bind called on incompatible "+n);var i=u.call(arguments,1),s=function(){if(this instanceof s){var e=n.apply(this,i.concat(u.call(arguments)));return Object(e)===e?e:this}return n.apply(t,i.concat(u.call(arguments)))};return n.prototype&&(r.prototype=n.prototype,s.prototype=new r,r.prototype=null),s});var i=Function.prototype.call,s=Array.prototype,o=Object.prototype,u=s.slice,a=i.bind(o.toString),f=i.bind(o.hasOwnProperty),l,c,h,p,d;if(d=f(o,"__defineGetter__"))l=i.bind(o.__defineGetter__),c=i.bind(o.__defineSetter__),h=i.bind(o.__lookupGetter__),p=i.bind(o.__lookupSetter__);if([1,2].splice(0).length!=2)if(!function(){function e(e){var t=new Array(e+2);return t[0]=t[1]=0,t}var t=[],n;t.splice.apply(t,e(20)),t.splice.apply(t,e(26)),n=t.length,t.splice(5,0,"XXX"),n+1==t.length;if(n+1==t.length)return!0}())Array.prototype.splice=function(e,t){var n=this.length;e>0?e>n&&(e=n):e==void 0?e=0:e<0&&(e=Math.max(n+e,0)),e+ta)for(h=l;h--;)this[f+h]=this[a+h];if(s&&e===c)this.length=c,this.push.apply(this,i);else{this.length=c+s;for(h=0;h>>0;if(a(t)!="[object Function]")throw new TypeError;while(++s>>0,s=Array(i),o=arguments[1];if(a(t)!="[object Function]")throw new TypeError(t+" is not a function");for(var u=0;u>>0,s=[],o,u=arguments[1];if(a(t)!="[object Function]")throw new TypeError(t+" is not a function");for(var f=0;f>>0,s=arguments[1];if(a(t)!="[object Function]")throw new TypeError(t+" is not a function");for(var o=0;o>>0,s=arguments[1];if(a(t)!="[object Function]")throw new TypeError(t+" is not a function");for(var o=0;o>>0;if(a(t)!="[object Function]")throw new TypeError(t+" is not a function");if(!i&&arguments.length==1)throw new TypeError("reduce of empty array with no initial value");var s=0,o;if(arguments.length>=2)o=arguments[1];else do{if(s in r){o=r[s++];break}if(++s>=i)throw new TypeError("reduce of empty array with no initial value")}while(!0);for(;s>>0;if(a(t)!="[object Function]")throw new TypeError(t+" is not a function");if(!i&&arguments.length==1)throw new TypeError("reduceRight of empty array with no initial value");var s,o=i-1;if(arguments.length>=2)s=arguments[1];else do{if(o in r){s=r[o--];break}if(--o<0)throw new TypeError("reduceRight of empty array with no initial value")}while(!0);do o in this&&(s=t.call(void 0,s,r[o],o,n));while(o--);return s});if(!Array.prototype.indexOf||[0,1].indexOf(1,2)!=-1)Array.prototype.indexOf=function(t){var n=g&&a(this)=="[object String]"?this.split(""):F(this),r=n.length>>>0;if(!r)return-1;var i=0;arguments.length>1&&(i=H(arguments[1])),i=i>=0?i:Math.max(0,r+i);for(;i>>0;if(!r)return-1;var i=r-1;arguments.length>1&&(i=Math.min(i,H(arguments[1]))),i=i>=0?i:r-Math.abs(i);for(;i>=0;i--)if(i in n&&t===n[i])return i;return-1};Object.getPrototypeOf||(Object.getPrototypeOf=function(t){return t.__proto__||(t.constructor?t.constructor.prototype:o)});if(!Object.getOwnPropertyDescriptor){var y="Object.getOwnPropertyDescriptor called on a non-object: ";Object.getOwnPropertyDescriptor=function(t,n){if(typeof t!="object"&&typeof t!="function"||t===null)throw new TypeError(y+t);if(!f(t,n))return;var r,i,s;r={enumerable:!0,configurable:!0};if(d){var u=t.__proto__;t.__proto__=o;var i=h(t,n),s=p(t,n);t.__proto__=u;if(i||s)return i&&(r.get=i),s&&(r.set=s),r}return r.value=t[n],r}}Object.getOwnPropertyNames||(Object.getOwnPropertyNames=function(t){return Object.keys(t)});if(!Object.create){var b;Object.prototype.__proto__===null?b=function(){return{__proto__:null}}:b=function(){var e={};for(var t in e)e[t]=null;return e.constructor=e.hasOwnProperty=e.propertyIsEnumerable=e.isPrototypeOf=e.toLocaleString=e.toString=e.valueOf=e.__proto__=null,e},Object.create=function(t,n){var r;if(t===null)r=b();else{if(typeof t!="object")throw new TypeError("typeof prototype["+typeof t+"] != 'object'");var i=function(){};i.prototype=t,r=new i,r.__proto__=t}return n!==void 0&&Object.defineProperties(r,n),r}}if(Object.defineProperty){var E=w({}),S=typeof document=="undefined"||w(document.createElement("div"));if(!E||!S)var x=Object.defineProperty}if(!Object.defineProperty||x){var T="Property description must be an object: ",N="Object.defineProperty called on non-object: ",C="getters & setters can not be defined on this javascript engine";Object.defineProperty=function(t,n,r){if(typeof t!="object"&&typeof t!="function"||t===null)throw new TypeError(N+t);if(typeof r!="object"&&typeof r!="function"||r===null)throw new TypeError(T+r);if(x)try{return x.call(Object,t,n,r)}catch(i){}if(f(r,"value"))if(d&&(h(t,n)||p(t,n))){var s=t.__proto__;t.__proto__=o,delete t[n],t[n]=r.value,t.__proto__=s}else t[n]=r.value;else{if(!d)throw new TypeError(C);f(r,"get")&&l(t,n,r.get),f(r,"set")&&c(t,n,r.set)}return t}}Object.defineProperties||(Object.defineProperties=function(t,n){for(var r in n)f(n,r)&&Object.defineProperty(t,r,n[r]);return t}),Object.seal||(Object.seal=function(t){return t}),Object.freeze||(Object.freeze=function(t){return t});try{Object.freeze(function(){})}catch(k){Object.freeze=function(t){return function(n){return typeof n=="function"?n:t(n)}}(Object.freeze)}Object.preventExtensions||(Object.preventExtensions=function(t){return t}),Object.isSealed||(Object.isSealed=function(t){return!1}),Object.isFrozen||(Object.isFrozen=function(t){return!1}),Object.isExtensible||(Object.isExtensible=function(t){if(Object(t)===t)throw new TypeError;var n="";while(f(t,n))n+="?";t[n]=!0;var r=f(t,n);return delete t[n],r});if(!Object.keys){var L=!0,A=["toString","toLocaleString","valueOf","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","constructor"],O=A.length;for(var M in{toString:null})L=!1;Object.keys=function I(e){if(typeof e!="object"&&typeof e!="function"||e===null)throw new TypeError("Object.keys called on a non-object");var I=[];for(var t in e)f(e,t)&&I.push(t);if(L)for(var n=0,r=O;ntr.selected { + background-color: rgb(218, 231, 255); +} + +.table-hover>tbody>tr.selected:hover { + background-color: rgb(205, 212, 226); +} \ No newline at end of file diff --git a/public/javascript/editor.js b/public/javascript/editor.js index 0fd7fb18..b6b37092 100644 --- a/public/javascript/editor.js +++ b/public/javascript/editor.js @@ -39,6 +39,12 @@ $('div[class*="code-editor-"]').each(function () { editor.getSession().setUseWorker(false); } else if ($(this).hasClass('code-editor-css')) { mode = 'css'; + } else if ($(this).hasClass('code-editor-javascript')) { + mode = 'javascript'; + } else if ($(this).hasClass('code-editor-json')) { + mode = 'json'; + } else if ($(this).hasClass('code-editor-handlebars')) { + mode = 'handlebars'; } editor.setTheme('ace/theme/chrome'); diff --git a/public/javascript/tables.js b/public/javascript/tables.js index 5ed32987..3660e2db 100644 --- a/public/javascript/tables.js +++ b/public/javascript/tables.js @@ -31,7 +31,7 @@ }); } - return { + var opts = { scrollX: true, order: [ [sortColumn, sortOrder] @@ -41,6 +41,55 @@ info: paging, /* This controls the "Showing 1 to 16 of 16 entries" */ pageLength: 50 }; + + if ($(elem).hasClass('data-table-selectable') || $(elem).hasClass('data-table-multiselectable')) { + var isMulti = $(elem).hasClass('data-table-multiselectable'); + + var dataElem = $(elem).siblings("input").first(); + + opts.rowCallback = function( row, data ) { + var selected = dataElem.val() == '' ? [] : dataElem.val().split(',').map(function(item) { return Number(item); }); + + if (!isMulti && selected.length > 0) { + selected = [selected[0]]; + } + + if ($.inArray(data.DT_RowId, selected) !== -1) { + $(row).addClass('selected'); + } + } + + $(elem).on('click', 'tbody tr', function () { + var id = this.id; + var selected = dataElem.val() == '' ? [] : dataElem.val().split(','); + + var index = $.inArray(id, selected); + + if (isMulti) { + if ( index === -1 ) { + selected.push(id); + } else { + selected.splice(index, 1); + } + + $(this).toggleClass('selected'); + } else { + for (var selIdx=0; selIdx < selected.length; selIdx++) { + if (selected[selIdx] != id) { + $('#' + selected[selIdx], elem).removeClass('selected'); + } + } + + $('#' + id, elem).addClass('selected'); + + selected = [id]; + } + + dataElem.val(selected.join(',')); + } ); + } + + return opts; } $('.data-table').each(function () { @@ -69,6 +118,7 @@ }); }); }); + })(); $('.data-stats-pie-chart').each(function () { diff --git a/routes/campaigns.js b/routes/campaigns.js index 18c15de9..f239bdd6 100644 --- a/routes/campaigns.js +++ b/routes/campaigns.js @@ -722,8 +722,8 @@ router.post('/clicked/ajax/:id/:linkId', (req, res) => { }); }); -router.post('/selection/ajax', (req, res) => { - campaigns.filter(req.body, Number(req.query.parent) || false, (err, data, total, filteredTotal) => { +router.post('/quicklist/ajax', (req, res) => { + campaigns.filterQuicklist(req.body, (err, data, total, filteredTotal) => { if (err) { return res.json({ error: err.message || err, @@ -735,13 +735,13 @@ router.post('/selection/ajax', (req, res) => { draw: req.body.draw, recordsTotal: total, recordsFiltered: filteredTotal, - data: data.map((row, i) => [ - '', - (Number(req.body.start) || 0) + 1 + i, - ' ' + htmlescape(row.name || '') + '', - htmlescape(striptags(row.description) || ''), - '' + row.created.toISOString() + ''] - ) + data: data.map((row, i) => ({ + "0": (Number(req.body.start) || 0) + 1 + i, + "1": ' ' + htmlescape(row.name || '') + '', + "2": htmlescape(striptags(row.description) || ''), + "3": '' + row.created.toISOString() + '', + "DT_RowId": row.id + })) }); }); }); diff --git a/routes/report-templates.js b/routes/report-templates.js new file mode 100644 index 00000000..37f9d6fd --- /dev/null +++ b/routes/report-templates.js @@ -0,0 +1,282 @@ +'use strict'; + +const express = require('express'); +const passport = require('../lib/passport'); +const router = new express.Router(); +const _ = require('../lib/translate')._; +const reportTemplates = require('../lib/models/report-templates'); +const tools = require('../lib/tools'); +const util = require('util'); +const htmlescape = require('escape-html'); +const striptags = require('striptags'); + +const allowedMimeTypes = { + 'text/html': 'HTML', + 'text/csv': 'CSV' +}; + +router.all('/*', (req, res, next) => { + if (!req.user) { + req.flash('danger', _('Need to be logged in to access restricted content')); + return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl)); + } + res.setSelectedMenu('reports'); + next(); +}); + +router.get('/', (req, res) => { + res.render('report-templates/report-templates', { + title: _('Report Templates') + }); +}); + +router.post('/ajax', (req, res) => { + reportTemplates.filter(req.body, (err, data, total, filteredTotal) => { + if (err) { + return res.json({ + error: err.message || err, + data: [] + }); + } + + res.json({ + draw: req.body.draw, + recordsTotal: total, + recordsFiltered: filteredTotal, + data: data.map((row, i) => [ + (Number(req.body.start) || 0) + 1 + i, + htmlescape(row.name || ''), + htmlescape(striptags(row.description) || ''), + '' + row.created.toISOString() + '', + ' ' + _('Edit') + ''] + ) + }); + }); +}); + +router.get('/create', passport.csrfProtection, (req, res) => { + const data = tools.convertKeys(req.query, { + skip: ['layout'] + }); + + const wizard = req.query['type'] || ''; + + if (wizard == 'subscribers-all') { + if (!('description' in data)) data.description = 'This sample shows how to generate a report listing all subscribers along with their statistics.'; + + if (!('mimeType' in data)) data.mimeType = 'text/html'; + + if (!('userFields' in data)) data.userFields = + '[\n' + + ' {\n' + + ' "id": "campaign",\n' + + ' "name": "Campaign",\n' + + ' "type": "campaign",\n' + + ' "minOccurences": 1,\n' + + ' "maxOccurences": 1\n' + + ' }\n' + + ']'; + + if (!('js' in data)) data.js = + 'const reports = require("../lib/models/reports");\n' + + '\n' + + 'reports.getCampaignResults(inputs.campaign, ["*"], "", (err, results) => {\n' + + ' if (err) {\n' + + ' return callback(err);\n' + + ' }\n' + + '\n' + + ' const data = {\n' + + ' title: "Sample Report on " + inputs.campaign.name,\n' + + ' results: results\n' + + ' };\n' + + '\n' + + ' return callback(null, data);\n' + + '});'; + + if (!('hbs' in data)) data.hbs = + '

{{title}}

\n' + + '\n' + + '
\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' {{#if results}}\n' + + ' \n' + + ' {{#each results}}\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' {{/each}}\n' + + ' \n' + + ' {{/if}}\n' + + '
\n' + + ' {{#translate}}Email{{/translate}}\n' + + ' \n' + + ' {{#translate}}Tracker Count{{/translate}}\n' + + '
\n' + + ' {{email}}\n' + + ' \n' + + ' {{tracker_count}}\n' + + '
\n' + + '
'; + + } else if (wizard == 'subscribers-grouped') { + if (!('description' in data)) data.description = 'This sample shows how to generate a report where results are aggregated by some (typically custom) field. The sample assumes that the list associated with the campaign contains a custom field "Country" (which would be filled in via the subscription form).'; + + if (!('mimeType' in data)) data.mimeType = 'text/html'; + + if (!('userFields' in data)) data.userFields = + '[\n' + + ' {\n' + + ' "id": "campaign",\n' + + ' "name": "Campaign",\n' + + ' "type": "campaign",\n' + + ' "minOccurences": 1,\n' + + ' "maxOccurences": 1\n' + + ' }\n' + + ']'; + + if (!('js' in data)) data.js = + 'const reports = require("../lib/models/reports");\n' + + '\n' + + 'reports.getCampaignResults(inputs.campaign, ["custom_country", "count(*) AS countAll", "SUM(IF(tracker.count IS NULL, 0, 1)) AS countOpened"], "GROUP BY custom_country", (err, results) => {\n' + + ' if (err) {\n' + + ' return callback(err);\n' + + ' }\n' + + '\n' + + ' for (let row of results) {\n' + + ' row["percentage"] = Math.round((row.countOpened / row.countAll) * 100);\n' + + ' }\n' + + '\n' + + ' let data = {\n' + + ' title: "Sample Report on " + inputs.campaign.name,\n' + + ' results: results\n' + + ' };\n' + + '\n' + + ' return callback(null, data);\n' + + '});'; + + if (!('hbs' in data)) data.hbs = + '

{{title}}

\n' + + '\n' + + '
\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' {{#if results}}\n' + + ' \n' + + ' {{#each results}}\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' {{/each}}\n' + + ' \n' + + ' {{/if}}\n' + + '
\n' + + ' {{#translate}}Country{{/translate}}\n' + + ' \n' + + ' {{#translate}}Opened{{/translate}}\n' + + ' \n' + + ' {{#translate}}All{{/translate}}\n' + + ' \n' + + ' {{#translate}}Percentage{{/translate}}\n' + + '
\n' + + ' {{custom_zone}}\n' + + ' \n' + + ' {{countOpened}}\n' + + ' \n' + + ' {{countAll}}\n' + + ' \n' + + ' {{percentage}}%\n' + + '
\n' + + '
'; + } + + data.csrfToken = req.csrfToken(); + data.title = _('Create Report Template'); + data.useEditor = true; + + data.mimeTypes = Object.keys(allowedMimeTypes).map(key => ({ + key: key, + value: allowedMimeTypes[key], + selected: data.mimeType == key + })); + + res.render('report-templates/create', data); +}); + +router.post('/create', passport.parseForm, passport.csrfProtection, (req, res) => { + reportTemplates.createOrUpdate(true, req.body, (err, id) => { + if (err || !id) { + req.flash('danger', err && err.message || err || _('Could not create report template')); + return res.redirect('/report-templates/create?' + tools.queryParams(req.body)); + } + req.flash('success', util.format(_('Report template “%s” created'), req.body.name)); + res.redirect('/report-templates'); + }); +}); + +router.get('/edit/:id', passport.csrfProtection, (req, res) => { + reportTemplates.get(req.params.id, (err, template) => { + if (err || !template) { + req.flash('danger', err && err.message || err || _('Could not find report template with specified ID')); + return res.redirect('/report-templates'); + } + + template.csrfToken = req.csrfToken(); + template.title = _('Edit Report Template'); + template.useEditor = true; + + template.mimeTypes = Object.keys(allowedMimeTypes).map(key => ({ + key: key, + value: allowedMimeTypes[key], + selected: template.mimeType == key + })); + + res.render('report-templates/edit', template); + }); +}); + +router.post('/edit', passport.parseForm, passport.csrfProtection, (req, res) => { + reportTemplates.createOrUpdate(false, req.body, (err, updated) => { + if (err) { + req.flash('danger', err.message || err); + } else if (updated) { + req.flash('success', _('Report template updated')); + } else { + req.flash('info', _('Report template not updated')); + } + + if (req.body['submit'] == 'update-and-stay') { + return res.redirect('/report-templates/edit/' + req.body.id); + } else { + return res.redirect('/report-templates'); + } + }); +}); + +router.post('/delete', passport.parseForm, passport.csrfProtection, (req, res) => { + reportTemplates.delete(req.body.id, (err, deleted) => { + if (err) { + req.flash('danger', err && err.message || err); + } else if (deleted) { + req.flash('success', _('Report template deleted')); + } else { + req.flash('info', _('Could not delete specified report template')); + } + + return res.redirect('/report-templates'); + }); +}); + +module.exports = router; diff --git a/routes/reports.js b/routes/reports.js new file mode 100644 index 00000000..922874d0 --- /dev/null +++ b/routes/reports.js @@ -0,0 +1,361 @@ +'use strict'; + +const express = require('express'); +const passport = require('../lib/passport'); +const router = new express.Router(); +const _ = require('../lib/translate')._; +const reportTemplates = require('../lib/models/report-templates'); +const reports = require('../lib/models/reports'); +const campaigns = require('../lib/models/campaigns'); +const tools = require('../lib/tools'); +const util = require('util'); +const htmlescape = require('escape-html'); +const striptags = require('striptags'); +const hbs = require('hbs'); +const vm = require('vm'); + +router.all('/*', (req, res, next) => { + if (!req.user) { + req.flash('danger', _('Need to be logged in to access restricted content')); + return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl)); + } + res.setSelectedMenu('reports'); + next(); +}); + +router.get('/', (req, res) => { + res.render('reports/reports', { + title: _('Reports') + }); +}); + +router.post('/ajax', (req, res) => { + reports.filter(req.body, (err, data, total, filteredTotal) => { + if (err) { + return res.json({ + error: err.message || err, + data: [] + }); + } + + res.json({ + draw: req.body.draw, + recordsTotal: total, + recordsFiltered: filteredTotal, + data: data.map((row, i) => [ + (Number(req.body.start) || 0) + 1 + i, + htmlescape(row.name || ''), + htmlescape(row.reportTemplateName || ''), + htmlescape(striptags(row.description) || ''), + '' + row.created.toISOString() + '', + ' ' + + ''] + ) + }); + }); +}); + +router.get('/create', passport.csrfProtection, (req, res) => { + const reqData = tools.convertKeys(req.query, { + skip: ['layout'] + }); + + reqData.csrfToken = req.csrfToken(); + reqData.title = _('Create Report'); + reqData.useEditor = true; + + reportTemplates.quicklist((err, items) => { + if (err) { + req.flash('danger', err.message || err); + return res.redirect('/reports'); + } + + const reportTemplateId = Number(reqData.reportTemplate); + + if (reportTemplateId) { + items.forEach(item => { + if (item.id === reportTemplateId) { + item.selected = true; + } + }); + } + + reqData.reportTemplates = items; + + if (!reportTemplateId) { + res.render('reports/create-select-template', reqData); + } else { + addUserFields(reportTemplateId, reqData, null, (err, data) => { + if (err) { + req.flash('danger', err.message || err); + return res.redirect('/reports'); + } + + res.render('reports/create', data); + }); + } + }); +}); + +router.post('/create', passport.parseForm, passport.csrfProtection, (req, res) => { + const reqData = req.body; + const reportTemplateId = Number(reqData.reportTemplate); + + addParamsObject(reportTemplateId, reqData, (err, data) => { + if (err) { + req.flash('danger', err && err.message || err || _('Could not create report')); + return res.redirect('/reports/create?' + tools.queryParams(data)); + } + + reports.createOrUpdate(true, data, (err, id) => { + if (err || !id) { + req.flash('danger', err && err.message || err || _('Could not create report')); + return res.redirect('/reports/create?' + tools.queryParams(data)); + } + req.flash('success', util.format(_('Report “%s” created'), data.name)); + res.redirect('/reports'); + }); + }); +}); + +router.get('/edit/:id', passport.csrfProtection, (req, res) => { + const reqData = tools.convertKeys(req.query, { + skip: ['layout'] + }); + + reports.get(req.params.id, (err, template) => { + if (err || !template) { + req.flash('danger', err && err.message || err || _('Could not find report with specified ID')); + return res.redirect('/reports'); + } + + template.csrfToken = req.csrfToken(); + template.title = _('Edit Report'); + template.useEditor = true; + + reportTemplates.quicklist((err, items) => { + if (err) { + req.flash('danger', err.message || err); + return res.redirect('/'); + } + + const reportTemplateId = template.reportTemplate; + + items.forEach(item => { + if (item.id === reportTemplateId) { + item.selected = true; + } + }); + + template.reportTemplates = items; + + addUserFields(reportTemplateId, reqData, template, (err, data) => { + if (err) { + req.flash('danger', err.message || err); + return res.redirect('/reports'); + } + + res.render('reports/edit', data); + }); + }); + }); +}); + +router.post('/edit', passport.parseForm, passport.csrfProtection, (req, res) => { + const reqData = req.body; + const reportTemplateId = Number(reqData.reportTemplate); + + addParamsObject(reportTemplateId, reqData, (err, data) => { + if (err) { + req.flash('danger', err && err.message || err || _('Could not update report')); + return res.redirect('/reports/create?' + tools.queryParams(data)); + } + + reports.createOrUpdate(false, data, (err, updated) => { + if (err) { + req.flash('danger', err && err.message || err || _('Could not update report')); + return res.redirect('/reports/edit/' + data.id + '?' + tools.queryParams(data)); + } else if (updated) { + req.flash('success', _('Report updated')); + } else { + req.flash('info', _('Report not updated')); + } + + return res.redirect('/reports'); + }); + }); +}); + +router.post('/delete', passport.parseForm, passport.csrfProtection, (req, res) => { + reports.delete(req.body.id, (err, deleted) => { + if (err) { + req.flash('danger', err && err.message || err); + } else if (deleted) { + req.flash('success', _('Report deleted')); + } else { + req.flash('info', _('Could not delete specified report')); + } + + return res.redirect('/reports'); + }); +}); + +router.get('/view/:id', passport.csrfProtection, (req, res) => { + reports.get(req.params.id, (err, template) => { + if (err || !template) { + req.flash('danger', err && err.message || err || _('Could not find report with specified ID')); + return res.redirect('/reports'); + } + + reportTemplates.get(template.reportTemplate, (err, reportTemplate) => { + if (err) { + return callback(err); + } + + resolveUserFields(reportTemplate.userFieldsObject, template.paramsObject, (err, inputs) => { + if (err) { + req.flash('danger', err.message || err); + return res.redirect('/reports'); + } + + const sandbox = { + require: require, + inputs: inputs, + callback: (err, outputs) => { + if (err) { + req.flash('danger', err.message || err); + return res.redirect('/reports'); + } + + const hbsTmpl = hbs.handlebars.compile(reportTemplate.hbs); + const report = hbsTmpl(outputs); + + const data = { + csrfToken: req.csrfToken(), + report: new hbs.handlebars.SafeString(report), + title: outputs.title + }; + + res.render('reports/view', data); + } + }; + + try { + const script = new vm.Script(reportTemplate.js); + script.runInNewContext(sandbox, { displayErrors: true, timeout: 10000 }); + } catch (err) { + req.flash('danger', 'Error in the report template script ... ' + err.stack.replace(/at ContextifyScript.Script.runInContext[\s\S]*/,'')); + return res.redirect('/reports'); + } + }); + }); + }); +}); + +function resolveCampaigns(ids, callback) { + const idsRemaining = ids.slice(); + const resolved = []; + + function doWork() { + if (idsRemaining.length == 0) { + return callback(null, resolved); + } + + campaigns.get(idsRemaining.shift(), false, (err, campaign) => { + if (err) { + return callback(err); + } + + resolved.push(campaign); + return doWork(); + }); + } + + setImmediate(doWork); +} + +function resolveUserFields(userFields, params, callback) { + const userFieldsRemaining = userFields.slice(); + const resolved = {}; + + function doWork() { + if (userFieldsRemaining.length == 0) { + return callback(null, resolved); + } + + const spec = userFieldsRemaining.shift(); + if (spec.type == 'campaign') { + return resolveCampaigns(params[spec.id], (err, campaigns) => { + if (spec.minOccurences == 1 && spec.maxOccurences == 1) { + resolved[spec.id] = campaigns[0]; + } else { + resolved[spec.id] = campaigns; + } + + doWork(); + }); + } + + return callback(new Error(_('Unknown user field type "' + spec.type + '".'))); + } + + setImmediate(doWork); +} + +function addUserFields(reportTemplateId, reqData, template, callback) { + reportTemplates.get(reportTemplateId, (err, reportTemplate) => { + if (err) { + return callback(err); + } + + const userFields = []; + + for (const spec of reportTemplate.userFieldsObject) { + let value = ''; + if ((spec.id + 'Selection') in reqData) { + value = reqData[spec.id + 'Selection']; + } else if (template && template.paramsObject && spec.id in template.paramsObject) { + value = template.paramsObject[spec.id].join(','); + } + + userFields.push({ + 'id': spec.id, + 'name': spec.name, + 'type': spec.type, + 'value': value, + 'isMulti': !(spec.minOccurences == 1 && spec.maxOccurences == 1) + }); + } + + const data = template ? template : reqData; + data.userFields = userFields; + + callback(null, data); + }); +} + +function addParamsObject(reportTemplateId, data, callback) { + reportTemplates.get(reportTemplateId, (err, reportTemplate) => { + if (err) { + return callback(err); + } + + const paramsObject = {}; + + for (const spec of reportTemplate.userFieldsObject) { + const sel = data[spec.id + 'Selection']; + + if (!sel) { + paramsObject[spec.id] = []; + } else { + paramsObject[spec.id] = sel.split(',').map(item => Number(item)); + } + } + + data.paramsObject = paramsObject; + + callback(null, data); + }); +} + +module.exports = router; diff --git a/setup/install-centos7.sh b/setup/install-centos7.sh new file mode 100644 index 00000000..be9ea6dc --- /dev/null +++ b/setup/install-centos7.sh @@ -0,0 +1,210 @@ +#!/bin/bash + +# This installation script works on CentOS 7 +# Run as root! + +if [[ $EUID -ne 0 ]]; then + echo "This script must be run as root" 1>&2 + exit 1 +fi + +set -e + +yum -y install epel-release + +curl --silent --location https://rpm.nodesource.com/setup_6.x | bash - +yum -y install mariadb-server nodejs ImageMagick git python redis pwgen bind-utils + +systemctl start mariadb +systemctl enable mariadb + +systemctl start redis +systemctl enable redis + + +PUBLIC_IP=`curl -s https://api.ipify.org` +if [ ! -z "$PUBLIC_IP" ]; then + HOSTNAME=`dig +short -x $PUBLIC_IP | sed 's/\.$//'` + HOSTNAME="${HOSTNAME:-$PUBLIC_IP}" +fi +HOSTNAME="${HOSTNAME:-`hostname`}" + +MYSQL_PASSWORD=`pwgen 12 -1` +DKIM_API_KEY=`pwgen 12 -1` +SMTP_PASS=`pwgen 12 -1` + +# Setup MySQL user for Mailtrain +mysql -u root -e "CREATE USER 'mailtrain'@'localhost' IDENTIFIED BY '$MYSQL_PASSWORD';" +mysql -u root -e "GRANT ALL PRIVILEGES ON mailtrain.* TO 'mailtrain'@'localhost';" +mysql -u mailtrain --password="$MYSQL_PASSWORD" -e "CREATE database mailtrain;" + +# Enable firewall, allow connections to SSH, HTTP, HTTPS and SMTP +for port in 80/tcp 443/tcp 25/tcp; do firewall-cmd --add-port=$port --permanent; done +firewall-cmd --reload + +# Fetch Mailtrain files +mkdir -p /opt/mailtrain +cd /opt/mailtrain +git clone git://github.com/Mailtrain-org/mailtrain.git . + +# Normally we would let Mailtrain itself to import the initial SQL data but in this case +# we need to modify it, before we start Mailtrain +mysql -u mailtrain -p"$MYSQL_PASSWORD" mailtrain < setup/sql/mailtrain.sql + +mysql -u mailtrain -p"$MYSQL_PASSWORD" mailtrain <> config/production.toml < /etc/logrotate.d/mailtrain +/var/log/mailtrain.log { + daily + rotate 12 + compress + delaycompress + missingok + notifempty + copytruncate + nomail +} +EOM + +# Set up systemd service script +cp setup/mailtrain.service /etc/systemd/system/ +systemctl enable mailtrain.service + +# Fetch ZoneMTA files +mkdir -p /opt/zone-mta +cd /opt/zone-mta +git clone git://github.com/zone-eu/zone-mta.git . +git checkout 6964091273 + +# Ensure queue folder +mkdir -p /var/data/zone-mta/mailtrain + +# Setup installation configuration +cat >> config/production.json < +
  • {{#translate}}Home{{/translate}}
  • +
  • {{#translate}}Reports{{/translate}}
  • +
  • {{#translate}}Templates{{/translate}}
  • +
  • {{#translate}}Create Template{{/translate}}
  • + + +

    {{#translate}}Create Report Template{{/translate}}

    + +
    + +
    + + +{{> report_template_fields }} + +
    + +
    +
    + +
    +
    +
    diff --git a/views/report-templates/edit.hbs b/views/report-templates/edit.hbs new file mode 100644 index 00000000..ac4c142b --- /dev/null +++ b/views/report-templates/edit.hbs @@ -0,0 +1,36 @@ + + +

    {{#translate}}Edit Report Template{{/translate}}

    + +
    + +
    + + +
    + + +
    + + + +{{> report_template_fields }} + +
    + +
    +
    +
    + +
    + + +
    +
    + +
    diff --git a/views/report-templates/partials/report-template-fields.hbs b/views/report-templates/partials/report-template-fields.hbs new file mode 100644 index 00000000..67be0cf3 --- /dev/null +++ b/views/report-templates/partials/report-template-fields.hbs @@ -0,0 +1,59 @@ +
    + +
    + +
    +
    + +
    + +
    + + {{#translate}}HTML is allowed{{/translate}} +
    +
    + +
    + +
    + +
    +
    + +
    + +
    +
    + JSON specification of user selectable fields. +
    +
    + +
    +
    + +
    + +
    +
    + Write the body of the JavaScript function with signature function(inputs, callback) that returns an object to be rendered by the Handlebars template below. +
    +
    + +
    +
    + +
    + +
    +
    + Use HTML with Handlebars syntax. See documentation here. +
    +
    + +
    +
    + diff --git a/views/report-templates/report-templates.hbs b/views/report-templates/report-templates.hbs new file mode 100644 index 00000000..68939e11 --- /dev/null +++ b/views/report-templates/report-templates.hbs @@ -0,0 +1,44 @@ + + + + +

    {{#translate}}Report Templates{{/translate}}

    + +
    + +
    + + + + + + + + +
    + # + + {{#translate}}Name{{/translate}} + + {{#translate}}Description{{/translate}} + + {{#translate}}Created{{/translate}} + +   +
    +
    diff --git a/views/reports/create-select-template.hbs b/views/reports/create-select-template.hbs new file mode 100644 index 00000000..8d868b5b --- /dev/null +++ b/views/reports/create-select-template.hbs @@ -0,0 +1,23 @@ + + +

    {{#translate}}Create Report{{/translate}}

    + +
    + +
    + + +{{> report_select_template }} + +
    + +
    +
    + +
    +
    +
    diff --git a/views/reports/create.hbs b/views/reports/create.hbs new file mode 100644 index 00000000..e0fa4346 --- /dev/null +++ b/views/reports/create.hbs @@ -0,0 +1,23 @@ + + +

    {{#translate}}Create Report{{/translate}}

    + +
    + +
    + + +{{> report_fields }} + +
    + +
    +
    + +
    +
    +
    diff --git a/views/reports/edit.hbs b/views/reports/edit.hbs new file mode 100644 index 00000000..a3723365 --- /dev/null +++ b/views/reports/edit.hbs @@ -0,0 +1,34 @@ + + +

    {{#translate}}Edit Report{{/translate}}

    + +
    + +
    + + +
    + + +
    + + + +{{> report_fields }} + +
    + +
    +
    +
    + +
    + +
    +
    + +
    diff --git a/views/reports/partials/report-fields.hbs b/views/reports/partials/report-fields.hbs new file mode 100644 index 00000000..ba24828b --- /dev/null +++ b/views/reports/partials/report-fields.hbs @@ -0,0 +1,49 @@ + {{> report_select_template options="readonly" }} + +
    + +
    + +
    +
    + +
    + +
    + + {{#translate}}HTML is allowed{{/translate}} +
    +
    + + {{#each userFields}} + {{#switch type}} + {{#case "campaign"}} +
    + +
    +
    + + + + + + + +
    + # + + {{#translate}}Name{{/translate}} + + {{#translate}}Description{{/translate}} + + {{#translate}}Created{{/translate}} +
    + +
    + {{#translate}}Select a campaign in the table above by clicking on the respective row number.{{/translate}} +
    +
    + {{/case}} + {{/switch}} + {{/each}} + diff --git a/views/reports/partials/report-select-template.hbs b/views/reports/partials/report-select-template.hbs new file mode 100644 index 00000000..eeb033a5 --- /dev/null +++ b/views/reports/partials/report-select-template.hbs @@ -0,0 +1,11 @@ +
    + +
    + +
    +
    diff --git a/views/reports/reports.hbs b/views/reports/reports.hbs new file mode 100644 index 00000000..6998a8d2 --- /dev/null +++ b/views/reports/reports.hbs @@ -0,0 +1,39 @@ + + + + +

    {{#translate}}Reports{{/translate}}

    + +
    + +
    + + + + + + + + + +
    + # + + {{#translate}}Name{{/translate}} + + {{#translate}}Template{{/translate}} + + {{#translate}}Description{{/translate}} + + {{#translate}}Created{{/translate}} + +   +
    +
    diff --git a/views/reports/view.hbs b/views/reports/view.hbs new file mode 100644 index 00000000..854bf227 --- /dev/null +++ b/views/reports/view.hbs @@ -0,0 +1,7 @@ + + +{{report}} \ No newline at end of file From 2056645023551414c1f3986963c3f460943cd10e Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Mon, 17 Apr 2017 16:30:31 -0400 Subject: [PATCH 05/30] Added the option to select lists in report. Added an option to generate a CSV report. --- lib/models/lists.js | 4 + lib/models/reports.js | 2 +- lib/table-helpers.js | 16 ++- routes/lists.js | 23 +++++ routes/report-templates.js | 49 +++++++-- routes/reports.js | 108 ++++++++++++-------- views/report-templates/report-templates.hbs | 1 + views/reports/partials/report-fields.hbs | 24 +++++ 8 files changed, 177 insertions(+), 50 deletions(-) diff --git a/lib/models/lists.js b/lib/models/lists.js index dc1a0912..772a5820 100644 --- a/lib/models/lists.js +++ b/lib/models/lists.js @@ -17,6 +17,10 @@ module.exports.filter = (request, parent, callback) => { tableHelpers.filter('lists', ['*'], request, ['#', 'name', 'cid', 'subscribers', 'description'], ['name'], 'name ASC', null, callback); }; +module.exports.filterQuicklist = (request, callback) => { + tableHelpers.filter('lists', ['id', 'name', 'subscribers'], request, ['#', 'name', 'subscribers'], ['name'], 'name ASC', null, callback); +}; + module.exports.quicklist = callback => { db.getConnection((err, connection) => { if (err) { diff --git a/lib/models/reports.js b/lib/models/reports.js index 57f3b1f5..d9dd3098 100644 --- a/lib/models/reports.js +++ b/lib/models/reports.js @@ -16,7 +16,7 @@ module.exports.list = (start, limit, callback) => { module.exports.filter = (request, callback) => { tableHelpers.filter('reports JOIN report_templates ON reports.report_template = report_templates.id', - ['reports.id AS id', 'reports.name AS name', 'reports.description AS description', 'reports.report_template AS report_template', 'reports.params AS params', 'reports.created AS created', 'report_templates.name AS report_template_name' ], + ['reports.id AS id', 'reports.name AS name', 'reports.description AS description', 'reports.report_template AS report_template', 'reports.params AS params', 'reports.created AS created', 'report_templates.name AS report_template_name', 'report_templates.mime_type AS mime_type' ], request, ['#', 'name', 'report_templates.name', 'description', 'created'], ['name'], 'created DESC', null, callback); }; diff --git a/lib/table-helpers.js b/lib/table-helpers.js index eaac0597..f5c2e6bc 100644 --- a/lib/table-helpers.js +++ b/lib/table-helpers.js @@ -10,7 +10,19 @@ module.exports.list = (source, fields, orderBy, start, limit, callback) => { return callback(err); } - connection.query('SELECT SQL_CALC_FOUND_ROWS ' + fields.join(', ') + ' FROM ' + source + ' ORDER BY ' + orderBy + ' DESC LIMIT ? OFFSET ?', [limit, start], (err, rows) => { + let limitQuery = ''; + let limitValues = []; + if (limit) { + limitQuery = ' LIMIT ?'; + limitValues.push(limit); + + if (start) { + limitQuery += ' OFFSET ?'; + limitValues.push(start); + } + } + + connection.query('SELECT SQL_CALC_FOUND_ROWS ' + fields.join(', ') + ' FROM ' + source + ' ORDER BY ' + orderBy + ' DESC' + limitQuery, limitValues, (err, rows) => { if (err) { connection.release(); return callback(err); @@ -56,8 +68,6 @@ module.exports.filter = (source, fields, request, columns, searchFields, default values = values.concat(queryData.values || []); } - log.info("tableHelpers", query); - connection.query(query, values, (err, total) => { if (err) { connection.release(); diff --git a/routes/lists.js b/routes/lists.js index 1ae18d7c..64c1352f 100644 --- a/routes/lists.js +++ b/routes/lists.js @@ -748,4 +748,27 @@ router.get('/subscription/:id/import/:importId/failed', (req, res) => { }); }); +router.post('/quicklist/ajax', (req, res) => { + lists.filterQuicklist(req.body, (err, data, total, filteredTotal) => { + if (err) { + return res.json({ + error: err.message || err, + data: [] + }); + } + + res.json({ + draw: req.body.draw, + recordsTotal: total, + recordsFiltered: filteredTotal, + data: data.map((row, i) => ({ + "0": (Number(req.body.start) || 0) + 1 + i, + "1": ' ' + htmlescape(row.name || '') + '', + "2": row.subscribers, + "DT_RowId": row.id + })) + }); + }); +}); + module.exports = router; diff --git a/routes/report-templates.js b/routes/report-templates.js index 37f9d6fd..72c74f1f 100644 --- a/routes/report-templates.js +++ b/routes/report-templates.js @@ -62,7 +62,7 @@ router.get('/create', passport.csrfProtection, (req, res) => { const wizard = req.query['type'] || ''; if (wizard == 'subscribers-all') { - if (!('description' in data)) data.description = 'This sample shows how to generate a report listing all subscribers along with their statistics.'; + if (!('description' in data)) data.description = 'Generates a campaign report listing all subscribers along with their statistics.'; if (!('mimeType' in data)) data.mimeType = 'text/html'; @@ -124,7 +124,7 @@ router.get('/create', passport.csrfProtection, (req, res) => { '
    '; } else if (wizard == 'subscribers-grouped') { - if (!('description' in data)) data.description = 'This sample shows how to generate a report where results are aggregated by some (typically custom) field. The sample assumes that the list associated with the campaign contains a custom field "Country" (which would be filled in via the subscription form).'; + if (!('description' in data)) data.description = 'Generates a campaign report with results are aggregated by some "Country" custom field.'; if (!('mimeType' in data)) data.mimeType = 'text/html'; @@ -142,13 +142,13 @@ router.get('/create', passport.csrfProtection, (req, res) => { if (!('js' in data)) data.js = 'const reports = require("../lib/models/reports");\n' + '\n' + - 'reports.getCampaignResults(inputs.campaign, ["custom_country", "count(*) AS countAll", "SUM(IF(tracker.count IS NULL, 0, 1)) AS countOpened"], "GROUP BY custom_country", (err, results) => {\n' + + 'reports.getCampaignResults(inputs.campaign, ["custom_country", "count(*) AS count_all", "SUM(IF(tracker.count IS NULL, 0, 1)) AS count_opened"], "GROUP BY custom_country", (err, results) => {\n' + ' if (err) {\n' + ' return callback(err);\n' + ' }\n' + '\n' + ' for (let row of results) {\n' + - ' row["percentage"] = Math.round((row.countOpened / row.countAll) * 100);\n' + + ' row["percentage"] = Math.round((row.count_opened / row.count_all) * 100);\n' + ' }\n' + '\n' + ' let data = {\n' + @@ -186,10 +186,10 @@ router.get('/create', passport.csrfProtection, (req, res) => { ' {{custom_zone}}\n' + ' \n' + ' \n' + - ' {{countOpened}}\n' + + ' {{count_opened}}\n' + ' \n' + ' \n' + - ' {{countAll}}\n' + + ' {{count_all}}\n' + ' \n' + ' \n' + ' {{percentage}}%\n' + @@ -200,6 +200,43 @@ router.get('/create', passport.csrfProtection, (req, res) => { ' {{/if}}\n' + ' \n' + '
    '; + + } else if (wizard == 'export-list-csv') { + if (!('description' in data)) data.description = 'Exports a list as a CSV file.'; + + if (!('mimeType' in data)) data.mimeType = 'text/csv'; + + if (!('userFields' in data)) data.userFields = + '[\n' + + ' {\n' + + ' "id": "list",\n' + + ' "name": "List",\n' + + ' "type": "list",\n' + + ' "minOccurences": 1,\n' + + ' "maxOccurences": 1\n' + + ' }\n' + + ']'; + + if (!('js' in data)) data.js = + 'const subscriptions = require("../lib/models/subscriptions");\n' + + '\n' + + 'subscriptions.list(inputs.list.id,0,0, (err, results) => {\n' + + ' if (err) {\n' + + ' return callback(err);\n' + + ' }\n' + + '\n' + + ' let data = {\n' + + ' title: "Sample Export of " + inputs.list.name,\n' + + ' results: results\n' + + ' };\n' + + '\n' + + ' return callback(null, data);\n' + + '});'; + + if (!('hbs' in data)) data.hbs = + '{{#each results}}\n' + + '{{firstName}},{{lastName}},{{email}}\n' + + '{{/each}}'; } data.csrfToken = req.csrfToken(); diff --git a/routes/reports.js b/routes/reports.js index 922874d0..645c7bc9 100644 --- a/routes/reports.js +++ b/routes/reports.js @@ -7,6 +7,7 @@ const _ = require('../lib/translate')._; const reportTemplates = require('../lib/models/report-templates'); const reports = require('../lib/models/reports'); const campaigns = require('../lib/models/campaigns'); +const lists = require('../lib/models/lists'); const tools = require('../lib/tools'); const util = require('util'); const htmlescape = require('escape-html'); @@ -30,6 +31,12 @@ router.get('/', (req, res) => { }); router.post('/ajax', (req, res) => { + function getViewIcon(mimeType) { + let icon = 'search'; + if (mimeType == 'text/csv') icon = 'download-alt'; + return icon; + } + reports.filter(req.body, (err, data, total, filteredTotal) => { if (err) { return res.json({ @@ -48,7 +55,7 @@ router.post('/ajax', (req, res) => { htmlescape(row.reportTemplateName || ''), htmlescape(striptags(row.description) || ''), '' + row.created.toISOString() + '', - ' ' + + ' ' + ''] ) }); @@ -123,15 +130,15 @@ router.get('/edit/:id', passport.csrfProtection, (req, res) => { skip: ['layout'] }); - reports.get(req.params.id, (err, template) => { - if (err || !template) { + reports.get(req.params.id, (err, report) => { + if (err || !report) { req.flash('danger', err && err.message || err || _('Could not find report with specified ID')); return res.redirect('/reports'); } - template.csrfToken = req.csrfToken(); - template.title = _('Edit Report'); - template.useEditor = true; + report.csrfToken = req.csrfToken(); + report.title = _('Edit Report'); + report.useEditor = true; reportTemplates.quicklist((err, items) => { if (err) { @@ -139,7 +146,7 @@ router.get('/edit/:id', passport.csrfProtection, (req, res) => { return res.redirect('/'); } - const reportTemplateId = template.reportTemplate; + const reportTemplateId = report.reportTemplate; items.forEach(item => { if (item.id === reportTemplateId) { @@ -147,9 +154,9 @@ router.get('/edit/:id', passport.csrfProtection, (req, res) => { } }); - template.reportTemplates = items; + report.reportTemplates = items; - addUserFields(reportTemplateId, reqData, template, (err, data) => { + addUserFields(reportTemplateId, reqData, report, (err, data) => { if (err) { req.flash('danger', err.message || err); return res.redirect('/reports'); @@ -201,18 +208,18 @@ router.post('/delete', passport.parseForm, passport.csrfProtection, (req, res) = }); router.get('/view/:id', passport.csrfProtection, (req, res) => { - reports.get(req.params.id, (err, template) => { - if (err || !template) { + reports.get(req.params.id, (err, report) => { + if (err || !report) { req.flash('danger', err && err.message || err || _('Could not find report with specified ID')); return res.redirect('/reports'); } - reportTemplates.get(template.reportTemplate, (err, reportTemplate) => { + reportTemplates.get(report.reportTemplate, (err, reportTemplate) => { if (err) { return callback(err); } - resolveUserFields(reportTemplate.userFieldsObject, template.paramsObject, (err, inputs) => { + resolveUserFields(reportTemplate.userFieldsObject, report.paramsObject, (err, inputs) => { if (err) { req.flash('danger', err.message || err); return res.redirect('/reports'); @@ -228,31 +235,45 @@ router.get('/view/:id', passport.csrfProtection, (req, res) => { } const hbsTmpl = hbs.handlebars.compile(reportTemplate.hbs); - const report = hbsTmpl(outputs); + const reportText = hbsTmpl(outputs); - const data = { - csrfToken: req.csrfToken(), - report: new hbs.handlebars.SafeString(report), - title: outputs.title - }; + if (reportTemplate.mimeType == 'text/html') { + const data = { + csrfToken: req.csrfToken(), + report: new hbs.handlebars.SafeString(reportText), + title: outputs.title + }; - res.render('reports/view', data); + res.render('reports/view', data); + + } else if (reportTemplate.mimeType == 'text/csv') { + res.set('Content-Disposition', 'attachment;filename=' + toFileName(report.name) + '.csv'); + res.set('Content-Type', 'text/csv'); + res.send(new Buffer(reportText)); + + } else { + req.flash('danger', _('Unknown type of template')); + return res.redirect('/reports'); + } } }; - try { - const script = new vm.Script(reportTemplate.js); - script.runInNewContext(sandbox, { displayErrors: true, timeout: 10000 }); - } catch (err) { - req.flash('danger', 'Error in the report template script ... ' + err.stack.replace(/at ContextifyScript.Script.runInContext[\s\S]*/,'')); - return res.redirect('/reports'); - } + const script = new vm.Script(reportTemplate.js); + script.runInNewContext(sandbox, { displayErrors: true, timeout: 10000 }); }); }); }); }); -function resolveCampaigns(ids, callback) { +function toFileName(name) { + return name. + trim(). + toLowerCase(). + replace(/[ .+/]/g, '-'). + replace(/[^a-z0-9\-_]/gi, ''); +} + +function resolveEntities(getter, ids, callback) { const idsRemaining = ids.slice(); const resolved = []; @@ -261,12 +282,12 @@ function resolveCampaigns(ids, callback) { return callback(null, resolved); } - campaigns.get(idsRemaining.shift(), false, (err, campaign) => { + getter(idsRemaining.shift(), (err, entity) => { if (err) { return callback(err); } - resolved.push(campaign); + resolved.push(entity); return doWork(); }); } @@ -274,6 +295,11 @@ function resolveCampaigns(ids, callback) { setImmediate(doWork); } +const userFieldTypeToGetter = { + 'campaign': (id, callback) => campaigns.get(id, false, callback), + 'list': lists.get +}; + function resolveUserFields(userFields, params, callback) { const userFieldsRemaining = userFields.slice(); const resolved = {}; @@ -284,25 +310,27 @@ function resolveUserFields(userFields, params, callback) { } const spec = userFieldsRemaining.shift(); - if (spec.type == 'campaign') { - return resolveCampaigns(params[spec.id], (err, campaigns) => { + const getter = userFieldTypeToGetter[spec.type]; + + if (getter) { + return resolveEntities(getter, params[spec.id], (err, entities) => { if (spec.minOccurences == 1 && spec.maxOccurences == 1) { - resolved[spec.id] = campaigns[0]; + resolved[spec.id] = entities[0]; } else { - resolved[spec.id] = campaigns; + resolved[spec.id] = entities; } doWork(); }); + } else { + return callback(new Error(_('Unknown user field type "' + spec.type + '".'))); } - - return callback(new Error(_('Unknown user field type "' + spec.type + '".'))); } setImmediate(doWork); } -function addUserFields(reportTemplateId, reqData, template, callback) { +function addUserFields(reportTemplateId, reqData, report, callback) { reportTemplates.get(reportTemplateId, (err, reportTemplate) => { if (err) { return callback(err); @@ -314,8 +342,8 @@ function addUserFields(reportTemplateId, reqData, template, callback) { let value = ''; if ((spec.id + 'Selection') in reqData) { value = reqData[spec.id + 'Selection']; - } else if (template && template.paramsObject && spec.id in template.paramsObject) { - value = template.paramsObject[spec.id].join(','); + } else if (report && report.paramsObject && spec.id in report.paramsObject) { + value = report.paramsObject[spec.id].join(','); } userFields.push({ @@ -327,7 +355,7 @@ function addUserFields(reportTemplateId, reqData, template, callback) { }); } - const data = template ? template : reqData; + const data = report ? report : reqData; data.userFields = userFields; callback(null, data); diff --git a/views/report-templates/report-templates.hbs b/views/report-templates/report-templates.hbs index 68939e11..3250137d 100644 --- a/views/report-templates/report-templates.hbs +++ b/views/report-templates/report-templates.hbs @@ -13,6 +13,7 @@
  • {{#translate}}Blank{{/translate}}
  • {{#translate}}All Subscribers{{/translate}}
  • {{#translate}}Grouped Subscribers{{/translate}}
  • +
  • {{#translate}}Export List as CSV{{/translate}}
  • diff --git a/views/reports/partials/report-fields.hbs b/views/reports/partials/report-fields.hbs index ba24828b..df53d9bb 100644 --- a/views/reports/partials/report-fields.hbs +++ b/views/reports/partials/report-fields.hbs @@ -44,6 +44,30 @@
    {{/case}} + {{#case "list"}} +
    + +
    +
    + + + + + + +
    + # + + {{#translate}}Name{{/translate}} + + {{#translate}}Subscribers{{/translate}} +
    + +
    + {{#translate}}Select a campaign in the table above by clicking on the respective row number.{{/translate}} +
    +
    + {{/case}} {{/switch}} {{/each}} From e7d12f1dbccd62a18b15bbfe4ce08728802fa013 Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Mon, 17 Apr 2017 18:31:01 -0400 Subject: [PATCH 06/30] Halfway through in refactoring the report generation to a separate process running asynchronously of the Express server. --- app.js | 118 ++++++++--------------- lib/fs-tools.js | 14 +++ lib/handlebars-helpers.js | 46 +++++++++ lib/models/reports.js | 4 +- protected/reports/.gitignore | 3 + protected/reports/README.md | 1 + routes/report-templates.js | 8 +- routes/reports.js | 182 ++++++++++++----------------------- services/reports.js | 146 ++++++++++++++++++++++++++++ setup/sql/upgrade-00027.sql | 3 + 10 files changed, 319 insertions(+), 206 deletions(-) create mode 100644 lib/fs-tools.js create mode 100644 lib/handlebars-helpers.js create mode 100644 protected/reports/.gitignore create mode 100644 protected/reports/README.md create mode 100644 services/reports.js diff --git a/app.js b/app.js index a7082983..715180a9 100644 --- a/app.js +++ b/app.js @@ -1,49 +1,50 @@ 'use strict'; -let config = require('config'); -let log = require('npmlog'); +const config = require('config'); +const log = require('npmlog'); -let _ = require('./lib/translate')._; -let util = require('util'); +const _ = require('./lib/translate')._; +const util = require('util'); -let express = require('express'); -let bodyParser = require('body-parser'); -let path = require('path'); -let favicon = require('serve-favicon'); -let logger = require('morgan'); -let cookieParser = require('cookie-parser'); -let session = require('express-session'); -let RedisStore = require('connect-redis')(session); -let flash = require('connect-flash'); -let hbs = require('hbs'); -let compression = require('compression'); -let passport = require('./lib/passport'); -let tools = require('./lib/tools'); +const express = require('express'); +const bodyParser = require('body-parser'); +const path = require('path'); +const favicon = require('serve-favicon'); +const logger = require('morgan'); +const cookieParser = require('cookie-parser'); +const session = require('express-session'); +const RedisStore = require('connect-redis')(session); +const flash = require('connect-flash'); +const hbs = require('hbs'); +const handlebarsHelpers = require('./lib/handlebars-helpers'); +const compression = require('compression'); +const passport = require('./lib/passport'); +const tools = require('./lib/tools'); -let routes = require('./routes/index'); -let users = require('./routes/users'); -let lists = require('./routes/lists'); -let settings = require('./routes/settings'); -let settingsModel = require('./lib/models/settings'); -let templates = require('./routes/templates'); -let campaigns = require('./routes/campaigns'); -let links = require('./routes/links'); -let fields = require('./routes/fields'); -let forms = require('./routes/forms'); -let segments = require('./routes/segments'); -let triggers = require('./routes/triggers'); -let webhooks = require('./routes/webhooks'); -let subscription = require('./routes/subscription'); -let archive = require('./routes/archive'); -let api = require('./routes/api'); -let blacklist = require('./routes/blacklist'); -let editorapi = require('./routes/editorapi'); -let grapejs = require('./routes/grapejs'); -let mosaico = require('./routes/mosaico'); -let reports = require('./routes/reports'); -let reportsTemplates = require('./routes/report-templates'); +const routes = require('./routes/index'); +const users = require('./routes/users'); +const lists = require('./routes/lists'); +const settings = require('./routes/settings'); +const settingsModel = require('./lib/models/settings'); +const templates = require('./routes/templates'); +const campaigns = require('./routes/campaigns'); +const links = require('./routes/links'); +const fields = require('./routes/fields'); +const forms = require('./routes/forms'); +const segments = require('./routes/segments'); +const triggers = require('./routes/triggers'); +const webhooks = require('./routes/webhooks'); +const subscription = require('./routes/subscription'); +const archive = require('./routes/archive'); +const api = require('./routes/api'); +const blacklist = require('./routes/blacklist'); +const editorapi = require('./routes/editorapi'); +const grapejs = require('./routes/grapejs'); +const mosaico = require('./routes/mosaico'); +const reports = require('./routes/reports'); +const reportsTemplates = require('./routes/report-templates'); -let app = express(); +const app = express(); // view engine setup app.set('views', path.join(__dirname, 'views')); @@ -108,43 +109,8 @@ hbs.registerHelper('flash_messages', function () { // eslint-disable-line prefer ); }); -// {{#translate}}abc{{/translate}} -hbs.registerHelper('translate', function (context, options) { // eslint-disable-line prefer-arrow-callback - if (typeof options === 'undefined' && context) { - options = context; - context = false; - } +handlebarsHelpers.registerHelpers(hbs.handlebars); - let result = _(options.fn(this)); // eslint-disable-line no-invalid-this - - if (Array.isArray(context)) { - result = util.format(result, ...context); - } - return new hbs.handlebars.SafeString(result); -}); - -/* Credits to http://chrismontrois.net/2016/01/30/handlebars-switch/ - - {{#switch letter}} - {{#case "a"}} - A is for alpaca - {{/case}} - {{#case "b"}} - B is for bluebird - {{/case}} - {{/switch}} - */ -hbs.registerHelper("switch", function(value, options) { - this._switch_value_ = value; - var html = options.fn(this); // Process the body of the switch block - delete this._switch_value_; - return html; -}); -hbs.registerHelper("case", function(value, options) { - if (value == this._switch_value_) { - return options.fn(this); - } -}); app.use(compression()); app.use(favicon(path.join(__dirname, 'public', 'favicon.ico'))); diff --git a/lib/fs-tools.js b/lib/fs-tools.js new file mode 100644 index 00000000..59f126e6 --- /dev/null +++ b/lib/fs-tools.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = { + nameToFileName, +}; + +function nameToFileName(name) { + return name. + trim(). + toLowerCase(). + replace(/[ .+/]/g, '-'). + replace(/[^a-z0-9\-_]/gi, ''). + replace(/--*/g, '-'); +} diff --git a/lib/handlebars-helpers.js b/lib/handlebars-helpers.js new file mode 100644 index 00000000..9ee22109 --- /dev/null +++ b/lib/handlebars-helpers.js @@ -0,0 +1,46 @@ +'use strict'; + +const _ = require('../lib/translate')._; + +module.exports.registerHelpers = (handlebars) => { + // {{#translate}}abc{{/translate}} + handlebars.registerHelper('translate', function (context, options) { // eslint-disable-line prefer-arrow-callback + if (typeof options === 'undefined' && context) { + options = context; + context = false; + } + + let result = _(options.fn(this)); // eslint-disable-line no-invalid-this + + if (Array.isArray(context)) { + result = util.format(result, ...context); + } + return new handlebars.SafeString(result); + }); + + + /* Credits to http://chrismontrois.net/2016/01/30/handlebars-switch/ + + {{#switch letter}} + {{#case "a"}} + A is for alpaca + {{/case}} + {{#case "b"}} + B is for bluebird + {{/case}} + {{/switch}} + */ + handlebars.registerHelper("switch", function(value, options) { + this._switch_value_ = value; + var html = options.fn(this); // Process the body of the switch block + delete this._switch_value_; + return html; + }); + + handlebars.registerHelper("case", function(value, options) { + if (value == this._switch_value_) { + return options.fn(this); + } + }); + +}; diff --git a/lib/models/reports.js b/lib/models/reports.js index d9dd3098..2d972f36 100644 --- a/lib/models/reports.js +++ b/lib/models/reports.js @@ -8,7 +8,7 @@ const tools = require('../tools'); const _ = require('../translate')._; const log = require('npmlog'); -const allowedKeys = ['name', 'description', 'report_template', 'params']; +const allowedKeys = ['name', 'description', 'report_template', 'params', 'filename']; module.exports.list = (start, limit, callback) => { tableHelpers.list('reports', ['*'], 'name', start, limit, callback); @@ -16,7 +16,7 @@ module.exports.list = (start, limit, callback) => { module.exports.filter = (request, callback) => { tableHelpers.filter('reports JOIN report_templates ON reports.report_template = report_templates.id', - ['reports.id AS id', 'reports.name AS name', 'reports.description AS description', 'reports.report_template AS report_template', 'reports.params AS params', 'reports.created AS created', 'report_templates.name AS report_template_name', 'report_templates.mime_type AS mime_type' ], + ['reports.id AS id', 'reports.name AS name', 'reports.description AS description', 'reports.state AS state', 'reports.filename AS filename', 'reports.report_template AS report_template', 'reports.params AS params', 'reports.created AS created', 'report_templates.name AS report_template_name', 'report_templates.mime_type AS mime_type' ], request, ['#', 'name', 'report_templates.name', 'description', 'created'], ['name'], 'created DESC', null, callback); }; diff --git a/protected/reports/.gitignore b/protected/reports/.gitignore new file mode 100644 index 00000000..d5b76284 --- /dev/null +++ b/protected/reports/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!README.md \ No newline at end of file diff --git a/protected/reports/README.md b/protected/reports/README.md new file mode 100644 index 00000000..0420f4d0 --- /dev/null +++ b/protected/reports/README.md @@ -0,0 +1 @@ +This directory serves for generated reports. \ No newline at end of file diff --git a/routes/report-templates.js b/routes/report-templates.js index 72c74f1f..638502fd 100644 --- a/routes/report-templates.js +++ b/routes/report-templates.js @@ -55,10 +55,7 @@ router.post('/ajax', (req, res) => { }); router.get('/create', passport.csrfProtection, (req, res) => { - const data = tools.convertKeys(req.query, { - skip: ['layout'] - }); - + const data = req.query; const wizard = req.query['type'] || ''; if (wizard == 'subscribers-all') { @@ -86,7 +83,6 @@ router.get('/create', passport.csrfProtection, (req, res) => { ' }\n' + '\n' + ' const data = {\n' + - ' title: "Sample Report on " + inputs.campaign.name,\n' + ' results: results\n' + ' };\n' + '\n' + @@ -152,7 +148,6 @@ router.get('/create', passport.csrfProtection, (req, res) => { ' }\n' + '\n' + ' let data = {\n' + - ' title: "Sample Report on " + inputs.campaign.name,\n' + ' results: results\n' + ' };\n' + '\n' + @@ -226,7 +221,6 @@ router.get('/create', passport.csrfProtection, (req, res) => { ' }\n' + '\n' + ' let data = {\n' + - ' title: "Sample Export of " + inputs.list.name,\n' + ' results: results\n' + ' };\n' + '\n' + diff --git a/routes/reports.js b/routes/reports.js index 645c7bc9..5006668c 100644 --- a/routes/reports.js +++ b/routes/reports.js @@ -12,8 +12,8 @@ const tools = require('../lib/tools'); const util = require('util'); const htmlescape = require('escape-html'); const striptags = require('striptags'); -const hbs = require('hbs'); -const vm = require('vm'); +const fs = require('fs'); +const fsTools = require('../lib/fs-tools'); router.all('/*', (req, res, next) => { if (!req.user) { @@ -31,10 +31,23 @@ router.get('/', (req, res) => { }); router.post('/ajax', (req, res) => { - function getViewIcon(mimeType) { - let icon = 'search'; - if (mimeType == 'text/csv') icon = 'download-alt'; - return icon; + function getViewLink(row) { + if (row.state == 0) { + // TODO: Render waiting + // TODO: Add error output + return ' '; + } else if (row.state == 1) { + let icon = 'eye-open'; + if (row.mimeType == 'text/csv') icon = 'download-alt'; + + // TODO: Add error output + return ' '; + } else if (row.state == 2) { + // TODO: Add error output + return ' '; + } + + return ''; } reports.filter(req.body, (err, data, total, filteredTotal) => { @@ -55,7 +68,7 @@ router.post('/ajax', (req, res) => { htmlescape(row.reportTemplateName || ''), htmlescape(striptags(row.description) || ''), '' + row.created.toISOString() + '', - ' ' + + getViewLink(row) + ''] ) }); @@ -63,10 +76,7 @@ router.post('/ajax', (req, res) => { }); router.get('/create', passport.csrfProtection, (req, res) => { - const reqData = tools.convertKeys(req.query, { - skip: ['layout'] - }); - + const reqData = req.query; reqData.csrfToken = req.csrfToken(); reqData.title = _('Create Report'); reqData.useEditor = true; @@ -106,6 +116,8 @@ router.get('/create', passport.csrfProtection, (req, res) => { router.post('/create', passport.parseForm, passport.csrfProtection, (req, res) => { const reqData = req.body; + delete reqData.filename; // This is to make sure no one inserts a fake filename when editing the report. + const reportTemplateId = Number(reqData.reportTemplate); addParamsObject(reportTemplateId, reqData, (err, data) => { @@ -126,10 +138,7 @@ router.post('/create', passport.parseForm, passport.csrfProtection, (req, res) = }); router.get('/edit/:id', passport.csrfProtection, (req, res) => { - const reqData = tools.convertKeys(req.query, { - skip: ['layout'] - }); - + const reqData = req.query; reports.get(req.params.id, (err, report) => { if (err || !report) { req.flash('danger', err && err.message || err || _('Could not find report with specified ID')); @@ -170,6 +179,8 @@ router.get('/edit/:id', passport.csrfProtection, (req, res) => { router.post('/edit', passport.parseForm, passport.csrfProtection, (req, res) => { const reqData = req.body; + delete reqData.filename; // This is to make sure no one inserts a fake filename when editing the report. + const reportTemplateId = Number(reqData.reportTemplate); addParamsObject(reportTemplateId, reqData, (err, data) => { @@ -216,120 +227,49 @@ router.get('/view/:id', passport.csrfProtection, (req, res) => { reportTemplates.get(report.reportTemplate, (err, reportTemplate) => { if (err) { - return callback(err); + req.flash('danger', err && err.message || err || _('Could not find report template')); + return res.redirect('/reports'); } - resolveUserFields(reportTemplate.userFieldsObject, report.paramsObject, (err, inputs) => { - if (err) { - req.flash('danger', err.message || err); - return res.redirect('/reports'); + if (report.state == 1) { + if (reportTemplate.mimeType == 'text/html') { + + fs.readFile(path.join(__dirname, '../protected/reports', report.filename + '.report'), (err, reportContent) => { + if (err) { + req.flash('danger', err && err.message || err || _('Could not find report with specified ID')); + return res.redirect('/reports'); + } + + const data = { + csrfToken: req.csrfToken(), + report: new hbs.handlebars.SafeString(reportContent), + title: report.name + }; + + res.render('reports/view', data); + }); + + } else if (reportTemplate.mimeType == 'text/csv') { + const headers = { + 'Content-Disposition': 'attachment;filename=' + fsTools.nameToFileName(report.name) + '.csv', + 'Content-Type': 'text/csv' + }; + + res.sendFile(path.join(__dirname, '../protected/reports', report.filename + '.report'), {headers: headers}); + + } else { + req.flash('danger', _('Unknown type of template')); + res.redirect('/reports'); } - const sandbox = { - require: require, - inputs: inputs, - callback: (err, outputs) => { - if (err) { - req.flash('danger', err.message || err); - return res.redirect('/reports'); - } - - const hbsTmpl = hbs.handlebars.compile(reportTemplate.hbs); - const reportText = hbsTmpl(outputs); - - if (reportTemplate.mimeType == 'text/html') { - const data = { - csrfToken: req.csrfToken(), - report: new hbs.handlebars.SafeString(reportText), - title: outputs.title - }; - - res.render('reports/view', data); - - } else if (reportTemplate.mimeType == 'text/csv') { - res.set('Content-Disposition', 'attachment;filename=' + toFileName(report.name) + '.csv'); - res.set('Content-Type', 'text/csv'); - res.send(new Buffer(reportText)); - - } else { - req.flash('danger', _('Unknown type of template')); - return res.redirect('/reports'); - } - } - }; - - const script = new vm.Script(reportTemplate.js); - script.runInNewContext(sandbox, { displayErrors: true, timeout: 10000 }); - }); + } else { + req.flash('danger', err && err.message || err || _('Could not find report with specified ID')); + return res.redirect('/reports'); + } }); }); }); -function toFileName(name) { - return name. - trim(). - toLowerCase(). - replace(/[ .+/]/g, '-'). - replace(/[^a-z0-9\-_]/gi, ''); -} - -function resolveEntities(getter, ids, callback) { - const idsRemaining = ids.slice(); - const resolved = []; - - function doWork() { - if (idsRemaining.length == 0) { - return callback(null, resolved); - } - - getter(idsRemaining.shift(), (err, entity) => { - if (err) { - return callback(err); - } - - resolved.push(entity); - return doWork(); - }); - } - - setImmediate(doWork); -} - -const userFieldTypeToGetter = { - 'campaign': (id, callback) => campaigns.get(id, false, callback), - 'list': lists.get -}; - -function resolveUserFields(userFields, params, callback) { - const userFieldsRemaining = userFields.slice(); - const resolved = {}; - - function doWork() { - if (userFieldsRemaining.length == 0) { - return callback(null, resolved); - } - - const spec = userFieldsRemaining.shift(); - const getter = userFieldTypeToGetter[spec.type]; - - if (getter) { - return resolveEntities(getter, params[spec.id], (err, entities) => { - if (spec.minOccurences == 1 && spec.maxOccurences == 1) { - resolved[spec.id] = entities[0]; - } else { - resolved[spec.id] = entities; - } - - doWork(); - }); - } else { - return callback(new Error(_('Unknown user field type "' + spec.type + '".'))); - } - } - - setImmediate(doWork); -} - function addUserFields(reportTemplateId, reqData, report, callback) { reportTemplates.get(reportTemplateId, (err, reportTemplate) => { if (err) { diff --git a/services/reports.js b/services/reports.js new file mode 100644 index 00000000..866badc8 --- /dev/null +++ b/services/reports.js @@ -0,0 +1,146 @@ +'use strict'; + +const reports = require('../lib/models/reports'); +const reportTemplates = require('../lib/models/report-templates'); +const lists = require('../lib/models/lists'); +const campaigns = require('../lib/models/campaigns'); +const handlebars = require('handlebars'); +const handlebarsHelpers = require('../lib/handlebars-helpers'); +const _ = require('../lib/translate')._; +const hbs = require('hbs'); +const vm = require('vm'); +const log = require('npmlog'); +const fs = require('fs'); +const path = require('path'); +const fsTools = require('../lib/fs-tools'); + +handlebarsHelpers.registerHelpers(handlebars); + +function resolveEntities(getter, ids, callback) { + const idsRemaining = ids.slice(); + const resolved = []; + + function doWork() { + if (idsRemaining.length == 0) { + return callback(null, resolved); + } + + getter(idsRemaining.shift(), (err, entity) => { + if (err) { + return callback(err); + } + + resolved.push(entity); + return doWork(); + }); + } + + setImmediate(doWork); +} + +const userFieldTypeToGetter = { + 'campaign': (id, callback) => campaigns.get(id, false, callback), + 'list': lists.get +}; + +function resolveUserFields(userFields, params, callback) { + const userFieldsRemaining = userFields.slice(); + const resolved = {}; + + function doWork() { + if (userFieldsRemaining.length == 0) { + return callback(null, resolved); + } + + const spec = userFieldsRemaining.shift(); + const getter = userFieldTypeToGetter[spec.type]; + + if (getter) { + return resolveEntities(getter, params[spec.id], (err, entities) => { + if (spec.minOccurences == 1 && spec.maxOccurences == 1) { + resolved[spec.id] = entities[0]; + } else { + resolved[spec.id] = entities; + } + + doWork(); + }); + } else { + return callback(new Error(_('Unknown user field type "' + spec.type + '".'))); + } + } + + setImmediate(doWork); +} + +function doneSuccess(id) { + // TODO: Mark in the DB as completed + update the date/time + // TODO update the report filename in the DB + + process.exit(0); +} + +function doneFail(id) { + // TODO: Mark in the DB as failed + process.exit(1); +} + +function start(id) { + // TODO: Mark in the DB as running +} + +// TODO: Retrieve report task from the DB and run it + +function processReport(reportId) { + reports.get(reportId, (err, report) => { + if (err || !report) { + log.error('reports', err && err.message || err || _('Could not find report with specified ID')); + doneFail(reportId); + } + + reportTemplates.get(report.reportTemplate, (err, reportTemplate) => { + if (err) { + log.error('reports', err && err.message || err || _('Could not find report template')); + doneFail(reportId); + } + + resolveUserFields(reportTemplate.userFieldsObject, report.paramsObject, (err, inputs) => { + if (err) { + log.error('reports', err.message || err); + doneFail(reportId); + } + + const filename = fsTools.nameToFileName(report.name); + + const sandbox = { + require: require, + inputs: inputs, + callback: (err, outputs) => { + if (err) { + log.error('reports', err.message || err); + doneFail(reportId); + } + + const hbsTmpl = handlebars.compile(reportTemplate.hbs); + const reportText = hbsTmpl(outputs); + + fs.writeFile(path.join(__dirname, '../protected/reports', filename + '.report'), reportText, (err, reportContent) => { + if (err) { + log.error('reports', err && err.message || err || _('Could not find report with specified ID')); + doneFail(reportId); + } + + doneSuccess(reportId, filename); + process + }); + } + }; + + const script = new vm.Script(reportTemplate.js); + script.runInNewContext(sandbox, {displayErrors: true, timeout: 120000}); + }); + }); + }); +} + +processReport(1); diff --git a/setup/sql/upgrade-00027.sql b/setup/sql/upgrade-00027.sql index 628d6335..2721dcdc 100644 --- a/setup/sql/upgrade-00027.sql +++ b/setup/sql/upgrade-00027.sql @@ -22,6 +22,9 @@ CREATE TABLE `reports` ( `description` text, `report_template` int(11) unsigned NOT NULL, `params` longtext, + `filename` varchar(255) DEFAULT NULL, + `state` int(11) unsigned NOT NULL DEFAULT 0, + `last_run` DATETIME DEFAULT NULL, `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `report_template` (`report_template`), From 8237dd5d77d5164587d53c073ea7470fef7da244 Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Thu, 20 Apr 2017 19:42:01 -0400 Subject: [PATCH 07/30] The "Reports" feature seems functional. Some small refactoring (column widths) of rendering tables in Lists, Templates, and Campaigns so that it is the same as Reports. --- index.js | 33 +- lib/fs-tools.js | 14 - lib/models/campaigns.js | 2 +- lib/models/lists.js | 2 +- lib/models/report-templates.js | 2 +- lib/models/reports.js | 66 +++- lib/models/subscriptions.js | 2 +- lib/models/templates.js | 2 +- lib/table-helpers.js | 12 +- lib/tools.js | 10 + public/css/mailtrain.css | 8 + public/javascript/tables.js | 314 +++++++++++------- routes/report-templates.js | 2 +- routes/reports.js | 138 ++++++-- ...{reports.js => report-processor-worker.js} | 33 +- services/report-processor.js | 154 +++++++++ setup/sql/upgrade-00027.sql | 1 - views/campaigns/campaigns.hbs | 4 +- views/lists/lists.hbs | 4 +- views/reports/output.hbs | 8 + views/reports/reports.hbs | 5 +- views/templates/templates.hbs | 4 +- 22 files changed, 584 insertions(+), 236 deletions(-) delete mode 100644 lib/fs-tools.js rename services/{reports.js => report-processor-worker.js} (80%) create mode 100644 services/report-processor.js create mode 100644 views/reports/output.hbs diff --git a/index.js b/index.js index 1ef2340f..57b9d482 100644 --- a/index.js +++ b/index.js @@ -18,6 +18,7 @@ let tzupdate = require('./services/tzupdate'); let feedcheck = require('./services/feedcheck'); let dbcheck = require('./lib/dbcheck'); let tools = require('./lib/tools'); +let reportProcessor = require('./services/report-processor'); let port = config.www.port; let host = config.www.host; @@ -120,23 +121,25 @@ server.on('listening', () => { spawnSenders(() => { feedcheck(() => { postfixBounceServer(() => { - log.info('Service', 'All services started'); - if (config.group) { - try { - process.setgid(config.group); - log.info('Service', 'Changed group to "%s" (%s)', config.group, process.getgid()); - } catch (E) { - log.info('Service', 'Failed to change group to "%s" (%s)', config.group, E.message); + reportProcessor.init(() => { + log.info('Service', 'All services started'); + if (config.group) { + try { + process.setgid(config.group); + log.info('Service', 'Changed group to "%s" (%s)', config.group, process.getgid()); + } catch (E) { + log.info('Service', 'Failed to change group to "%s" (%s)', config.group, E.message); + } } - } - if (config.user) { - try { - process.setuid(config.user); - log.info('Service', 'Changed user to "%s" (%s)', config.user, process.getuid()); - } catch (E) { - log.info('Service', 'Failed to change user to "%s" (%s)', config.user, E.message); + if (config.user) { + try { + process.setuid(config.user); + log.info('Service', 'Changed user to "%s" (%s)', config.user, process.getuid()); + } catch (E) { + log.info('Service', 'Failed to change user to "%s" (%s)', config.user, E.message); + } } - } + }); }); }); }); diff --git a/lib/fs-tools.js b/lib/fs-tools.js deleted file mode 100644 index 59f126e6..00000000 --- a/lib/fs-tools.js +++ /dev/null @@ -1,14 +0,0 @@ -'use strict'; - -module.exports = { - nameToFileName, -}; - -function nameToFileName(name) { - return name. - trim(). - toLowerCase(). - replace(/[ .+/]/g, '-'). - replace(/[^a-z0-9\-_]/gi, ''). - replace(/--*/g, '-'); -} diff --git a/lib/models/campaigns.js b/lib/models/campaigns.js index e9007dcf..1291cd78 100644 --- a/lib/models/campaigns.js +++ b/lib/models/campaigns.js @@ -19,7 +19,7 @@ let tableHelpers = require('../table-helpers'); let allowedKeys = ['description', 'from', 'address', 'reply_to', 'subject', 'editor_name', 'editor_data', 'template', 'source_url', 'list', 'segment', 'html', 'text', 'tracking_disabled']; module.exports.list = (start, limit, callback) => { - tableHelpers.list('campaigns', ['*'], 'scheduled', start, limit, callback); + tableHelpers.list('campaigns', ['*'], 'scheduled', null, start, limit, callback); }; module.exports.filter = (request, parent, callback) => { diff --git a/lib/models/lists.js b/lib/models/lists.js index 772a5820..28304722 100644 --- a/lib/models/lists.js +++ b/lib/models/lists.js @@ -10,7 +10,7 @@ let tableHelpers = require('../table-helpers'); let allowedKeys = ['description', 'default_form', 'public_subscribe']; module.exports.list = (start, limit, callback) => { - tableHelpers.list('lists', ['*'], 'name', start, limit, callback); + tableHelpers.list('lists', ['*'], 'name', null, start, limit, callback); }; module.exports.filter = (request, parent, callback) => { diff --git a/lib/models/report-templates.js b/lib/models/report-templates.js index 195472a3..e43bd7d8 100644 --- a/lib/models/report-templates.js +++ b/lib/models/report-templates.js @@ -8,7 +8,7 @@ const _ = require('../translate')._; const allowedKeys = ['name', 'description', 'mime_type', 'user_fields', 'js', 'hbs']; module.exports.list = (start, limit, callback) => { - tableHelpers.list('report_templates', ['*'], 'name', start, limit, callback); + tableHelpers.list('report_templates', ['*'], 'name', null, start, limit, callback); }; module.exports.quicklist = callback => { diff --git a/lib/models/reports.js b/lib/models/reports.js index 2d972f36..31a53c4f 100644 --- a/lib/models/reports.js +++ b/lib/models/reports.js @@ -8,16 +8,29 @@ const tools = require('../tools'); const _ = require('../translate')._; const log = require('npmlog'); -const allowedKeys = ['name', 'description', 'report_template', 'params', 'filename']; +const allowedKeys = ['name', 'description', 'report_template', 'params']; + +const ReportState = { + SCHEDULED: 0, + PROCESSING: 1, + FINISHED: 2, + FAILED: 3 +}; + +module.exports.ReportState = ReportState; module.exports.list = (start, limit, callback) => { - tableHelpers.list('reports', ['*'], 'name', start, limit, callback); + tableHelpers.list('reports', ['*'], 'name', null, start, limit, callback); }; +module.exports.listWithState = (state, start, limit, callback) => { + tableHelpers.list('reports', ['*'], 'name', { where: 'state=?', values: [state] }, start, limit, callback); +} + module.exports.filter = (request, callback) => { tableHelpers.filter('reports JOIN report_templates ON reports.report_template = report_templates.id', - ['reports.id AS id', 'reports.name AS name', 'reports.description AS description', 'reports.state AS state', 'reports.filename AS filename', 'reports.report_template AS report_template', 'reports.params AS params', 'reports.created AS created', 'report_templates.name AS report_template_name', 'report_templates.mime_type AS mime_type' ], - request, ['#', 'name', 'report_templates.name', 'description', 'created'], ['name'], 'created DESC', null, callback); + ['reports.id AS id', 'reports.name AS name', 'reports.description AS description', 'reports.state AS state', 'reports.report_template AS report_template', 'reports.params AS params', 'reports.last_run AS last_run', 'report_templates.name AS report_template_name', 'report_templates.mime_type AS mime_type' ], + request, ['#', 'name', 'report_templates.name', 'description', 'last_run'], ['name'], 'name ASC', null, callback); }; module.exports.get = (id, callback) => { @@ -60,29 +73,56 @@ module.exports.get = (id, callback) => { }); }; -module.exports.createOrUpdate = (createMode, data, callback) => { - data = data || {}; +// This method is not supposed to be used for unsanitized inputs. It does not do any checks. +module.exports.updateFields = (id, fieldValueMap, callback) => { + db.getConnection((err, connection) => { + if (err) { + return next(err); + } - const id = 'id' in data ? Number(data.id) : 0; + const clauses = []; + const values = []; + for (let key of Object.keys(fieldValueMap)) { + clauses.push(tools.toDbKey(key) + '=?'); + values.push(fieldValueMap[key]); + } + + values.push(id); + + const query = 'UPDATE reports SET ' + clauses.join(', ') + ' WHERE id=? LIMIT 1'; + connection.query(query, values, (err, result) => { + connection.release(); + if (err) { + return callback(err); + } + + return callback(null, result && result.affectedRows || false) + }); + }); +}; + +module.exports.createOrUpdate = (createMode, report, callback) => { + report = report || {}; + + const id = 'id' in report ? Number(report.id) : 0; if (!createMode && id < 1) { return callback(new Error(_('Missing report ID'))); } - const template = tools.convertKeys(data); - const name = (template.name || '').toString().trim(); + const name = (report.name || '').toString().trim(); if (!name) { return callback(new Error(_('Report name must be set'))); } - const reportTemplateId = Number(template.reportTemplate); + const reportTemplateId = Number(report.reportTemplate); reportTemplates.get(reportTemplateId, (err, reportTemplate) => { if (err) { callback(err); } - const params = data.paramsObject; + const params = report.paramsObject; for (const spec of reportTemplate.userFieldsObject) { if (params[spec.id].length < spec.minOccurences) { return callback(new Error(_('At least ' + spec.minOccurences + ' rows in "' + spec.name + '" have to be selected.'))); @@ -97,8 +137,8 @@ module.exports.createOrUpdate = (createMode, data, callback) => { const values = [name, JSON.stringify(params)]; - Object.keys(template).forEach(key => { - let value = typeof template[key] === 'number' ? template[key] : (template[key] || '').toString().trim(); + Object.keys(report).forEach(key => { + let value = typeof report[key] === 'number' ? report[key] : (report[key] || '').toString().trim(); key = tools.toDbKey(key); if (key === 'description') { diff --git a/lib/models/subscriptions.js b/lib/models/subscriptions.js index f5fb5f84..99d32be0 100644 --- a/lib/models/subscriptions.js +++ b/lib/models/subscriptions.js @@ -21,7 +21,7 @@ module.exports.list = (listId, start, limit, callback) => { return callback(new Error('Missing List ID')); } - tableHelpers.list('subscription__' + listId, ['*'], 'email', start, limit, (err, rows, total) => { + tableHelpers.list('subscription__' + listId, ['*'], 'email', null, start, limit, (err, rows, total) => { if (!err) { rows = rows.map(row => tools.convertKeys(row)); } diff --git a/lib/models/templates.js b/lib/models/templates.js index 09f0abb3..a6723eef 100644 --- a/lib/models/templates.js +++ b/lib/models/templates.js @@ -8,7 +8,7 @@ let tableHelpers = require('../table-helpers'); let allowedKeys = ['description', 'editor_name', 'editor_data', 'html', 'text']; module.exports.list = (start, limit, callback) => { - tableHelpers.list('templates', ['*'], 'name', start, limit, callback); + tableHelpers.list('templates', ['*'], 'name', null, start, limit, callback); }; module.exports.filter = (request, parent, callback) => { diff --git a/lib/table-helpers.js b/lib/table-helpers.js index f5c2e6bc..2c082c2c 100644 --- a/lib/table-helpers.js +++ b/lib/table-helpers.js @@ -4,7 +4,7 @@ let db = require('./db'); let tools = require('./tools'); let log = require('npmlog'); -module.exports.list = (source, fields, orderBy, start, limit, callback) => { +module.exports.list = (source, fields, orderBy, queryData, start, limit, callback) => { db.getConnection((err, connection) => { if (err) { return callback(err); @@ -22,7 +22,15 @@ module.exports.list = (source, fields, orderBy, start, limit, callback) => { } } - connection.query('SELECT SQL_CALC_FOUND_ROWS ' + fields.join(', ') + ' FROM ' + source + ' ORDER BY ' + orderBy + ' DESC' + limitQuery, limitValues, (err, rows) => { + let whereClause = ''; + let whereValues = []; + + if (queryData) { + whereClause = ' WHERE ' + queryData.where; + whereValues = queryData.values; + } + + connection.query('SELECT SQL_CALC_FOUND_ROWS ' + fields.join(', ') + ' FROM ' + source + whereClause + ' ORDER BY ' + orderBy + ' DESC' + limitQuery, whereValues.concat(limitValues), (err, rows) => { if (err) { connection.release(); return callback(err); diff --git a/lib/tools.js b/lib/tools.js index 080afdcb..7b3074a2 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -28,6 +28,7 @@ module.exports = { prepareHtml, purifyHTML, mergeTemplateIntoLayout, + nameToFileName, workers: new Set() }; @@ -300,3 +301,12 @@ function mergeTemplateIntoLayout(template, layout, callback) { return done(template, layout); } } + +function nameToFileName(name) { + return name. + trim(). + toLowerCase(). + replace(/[ .+/]/g, '-'). + replace(/[^a-z0-9\-_]/gi, ''). + replace(/--*/g, '-'); +} diff --git a/public/css/mailtrain.css b/public/css/mailtrain.css index f8a82266..f4bf6f2f 100644 --- a/public/css/mailtrain.css +++ b/public/css/mailtrain.css @@ -38,4 +38,12 @@ tbody>tr.selected { .table-hover>tbody>tr.selected:hover { background-color: rgb(205, 212, 226); +} + +.row-actions .row-action { + padding-right: 15px; +} + +.row-actions .row-action:last-child { + padding-right: 0px; } \ No newline at end of file diff --git a/public/javascript/tables.js b/public/javascript/tables.js index 3660e2db..be91079e 100644 --- a/public/javascript/tables.js +++ b/public/javascript/tables.js @@ -4,7 +4,62 @@ 'use strict'; -(function(){ +(function() { + function refreshTargets(data) { + for (var target in data) { + var newContent = $(data[target]); + + $(target).replaceWith(newContent); + installHandlers(newContent.parent()); + } + } + + function getAjaxUrl(self) { + var topicId = self.data('topicId'); + var topicUrl = self.data('topicUrl'); + + return topicUrl + '/ajax/' + topicId; + } + + function setupAjaxRefresh() { + var self = $(this); + var ajaxUrl = getAjaxUrl(self); + + var interval = Number(self.data('interval')) || 60; + + setTimeout(function () { + $.get(ajaxUrl, function(data) { + refreshTargets(data); + }); + + }, interval * 1000); + } + + function setupAjaxAction() { + var self = $(this); + var ajaxUrl = getAjaxUrl(self); + + var processing = false; + + self.click(function () { + if (!processing) { + $.get(ajaxUrl, function (data) { + refreshTargets(data); + }); + + processing = true; + } + + return false; + }); + } + + function setupDatestring() { + var self = $(this); + self.html(moment(self.data('date')).fromNow()); + } + + function getDataTableOptions(elem) { var rowSort = $(elem).data('rowSort') || false; @@ -92,6 +147,15 @@ return opts; } + + function installHandlers(elem) { + $('.ajax-refresh', elem).each(setupAjaxRefresh); + $('.ajax-action', elem).each(setupAjaxAction); + $('.datestring', elem).each(setupDatestring); + } + + installHandlers($(document)); + $('.data-table').each(function () { var opts = getDataTableOptions(this); $(this).DataTable(opts); @@ -112,131 +176,135 @@ opts.serverSide = true; opts.processing = true; + opts.createdRow = function( row, data, dataIndex ) { + installHandlers($(row)); + } + $(this).DataTable(opts).on('draw', function () { - $('.datestring').each(function () { - $(this).html(moment($(this).data('date')).fromNow()); - }); + $('.datestring').each(setupDatestring); }); }); + $('.data-stats-pie-chart').each(function () { + var column = $(this).data('column') || 'country'; + var limit = $(this).data('limit') || 20; + var topicId = $(this).data('topicId'); + var topicUrl = $(this).data('topicUrl') || '/campaigns/clicked'; + var ajaxUrl = topicUrl + '/ajax/' + topicId + '/stats'; + var self = $(this); + + $.post(ajaxUrl, {column: column, limit: limit}, function(data) { + google.charts.load('current', {'packages':['corechart']}); + google.charts.setOnLoadCallback(drawChart); + + function drawChart() { + var gTable = new google.visualization.DataTable(); + gTable.addColumn('string', 'Column'); + gTable.addColumn('number', 'Value'); + gTable.addRows(data.data); + + var options = {'width':500, 'height':400}; + var chart = new google.visualization.PieChart(self[0]); + chart.draw(gTable, options); + } + }); + }); + + $('.datestring').each(function () { + $(this).html(moment($(this).data('date')).fromNow()); + }); + + $('.delete-form,.confirm-submit').on('submit', function (e) { + if (!confirm($(this).data('confirmMessage') || 'Are you sure? This action can not be undone')) { + e.preventDefault(); + } + }); + + $('.fm-date-us.date').datepicker({ + format: 'mm/dd/yyyy', + weekStart: 0, + autoclose: true + }); + + $('.fm-date-eur.date').datepicker({ + format: 'dd/mm/yyyy', + weekStart: 1, + autoclose: true + }); + + $('.fm-date-generic.date').datepicker({ + format: 'yyyy-mm-dd', + weekStart: 1, + autoclose: true + }); + + $('.fm-birthday-us.date').datepicker({ + format: 'mm/dd', + weekStart: 0, + autoclose: true + }); + + $('.fm-birthday-eur.date').datepicker({ + format: 'dd/mm', + weekStart: 1, + autoclose: true + }); + + $('.fm-birthday-generic.date').datepicker({ + format: 'mm-dd', + weekStart: 1, + autoclose: true + }); + + $('.page-refresh').each(function () { + var interval = Number($(this).data('interval')) || 60; + setTimeout(function () { + window.location.reload(); + }, interval * 1000); + }); + + + $('.click-select').on('click', function () { + $(this).select(); + }); + + if (typeof moment.tz !== 'undefined') { + (function () { + var tz = moment.tz.guess(); + if (tz) { + $('.tz-detect').val(tz); + } + })(); + } + + // setup SMTP check + var smtpForm = document.querySelector('form#smtp-verify'); + if (smtpForm) { + smtpForm.addEventListener('submit', function (e) { + e.preventDefault(); + + var form = document.getElementById('settings-form'); + var formData = new FormData(form); + var result = fetch('/settings/smtp-verify', { + method: 'POST', + body: formData, + credentials: 'same-origin' + }); + + var $btn = $('#verify-button').button('loading'); + + result.then(function (res) { + return res.json(); + }).then(function (data) { + alert(data.error ? 'Invalid Mailer settings\n' + data.error : data.message); + $btn.button('reset'); + }).catch(function (err) { + alert(err.message); + $btn.button('reset'); + }); + + }); + } + })(); -$('.data-stats-pie-chart').each(function () { - var column = $(this).data('column') || 'country'; - var limit = $(this).data('limit') || 20; - var topicId = $(this).data('topicId'); - var topicUrl = $(this).data('topicUrl') || '/campaigns/clicked'; - var ajaxUrl = topicUrl + '/ajax/' + topicId + '/stats'; - var self = $(this); - - $.post(ajaxUrl, {column: column, limit: limit}, function(data) { - google.charts.load('current', {'packages':['corechart']}); - google.charts.setOnLoadCallback(drawChart); - - function drawChart() { - var gTable = new google.visualization.DataTable(); - gTable.addColumn('string', 'Column'); - gTable.addColumn('number', 'Value'); - gTable.addRows(data.data); - - var options = {'width':500, 'height':400}; - var chart = new google.visualization.PieChart(self[0]); - chart.draw(gTable, options); - } - }); -}); - -$('.datestring').each(function () { - $(this).html(moment($(this).data('date')).fromNow()); -}); - -$('.delete-form,.confirm-submit').on('submit', function (e) { - if (!confirm($(this).data('confirmMessage') || 'Are you sure? This action can not be undone')) { - e.preventDefault(); - } -}); - -$('.fm-date-us.date').datepicker({ - format: 'mm/dd/yyyy', - weekStart: 0, - autoclose: true -}); - -$('.fm-date-eur.date').datepicker({ - format: 'dd/mm/yyyy', - weekStart: 1, - autoclose: true -}); - -$('.fm-date-generic.date').datepicker({ - format: 'yyyy-mm-dd', - weekStart: 1, - autoclose: true -}); - -$('.fm-birthday-us.date').datepicker({ - format: 'mm/dd', - weekStart: 0, - autoclose: true -}); - -$('.fm-birthday-eur.date').datepicker({ - format: 'dd/mm', - weekStart: 1, - autoclose: true -}); - -$('.fm-birthday-generic.date').datepicker({ - format: 'mm-dd', - weekStart: 1, - autoclose: true -}); - -$('.page-refresh').each(function () { - var interval = Number($(this).data('interval')) || 60; - setTimeout(function () { - window.location.reload(); - }, interval * 1000); -}); - -$('.click-select').on('click', function () { - $(this).select(); -}); - -if (typeof moment.tz !== 'undefined') { - (function () { - var tz = moment.tz.guess(); - if (tz) { - $('.tz-detect').val(tz); - } - })(); -} - -// setup SMTP check -var smtpForm = document.querySelector('form#smtp-verify'); -if (smtpForm) { - smtpForm.addEventListener('submit', function (e) { - e.preventDefault(); - - var form = document.getElementById('settings-form'); - var formData = new FormData(form); - var result = fetch('/settings/smtp-verify', { - method: 'POST', - body: formData, - credentials: 'same-origin' - }); - - var $btn = $('#verify-button').button('loading'); - - result.then(function (res) { - return res.json(); - }).then(function (data) { - alert(data.error ? 'Invalid Mailer settings\n' + data.error : data.message); - $btn.button('reset'); - }).catch(function (err) { - alert(err.message); - $btn.button('reset'); - }); - - }); -} diff --git a/routes/report-templates.js b/routes/report-templates.js index 638502fd..5393ccb4 100644 --- a/routes/report-templates.js +++ b/routes/report-templates.js @@ -93,7 +93,7 @@ router.get('/create', passport.csrfProtection, (req, res) => { '

    {{title}}

    \n' + '\n' + '
    \n' + - ' \n' + + '
    \n' + ' \n' + '
    \n' + ' {{#translate}}Email{{/translate}}\n' + diff --git a/routes/reports.js b/routes/reports.js index 5006668c..af15abce 100644 --- a/routes/reports.js +++ b/routes/reports.js @@ -6,6 +6,7 @@ const router = new express.Router(); const _ = require('../lib/translate')._; const reportTemplates = require('../lib/models/report-templates'); const reports = require('../lib/models/reports'); +const reportProcessor = require('../services/report-processor'); const campaigns = require('../lib/models/campaigns'); const lists = require('../lib/models/lists'); const tools = require('../lib/tools'); @@ -13,7 +14,7 @@ const util = require('util'); const htmlescape = require('escape-html'); const striptags = require('striptags'); const fs = require('fs'); -const fsTools = require('../lib/fs-tools'); +const hbs = require('hbs'); router.all('/*', (req, res, next) => { if (!req.user) { @@ -30,26 +31,9 @@ router.get('/', (req, res) => { }); }); + + router.post('/ajax', (req, res) => { - function getViewLink(row) { - if (row.state == 0) { - // TODO: Render waiting - // TODO: Add error output - return ' '; - } else if (row.state == 1) { - let icon = 'eye-open'; - if (row.mimeType == 'text/csv') icon = 'download-alt'; - - // TODO: Add error output - return ' '; - } else if (row.state == 2) { - // TODO: Add error output - return ' '; - } - - return ''; - } - reports.filter(req.body, (err, data, total, filteredTotal) => { if (err) { return res.json({ @@ -67,14 +51,29 @@ router.post('/ajax', (req, res) => { htmlescape(row.name || ''), htmlescape(row.reportTemplateName || ''), htmlescape(striptags(row.description) || ''), - '' + row.created.toISOString() + '', - getViewLink(row) + - ''] - ) + getRowLastRun(row), + getRowActions(row) + ]) }); }); }); +router.get('/row/ajax/:id', (req, res) => { + respondRowActions(req.params.id, res); +}); + +router.get('/start/ajax/:id', (req, res) => { + reportProcessor.start(req.params.id, () => { + respondRowActions(req.params.id, res); + }); +}); + +router.get('/stop/ajax/:id', (req, res) => { + reportProcessor.stop(req.params.id, () => { + respondRowActions(req.params.id, res); + }); +}); + router.get('/create', passport.csrfProtection, (req, res) => { const reqData = req.query; reqData.csrfToken = req.csrfToken(); @@ -116,7 +115,6 @@ router.get('/create', passport.csrfProtection, (req, res) => { router.post('/create', passport.parseForm, passport.csrfProtection, (req, res) => { const reqData = req.body; - delete reqData.filename; // This is to make sure no one inserts a fake filename when editing the report. const reportTemplateId = Number(reqData.reportTemplate); @@ -131,6 +129,9 @@ router.post('/create', passport.parseForm, passport.csrfProtection, (req, res) = req.flash('danger', err && err.message || err || _('Could not create report')); return res.redirect('/reports/create?' + tools.queryParams(data)); } + + reportProcessor.start(id); + req.flash('success', util.format(_('Report “%s” created'), data.name)); res.redirect('/reports'); }); @@ -179,8 +180,6 @@ router.get('/edit/:id', passport.csrfProtection, (req, res) => { router.post('/edit', passport.parseForm, passport.csrfProtection, (req, res) => { const reqData = req.body; - delete reqData.filename; // This is to make sure no one inserts a fake filename when editing the report. - const reportTemplateId = Number(reqData.reportTemplate); addParamsObject(reportTemplateId, reqData, (err, data) => { @@ -231,10 +230,10 @@ router.get('/view/:id', passport.csrfProtection, (req, res) => { return res.redirect('/reports'); } - if (report.state == 1) { + if (report.state == reports.ReportState.FINISHED) { if (reportTemplate.mimeType == 'text/html') { - fs.readFile(path.join(__dirname, '../protected/reports', report.filename + '.report'), (err, reportContent) => { + fs.readFile(reportProcessor.getFileName(report, 'report'), (err, reportContent) => { if (err) { req.flash('danger', err && err.message || err || _('Could not find report with specified ID')); return res.redirect('/reports'); @@ -251,11 +250,11 @@ router.get('/view/:id', passport.csrfProtection, (req, res) => { } else if (reportTemplate.mimeType == 'text/csv') { const headers = { - 'Content-Disposition': 'attachment;filename=' + fsTools.nameToFileName(report.name) + '.csv', + 'Content-Disposition': 'attachment;filename=' + tools.nameToFileName(report.name) + '.csv', 'Content-Type': 'text/csv' }; - res.sendFile(path.join(__dirname, '../protected/reports', report.filename + '.report'), {headers: headers}); + res.sendFile(reportProcessor.getFileName(report, 'report'), {headers: headers}); } else { req.flash('danger', _('Unknown type of template')); @@ -270,6 +269,83 @@ router.get('/view/:id', passport.csrfProtection, (req, res) => { }); }); +router.get('/output/:id', passport.csrfProtection, (req, res) => { + reports.get(req.params.id, (err, report) => { + if (err || !report) { + req.flash('danger', err && err.message || err || _('Could not find report with specified ID')); + return res.redirect('/reports'); + } + + fs.readFile(reportProcessor.getFileName(report, 'output'), (err, output) => { + let data = { + csrfToken: req.csrfToken(), + title: 'Output for report ' + report.name + }; + + if (err) { + data.error = 'No output.'; + } else { + data.output = output; + } + + res.render('reports/output', data); + }); + }); +}); + +function getRowLastRun(row) { + return '' + (row.lastRun ? '' + row.lastRun.toISOString() + '' : '') + ''; +} + +function getRowActions(row) { + let requestRefresh = false; + let view, startStop; + let topic = 'data-topic-id="' + row.id + '"'; + + if (row.state == reports.ReportState.PROCESSING || row.state == reports.ReportState.SCHEDULED) { + view = ''; + startStop = ''; + requestRefresh = true; + + } else if (row.state == reports.ReportState.FINISHED) { + let icon = 'eye-open'; + if (row.mimeType == 'text/csv') icon = 'download-alt'; + + view = ''; + startStop = ''; + + } else if (row.state == reports.ReportState.FAILED) { + view = ''; + startStop = ''; + } + + let actions = view; + actions += ''; + actions += startStop; + actions += ''; + + return '' + + actions + + ''; +} + +function respondRowActions(id, res) { + reports.get(id, (err, report) => { + if (err) { + return res.json({ + error: err, + }); + } + + const data = {}; + data['#row-last-run-' + id] = getRowLastRun(report); + data['#row-actions-' + id] = getRowActions(report); + + res.json(data); + }); +} + + function addUserFields(reportTemplateId, reqData, report, callback) { reportTemplates.get(reportTemplateId, (err, reportTemplate) => { if (err) { diff --git a/services/reports.js b/services/report-processor-worker.js similarity index 80% rename from services/reports.js rename to services/report-processor-worker.js index 866badc8..d4141bd6 100644 --- a/services/reports.js +++ b/services/report-processor-worker.js @@ -11,8 +11,7 @@ const hbs = require('hbs'); const vm = require('vm'); const log = require('npmlog'); const fs = require('fs'); -const path = require('path'); -const fsTools = require('../lib/fs-tools'); +const reportProcessor = require('./report-processor'); handlebarsHelpers.registerHelpers(handlebars); @@ -74,64 +73,52 @@ function resolveUserFields(userFields, params, callback) { } function doneSuccess(id) { - // TODO: Mark in the DB as completed + update the date/time - // TODO update the report filename in the DB - process.exit(0); } function doneFail(id) { - // TODO: Mark in the DB as failed process.exit(1); } -function start(id) { - // TODO: Mark in the DB as running -} - -// TODO: Retrieve report task from the DB and run it - function processReport(reportId) { reports.get(reportId, (err, report) => { if (err || !report) { log.error('reports', err && err.message || err || _('Could not find report with specified ID')); - doneFail(reportId); + doneFail(); } reportTemplates.get(report.reportTemplate, (err, reportTemplate) => { if (err) { log.error('reports', err && err.message || err || _('Could not find report template')); - doneFail(reportId); + doneFail(); } resolveUserFields(reportTemplate.userFieldsObject, report.paramsObject, (err, inputs) => { if (err) { log.error('reports', err.message || err); - doneFail(reportId); + doneFail(); } - const filename = fsTools.nameToFileName(report.name); - const sandbox = { require: require, inputs: inputs, + console: console, callback: (err, outputs) => { if (err) { log.error('reports', err.message || err); - doneFail(reportId); + doneFail(); } const hbsTmpl = handlebars.compile(reportTemplate.hbs); const reportText = hbsTmpl(outputs); - fs.writeFile(path.join(__dirname, '../protected/reports', filename + '.report'), reportText, (err, reportContent) => { + fs.writeFile(reportProcessor.getFileName(report, 'report'), reportText, (err, reportContent) => { if (err) { log.error('reports', err && err.message || err || _('Could not find report with specified ID')); - doneFail(reportId); + doneFail(); } - doneSuccess(reportId, filename); - process + doneSuccess(); }); } }; @@ -143,4 +130,4 @@ function processReport(reportId) { }); } -processReport(1); +processReport(Number(process.argv[2])); diff --git a/services/report-processor.js b/services/report-processor.js new file mode 100644 index 00000000..be6cadf1 --- /dev/null +++ b/services/report-processor.js @@ -0,0 +1,154 @@ +'use strict'; + +const log = require('npmlog'); +const db = require('../lib/db'); +const reports = require('../lib/models/reports'); +const _ = require('../lib/translate')._; +const path = require('path'); +const tools = require('../lib/tools'); +const fs = require('fs'); +const fork = require('child_process').fork; + +let runningWorkersCount = 0; +let maxWorkersCount = 1; + +let workers = {}; + +function getFileName(report, suffix) { + return path.join(__dirname, '..', 'protected', 'reports', report.id + '-' + tools.nameToFileName(report.name) + '.' + suffix); +} + +module.exports.getFileName = getFileName; + +function spawnWorker(report) { + + fs.open(getFileName(report, 'output'), 'w', (err, outFd) => { + if (err) { + log.error('ReportProcessor', err); + return; + } + + runningWorkersCount++; + + const options = { + stdio: ['ignore', outFd, outFd, 'ipc'], + cwd: path.join(__dirname, '..') + }; + + let child = fork(path.join(__dirname, 'report-processor-worker.js'), [report.id], options); + let pid = child.pid; + workers[report.id] = child; + + log.info('ReportProcessor', 'Worker process for "%s" started with pid %s. Current worker count is %s.', report.name, pid, runningWorkersCount); + + child.on('close', (code, signal) => { + runningWorkersCount--; + + delete workers[report.id]; + log.info('ReportProcessor', 'Worker process for "%s" (pid %s) exited with code %s signal %s. Current worker count is %s.', report.name, pid, code, signal, runningWorkersCount); + + fs.close(outFd, (err) => { + if (err) { + log.error('ReportProcessor', err); + } + + const fields = {}; + if (code ===0 ) { + fields.state = reports.ReportState.FINISHED; + fields.lastRun = new Date(); + } else { + fields.state = reports.ReportState.FAILED; + } + + reports.updateFields(report.id, fields, (err) => { + if (err) { + log.error('ReportProcessor', err); + } + + setImmediate(worker); + }); + }); + }); + }); +}; + +function worker() { + reports.listWithState(reports.ReportState.SCHEDULED, 0, maxWorkersCount - runningWorkersCount, (err, reportList) => { + if (err) { + log.error('ReportProcessor', err); + return; + } + + for (let report of reportList) { + reports.updateFields(report.id, { state: reports.ReportState.PROCESSING }, (err) => { + if (err) { + log.error('ReportProcessor', err); + return; + } + + spawnWorker(report); + }); + } + }); +} + +module.exports.start = (reportId, callback) => { + if (!workers[reportId]) { + log.info('ReportProcessor', 'Scheduling report id: %s', reportId); + reports.updateFields(reportId, { state: reports.ReportState.SCHEDULED, lastRun: null}, (err) => { + if (err) { + return callback(err); + } + + if (runningWorkersCount < maxWorkersCount) { + log.info('ReportProcessor', 'Starting worker because runningWorkersCount=%s maxWorkersCount=%s', runningWorkersCount, maxWorkersCount); + + worker(); + } else { + log.info('ReportProcessor', 'Not starting worker because runningWorkersCount=%s maxWorkersCount=%s', runningWorkersCount, maxWorkersCount); + } + + callback(null); + }); + } else { + log.info('ReportProcessor', 'Worker for report id: %s is already running.', reportId); + } +}; + +module.exports.stop = (reportId, callback) => { + const child = workers[reportId]; + if (child) { + log.info('ReportProcessor', 'Killing worker for report id: %s', reportId); + child.kill(); + reports.updateFields(reportId, { state: reports.ReportState.FAILED}, callback); + } else { + log.info('ReportProcessor', 'No running worker found for report id: %s', reportId); + } +}; + +module.exports.init = (callback) => { + reports.listWithState(reports.ReportState.PROCESSING, 0, 0, (err, reportList) => { + if (err) { + log.error('ReportProcessor', err); + } + + function scheduleReport() { + if (reportList.length > 0) { + const report = reportList.shift(); + + reports.updateFields(report.id, { state: reports.ReportState.SCHEDULED}, (err) => { + if (err) { + log.error('ReportProcessor', err); + } + + scheduleReport(); + }); + } + + worker(); + callback(); + } + + scheduleReport(); + }); +}; diff --git a/setup/sql/upgrade-00027.sql b/setup/sql/upgrade-00027.sql index 2721dcdc..0f098534 100644 --- a/setup/sql/upgrade-00027.sql +++ b/setup/sql/upgrade-00027.sql @@ -22,7 +22,6 @@ CREATE TABLE `reports` ( `description` text, `report_template` int(11) unsigned NOT NULL, `params` longtext, - `filename` varchar(255) DEFAULT NULL, `state` int(11) unsigned NOT NULL DEFAULT 0, `last_run` DATETIME DEFAULT NULL, `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/views/campaigns/campaigns.hbs b/views/campaigns/campaigns.hbs index 9ac302ea..39a01fb6 100644 --- a/views/campaigns/campaigns.hbs +++ b/views/campaigns/campaigns.hbs @@ -23,7 +23,7 @@
    - - diff --git a/views/lists/lists.hbs b/views/lists/lists.hbs index 93cc4918..82a935d1 100644 --- a/views/lists/lists.hbs +++ b/views/lists/lists.hbs @@ -14,7 +14,7 @@
    + # @@ -38,7 +38,7 @@ {{#translate}}Created{{/translate}} +  
    - - diff --git a/views/reports/output.hbs b/views/reports/output.hbs new file mode 100644 index 00000000..3da8d65f --- /dev/null +++ b/views/reports/output.hbs @@ -0,0 +1,8 @@ + + +{{error}} +
    {{output}}
    diff --git a/views/reports/reports.hbs b/views/reports/reports.hbs index 6998a8d2..99d2c40b 100644 --- a/views/reports/reports.hbs +++ b/views/reports/reports.hbs @@ -16,7 +16,7 @@
    + # @@ -29,7 +29,7 @@ {{#translate}}Description{{/translate}} +  
    - -
    + # @@ -31,9 +31,10 @@ {{#translate}}Created{{/translate}} +  
    + diff --git a/views/templates/templates.hbs b/views/templates/templates.hbs index 0314eb3e..e88aac21 100644 --- a/views/templates/templates.hbs +++ b/views/templates/templates.hbs @@ -14,7 +14,7 @@
    - - From bb4eb3832f5c7353431c829e5e4140419d20045b Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Thu, 20 Apr 2017 19:57:55 -0400 Subject: [PATCH 08/30] Some bugfixes to the previous commit. --- routes/reports.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/routes/reports.js b/routes/reports.js index af15abce..8e660ba4 100644 --- a/routes/reports.js +++ b/routes/reports.js @@ -130,10 +130,10 @@ router.post('/create', passport.parseForm, passport.csrfProtection, (req, res) = return res.redirect('/reports/create?' + tools.queryParams(data)); } - reportProcessor.start(id); - - req.flash('success', util.format(_('Report “%s” created'), data.name)); - res.redirect('/reports'); + reportProcessor.start(id, () => { + req.flash('success', util.format(_('Report “%s” created'), data.name)); + res.redirect('/reports'); + }); }); }); }); From 3072632d8d5a59e4c1b4ddda95c5de7aa833b3b2 Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Sun, 23 Apr 2017 15:24:31 -0400 Subject: [PATCH 09/30] Fixed eslint errors --- app.js | 1 - lib/handlebars-helpers.js | 95 ++++++++++++------------ lib/models/report-templates.js | 8 +- lib/models/reports.js | 31 ++++---- lib/table-helpers.js | 7 +- routes/reports.js | 4 +- views/reports/create-select-template.hbs | 1 - 7 files changed, 73 insertions(+), 74 deletions(-) diff --git a/app.js b/app.js index 715180a9..91af1fc0 100644 --- a/app.js +++ b/app.js @@ -4,7 +4,6 @@ const config = require('config'); const log = require('npmlog'); const _ = require('./lib/translate')._; -const util = require('util'); const express = require('express'); const bodyParser = require('body-parser'); diff --git a/lib/handlebars-helpers.js b/lib/handlebars-helpers.js index 9ee22109..10a38741 100644 --- a/lib/handlebars-helpers.js +++ b/lib/handlebars-helpers.js @@ -1,46 +1,49 @@ -'use strict'; - -const _ = require('../lib/translate')._; - -module.exports.registerHelpers = (handlebars) => { - // {{#translate}}abc{{/translate}} - handlebars.registerHelper('translate', function (context, options) { // eslint-disable-line prefer-arrow-callback - if (typeof options === 'undefined' && context) { - options = context; - context = false; - } - - let result = _(options.fn(this)); // eslint-disable-line no-invalid-this - - if (Array.isArray(context)) { - result = util.format(result, ...context); - } - return new handlebars.SafeString(result); - }); - - - /* Credits to http://chrismontrois.net/2016/01/30/handlebars-switch/ - - {{#switch letter}} - {{#case "a"}} - A is for alpaca - {{/case}} - {{#case "b"}} - B is for bluebird - {{/case}} - {{/switch}} - */ - handlebars.registerHelper("switch", function(value, options) { - this._switch_value_ = value; - var html = options.fn(this); // Process the body of the switch block - delete this._switch_value_; - return html; - }); - - handlebars.registerHelper("case", function(value, options) { - if (value == this._switch_value_) { - return options.fn(this); - } - }); - -}; +'use strict'; + +const util = require('util'); + +const _ = require('../lib/translate')._; + +module.exports.registerHelpers = handlebars => { + // {{#translate}}abc{{/translate}} + handlebars.registerHelper('translate', function (context, options) { // eslint-disable-line prefer-arrow-callback + if (typeof options === 'undefined' && context) { + options = context; + context = false; + } + + let result = _(options.fn(this)); // eslint-disable-line no-invalid-this + + if (Array.isArray(context)) { + result = util.format(result, ...context); + } + return new handlebars.SafeString(result); + }); + + + /* Credits to http://chrismontrois.net/2016/01/30/handlebars-switch/ + + {{#switch letter}} + {{#case "a"}} + A is for alpaca + {{/case}} + {{#case "b"}} + B is for bluebird + {{/case}} + {{/switch}} + */ + /* eslint no-invalid-this: "off" */ + handlebars.registerHelper('switch', function(value, options) { + this._switch_value_ = value; + const html = options.fn(this); // Process the body of the switch block + delete this._switch_value_; + return html; + }); + + handlebars.registerHelper('case', function(value, options) { + if (value === this._switch_value_) { + return options.fn(this); + } + }); + +}; diff --git a/lib/models/report-templates.js b/lib/models/report-templates.js index e43bd7d8..6dcb2c70 100644 --- a/lib/models/report-templates.js +++ b/lib/models/report-templates.js @@ -44,13 +44,13 @@ module.exports.get = (id, callback) => { const template = tools.convertKeys(rows[0]); const userFields = template.userFields.trim(); - if (userFields != '') { + if (userFields !== '') { try { template.userFieldsObject = JSON.parse(userFields); } catch (err) { // This is to handle situation when for some reason we get corrupted JSON in the DB. template.userFieldsObject = {}; - template.userFields = "{}"; + template.userFields = '{}'; } } else { template.userFieldsObject = {}; @@ -91,7 +91,7 @@ module.exports.createOrUpdate = (createMode, data, callback) => { if (key === 'user_fields') { value = value.trim(); - if (value != '') { + if (value !== '') { try { JSON.parse(value); } catch (err) { @@ -129,7 +129,7 @@ module.exports.createOrUpdate = (createMode, data, callback) => { if (createMode) { return callback(null, result && result.insertId || false); } else { - return callback(null, result && result.affectedRows || false) + return callback(null, result && result.affectedRows || false); } }); }); diff --git a/lib/models/reports.js b/lib/models/reports.js index 31a53c4f..0c6a91f9 100644 --- a/lib/models/reports.js +++ b/lib/models/reports.js @@ -6,7 +6,6 @@ const fields = require('./fields'); const reportTemplates = require('./report-templates'); const tools = require('../tools'); const _ = require('../translate')._; -const log = require('npmlog'); const allowedKeys = ['name', 'description', 'report_template', 'params']; @@ -25,7 +24,7 @@ module.exports.list = (start, limit, callback) => { module.exports.listWithState = (state, start, limit, callback) => { tableHelpers.list('reports', ['*'], 'name', { where: 'state=?', values: [state] }, start, limit, callback); -} +}; module.exports.filter = (request, callback) => { tableHelpers.filter('reports JOIN report_templates ON reports.report_template = report_templates.id', @@ -58,7 +57,7 @@ module.exports.get = (id, callback) => { const template = tools.convertKeys(rows[0]); const params = template.params.trim(); - if (params != '') { + if (params !== '') { try { template.paramsObject = JSON.parse(params); } catch (err) { @@ -77,7 +76,7 @@ module.exports.get = (id, callback) => { module.exports.updateFields = (id, fieldValueMap, callback) => { db.getConnection((err, connection) => { if (err) { - return next(err); + return callback(err); } const clauses = []; @@ -96,7 +95,7 @@ module.exports.updateFields = (id, fieldValueMap, callback) => { return callback(err); } - return callback(null, result && result.affectedRows || false) + return callback(null, result && result.affectedRows || false); }); }); }; @@ -119,7 +118,7 @@ module.exports.createOrUpdate = (createMode, report, callback) => { const reportTemplateId = Number(report.reportTemplate); reportTemplates.get(reportTemplateId, (err, reportTemplate) => { if (err) { - callback(err); + return callback(err); } const params = report.paramsObject; @@ -153,7 +152,7 @@ module.exports.createOrUpdate = (createMode, report, callback) => { db.getConnection((err, connection) => { if (err) { - return next(err); + return callback(err); } let query; @@ -174,7 +173,7 @@ module.exports.createOrUpdate = (createMode, report, callback) => { if (createMode) { return callback(null, result && result.insertId || false); } else { - return callback(null, result && result.affectedRows || false) + return callback(null, result && result.affectedRows || false); } }); }); @@ -206,13 +205,13 @@ module.exports.delete = (id, callback) => { }; const campaignFieldsMapping = { - 'tracker_count': 'tracker.count', - 'country': 'tracker.country', - 'device_type': 'tracker.device_type', - 'status': 'campaign.status', - 'first_name': 'subscribers.first_name', - 'last_name': 'subscribers.last_name', - 'email': 'subscribers.email' + tracker_count: 'tracker.count', + country: 'tracker.country', + device_type: 'tracker.device_type', + status: 'campaign.status', + first_name: 'subscribers.first_name', + last_name: 'subscribers.last_name', + email: 'subscribers.email' }; module.exports.getCampaignResults = (campaign, select, clause, callback) => { @@ -236,7 +235,7 @@ module.exports.getCampaignResults = (campaign, select, clause, callback) => { const item = select[idx]; if (item in fieldsMapping) { selFields.push(fieldsMapping[item] + ' AS ' + item); - } else if (item == '*') { + } else if (item === '*') { selFields = selFields.concat(Object.keys(fieldsMapping).map(item => fieldsMapping[item] + ' AS ' + item)); } else { selFields.push(item); diff --git a/lib/table-helpers.js b/lib/table-helpers.js index 2c082c2c..1176eba2 100644 --- a/lib/table-helpers.js +++ b/lib/table-helpers.js @@ -1,8 +1,7 @@ 'use strict'; -let db = require('./db'); -let tools = require('./tools'); -let log = require('npmlog'); +const db = require('./db'); +const tools = require('./tools'); module.exports.list = (source, fields, orderBy, queryData, start, limit, callback) => { db.getConnection((err, connection) => { @@ -107,7 +106,7 @@ module.exports.filter = (source, fields, request, columns, searchFields, default let searchVal = '%' + request.search.value.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1') + '%'; searchWhere = searchFields.map(field => field + ' LIKE ?').join(' OR '); - searchArgs = searchFields.map(field => searchVal) + searchArgs = searchFields.map(() => searchVal); } let query = 'SELECT SQL_CALC_FOUND_ROWS ' + fields.join(', ') + ' FROM ' + source +' WHERE ' + (searchWhere ? '(' + searchWhere + ')': '1') + (queryData ? ' AND (' + queryData.where + ')' : '') + ' ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?'; diff --git a/routes/reports.js b/routes/reports.js index 8e660ba4..ecb2db0a 100644 --- a/routes/reports.js +++ b/routes/reports.js @@ -217,7 +217,7 @@ router.post('/delete', passport.parseForm, passport.csrfProtection, (req, res) = }); }); -router.get('/view/:id', passport.csrfProtection, (req, res) => { +router.get('/view/:id', (req, res) => { reports.get(req.params.id, (err, report) => { if (err || !report) { req.flash('danger', err && err.message || err || _('Could not find report with specified ID')); @@ -269,7 +269,7 @@ router.get('/view/:id', passport.csrfProtection, (req, res) => { }); }); -router.get('/output/:id', passport.csrfProtection, (req, res) => { +router.get('/output/:id', (req, res) => { reports.get(req.params.id, (err, report) => { if (err || !report) { req.flash('danger', err && err.message || err || _('Could not find report with specified ID')); diff --git a/views/reports/create-select-template.hbs b/views/reports/create-select-template.hbs index 8d868b5b..f3a64793 100644 --- a/views/reports/create-select-template.hbs +++ b/views/reports/create-select-template.hbs @@ -9,7 +9,6 @@
    - {{> report_select_template }} From 418dba7b9fdf3b626285bce92a7504bdf7adb3e0 Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Tue, 25 Apr 2017 22:49:31 +0000 Subject: [PATCH 10/30] Work in progress on securing reports. --- app.js | 7 +- config/default.toml | 20 ++ index.js | 81 ++++---- lib/executor.js | 74 +++++++ lib/file-helpers.js | 33 ++++ lib/privilege-helpers.js | 131 +++++++++++++ lib/report-processor.js | 129 +++++++++++++ lib/tools.js | 22 +-- package.json | 9 +- routes/report-templates.js | 10 +- routes/reports.js | 15 +- services/executor.js | 89 +++++++++ services/report-processor-worker.js | 133 ------------- services/report-processor.js | 287 ++++++++++++++++------------ 14 files changed, 709 insertions(+), 331 deletions(-) create mode 100644 lib/executor.js create mode 100644 lib/file-helpers.js create mode 100644 lib/privilege-helpers.js create mode 100644 lib/report-processor.js create mode 100644 services/executor.js delete mode 100644 services/report-processor-worker.js diff --git a/app.js b/app.js index 91af1fc0..f8bad1bf 100644 --- a/app.js +++ b/app.js @@ -213,8 +213,11 @@ app.use('/api', api); app.use('/editorapi', editorapi); app.use('/grapejs', grapejs); app.use('/mosaico', mosaico); -app.use('/reports', reports); -app.use('/report-templates', reportsTemplates); + +if (config.reports && config.reports.enabled === true) { + app.use('/reports', reports); + app.use('/report-templates', reportsTemplates); +} // catch 404 and forward to error handler app.use((req, res, next) => { diff --git a/config/default.toml b/config/default.toml index 6293d407..54fe11c0 100644 --- a/config/default.toml +++ b/config/default.toml @@ -74,6 +74,11 @@ postsize="2MB" host="localhost" user="mailtrain" password="mailtrain" +# If more security is desired when running reports (which use user-defined JS scripts located in DB), +# one can specify a DB user with read-only permissions. If these are not specified, Mailtrain uses the +# regular DB user (which has also write permissions). +# userRO="mailtrain-ro" +# passwordRO="mailtrain-ro" database="mailtrain" # Some installations, eg. MAMP can use a different port (8889) # MAMP users should also turn on "Allow network access to MySQL" otherwise MySQL might not be accessible @@ -150,3 +155,18 @@ templates=[["versafix-1", "Versafix One"]] [grapejs] # Installed templates templates=[["demo", "Demo Template"]] + +[reports] +# The whole reporting functionality can be disabled below if the they are not needed and the DB cannot be +# properly protected. +# Reports rely on custom user defined Javascript snippets defined in the report template. The snippets are run on the +# server when generating a report. As these snippets are stored in the DB, they pose a security risk because they can +# help gaining access to the server if the DB cannot +# be properly protected (e.g. if it is shared with another application with security weaknesses). +# Mailtrain mitigates this problem by running the custom Javascript snippets in a chrooted environment and under a +# DB user that cannot modify the database (see userRO in [mysql] above). However the chrooted environment is available +# only if Mailtrain is started as root. The chrooted environment still does not prevent the custom JS script in +# performing network operations and in generating XSS attacks as part of the report. +# The bottom line is that if people who are creating report templates or have write access to the DB cannot be trusted, +# then it's safer to switch off the reporting functionality below. +enabled=false diff --git a/index.js b/index.js index 57b9d482..6893ff3e 100644 --- a/index.js +++ b/index.js @@ -4,21 +4,23 @@ * Module dependencies. */ -let config = require('config'); -let log = require('npmlog'); -let app = require('./app'); -let http = require('http'); -let fork = require('child_process').fork; -let triggers = require('./services/triggers'); -let importer = require('./services/importer'); -let verpServer = require('./services/verp-server'); -let testServer = require('./services/test-server'); -let postfixBounceServer = require('./services/postfix-bounce-server'); -let tzupdate = require('./services/tzupdate'); -let feedcheck = require('./services/feedcheck'); -let dbcheck = require('./lib/dbcheck'); -let tools = require('./lib/tools'); -let reportProcessor = require('./services/report-processor'); +const config = require('config'); +const log = require('npmlog'); +const app = require('./app'); +const http = require('http'); +const fork = require('child_process').fork; +const triggers = require('./services/triggers'); +const importer = require('./services/importer'); +const verpServer = require('./services/verp-server'); +const testServer = require('./services/test-server'); +const postfixBounceServer = require('./services/postfix-bounce-server'); +const tzupdate = require('./services/tzupdate'); +const feedcheck = require('./services/feedcheck'); +const dbcheck = require('./lib/dbcheck'); +const tools = require('./lib/tools'); +const reportProcessor = require('./lib/report-processor'); +const executor = require('./lib/executor'); +const privilegeHelpers = require('./lib/privilege-helpers'); let port = config.www.port; let host = config.www.host; @@ -113,32 +115,21 @@ server.on('listening', () => { log.info('Express', 'WWW server listening on %s', bind); // start additional services - testServer(() => { - verpServer(() => { - tzupdate(() => { - importer(() => { - triggers(() => { - spawnSenders(() => { - feedcheck(() => { - postfixBounceServer(() => { - reportProcessor.init(() => { - log.info('Service', 'All services started'); - if (config.group) { - try { - process.setgid(config.group); - log.info('Service', 'Changed group to "%s" (%s)', config.group, process.getgid()); - } catch (E) { - log.info('Service', 'Failed to change group to "%s" (%s)', config.group, E.message); - } - } - if (config.user) { - try { - process.setuid(config.user); - log.info('Service', 'Changed user to "%s" (%s)', config.user, process.getuid()); - } catch (E) { - log.info('Service', 'Failed to change user to "%s" (%s)', config.user, E.message); - } - } + function startNextServices() { + testServer(() => { + verpServer(() => { + + privilegeHelpers.dropRootPrivileges(); + + tzupdate(() => { + importer(() => { + triggers(() => { + spawnSenders(() => { + feedcheck(() => { + postfixBounceServer(() => { + reportProcessor.init(() => { + log.info('Service', 'All services started'); + }); }); }); }); @@ -147,5 +138,11 @@ server.on('listening', () => { }); }); }); - }); + } + + if (config.reports && config.reports.enabled === true) { + executor.spawn(() => startNextServices); + } else { + startNextServices(); + } }); diff --git a/lib/executor.js b/lib/executor.js new file mode 100644 index 00000000..80db9b6b --- /dev/null +++ b/lib/executor.js @@ -0,0 +1,74 @@ +'use strict'; + +const fork = require('child_process').fork; +const log = require('npmlog'); +const path = require('path'); + +const requestCallbacks = {}; +let messageTid = 0; +let executorProcess; + +module.exports = { + spawn, + start, + stop +}; + +function spawn(callback) { + log.info('Executor', 'Spawning executor process.'); + + executorProcess = fork(path.join(__dirname, '..', 'services', 'executor.js'), [], { + cwd: path.join(__dirname, '..'), + env: {NODE_ENV: process.env.NODE_ENV} + }); + + executorProcess.on('message', msg => { + if (msg) { + if (msg.type === 'process-started') { + let requestCallback = requestCallbacks[msg.tid]; + if (requestCallback && requestCallback.startedCallback) { + requestCallback.startedCallback(msg.tid); + } + + } else if (msg.type === 'process-finished') { + let requestCallback = requestCallbacks[msg.tid]; + if (requestCallback && requestCallback.startedCallback) { + requestCallback.finishedCallback(msg.code, msg.signal); + } + + delete requestCallbacks[msg.tid]; + + } else if (msg.type === 'executor-started') { + log.info('Executor', 'Executor process started.'); + return callback(); + } + } + }); + + executorProcess.on('close', (code, signal) => { + log.info('Executor', 'Executor process exited with code %s signal %s.', code, signal); + }); +} + +function start(type, data, startedCallback, finishedCallback) { + requestCallbacks[messageTid] = { + startedCallback, + finishedCallback + }; + + executorProcess.send({ + type: 'start-' + type, + data, + tid: messageTid + }); + + messageTid++; +} + +function stop(tid) { + executorProcess.send({ + type: 'stop-process', + tid + }); +} + diff --git a/lib/file-helpers.js b/lib/file-helpers.js new file mode 100644 index 00000000..aca70e02 --- /dev/null +++ b/lib/file-helpers.js @@ -0,0 +1,33 @@ +'use strict'; + +const path = require('path'); + +function nameToFileName(name) { + return name. + trim(). + toLowerCase(). + replace(/[ .+/]/g, '-'). + replace(/[^a-z0-9\-_]/gi, ''). + replace(/--*/g, '-'); +} + + +function getReportDir(report) { + return path.join(__dirname, '..', 'protected', 'reports', report.id + '-' + nameToFileName(report.name)); +} + +function getReportContentFile(report) { + return path.join(getReportDir(report), 'report'); +} + +function getReportOutputFile(report) { + return getReportDir(report) + '.output'; +} + + +module.exports = { + getReportContentFile, + getReportDir, + getReportOutputFile, + nameToFileName +}; diff --git a/lib/privilege-helpers.js b/lib/privilege-helpers.js new file mode 100644 index 00000000..ce648d70 --- /dev/null +++ b/lib/privilege-helpers.js @@ -0,0 +1,131 @@ +'use strict'; + +const log = require('npmlog'); +const config = require('config'); +const path = require('path'); + +const promise = require('bluebird'); +const fsExtra = promise.promisifyAll(require('fs-extra')); +const fs = promise.promisifyAll(require('fs')); +const walk = require('walk'); + +const tryRequire = require('try-require'); +const posix = tryRequire('posix'); + + +function ensureMailtrainOwner(file, callback) { + try { + const uid = config.user ? posix.getpwnam(config.user).uid : 0; + const gid = config.group ? posix.getgrnam(config.group).gid : 0; + + fs.chown(file, uid, gid, callback); + + } catch (err) { + return callback(err); + } +} + +function ensureMailtrainOwnerRecursive(dir, callback) { + try { + const uid = config.user ? posix.getpwnam(config.user).uid : 0; + const gid = config.group ? posix.getgrnam(config.group).gid : 0; + + fs.chown(dir, uid, gid, err => { + if (err) { + return callback(err); + } + + walk.walk(dir) + .on('node', (root, stat, next) => { + fs.chown(path.join(root, stat.name), uid, gid, next); + }) + .on('end', callback); + }); + } catch (err) { + return callback(err); + } +} + +const ensureMailtrainOwnerRecursiveAsync = promise.promisify(ensureMailtrainOwnerRecursive); + +function dropRootPrivileges() { + if (config.group) { + try { + process.setgid(config.group); + log.info('PrivilegeHelpers', 'Changed group to "%s" (%s)', config.group, process.getgid()); + } catch (E) { + log.info('PrivilegeHelpers', 'Failed to change group to "%s" (%s)', config.group, E.message); + } + } + + if (config.user) { + try { + process.setuid(config.user); + log.info('PrivilegeHelpers', 'Changed user to "%s" (%s)', config.user, process.getuid()); + } catch (E) { + log.info('PrivilegeHelpers', 'Failed to change user to "%s" (%s)', config.user, E.message); + } + } +} + +function setupChrootDir(newRoot, callback) { + try { + fsExtra.emptyDirAsync(newRoot) + .then(() => fsExtra.ensureDirAsync(path.join(newRoot, 'etc'))) + .then(() => fsExtra.copyAsync('/etc/hosts', path.join(newRoot, 'etc', 'hosts'))) + .then(() => ensureMailtrainOwnerRecursiveAsync(newRoot)) + .then(() => { + log.info('PrivilegeHelpers', 'Chroot directory "%s" set up', newRoot); + callback(); + }) + .catch(err => { + log.info('PrivilegeHelpers', 'Failed to setup chroot directory "%s"', newRoot); + callback(err); + }); + + } catch(err) { + log.info('PrivilegeHelpers', 'Failed to setup chroot directory "%s"', newRoot); + } +} + +function tearDownChrootDir(root, callback) { + if (posix) { + fsExtra.removeAsync(path.join('/', 'etc')) + .then(() => { + log.info('PrivilegeHelpers', 'Chroot directory "%s" torn down', root); + callback(); + }) + .catch(err => { + log.info('PrivilegeHelpers', 'Failed to tear down chroot directory "%s"', root); + callback(err); + }); + } +} + +function chrootAndDropRootPrivileges(newRoot) { + + try { + const uid = config.user ? posix.getpwnam(config.user).uid : 0; + const gid = config.group ? posix.getgrnam(config.group).gid : 0; + + posix.chroot(newRoot); + process.chdir('/'); + + process.setgid(gid); + process.setuid(uid); + + log.info('PrivilegeHelpers', 'Changed root to "%s" and privileges to %s.%s', newRoot, uid, gid); + } catch(err) { + log.info('PrivilegeHelpers', 'Failed to change root to "%s" and set privileges', newRoot); + } + +} + +module.exports = { + dropRootPrivileges, + chrootAndDropRootPrivileges, + setupChrootDir, + tearDownChrootDir, + ensureMailtrainOwner, + ensureMailtrainOwnerRecursive +}; diff --git a/lib/report-processor.js b/lib/report-processor.js new file mode 100644 index 00000000..09308d37 --- /dev/null +++ b/lib/report-processor.js @@ -0,0 +1,129 @@ +'use strict'; + +const log = require('npmlog'); +const reports = require('./models/reports'); +const executor = require('./executor'); + +let runningWorkersCount = 0; +let maxWorkersCount = 1; + +let workers = {}; + +function startWorker(report) { + + function onStarted(tid) { + log.info('ReportProcessor', 'Worker process for "%s" started with tid %s. Current worker count is %s.', report.name, tid, runningWorkersCount); + workers[report.id] = tid; + } + + function onFinished(code, signal) { + runningWorkersCount--; + log.info('ReportProcessor', 'Worker process for "%s" (tid %s) exited with code %s signal %s. Current worker count is %s.', report.name, workers[report.id], code, signal, runningWorkersCount); + delete workers[report.id]; + + const fields = {}; + if (code === 0) { + fields.state = reports.ReportState.FINISHED; + fields.lastRun = new Date(); + } else { + fields.state = reports.ReportState.FAILED; + } + + reports.updateFields(report.id, fields, err => { + if (err) { + log.error('ReportProcessor', err); + } + + setImmediate(startWorkers); + }); + } + + const reportData = { + id: report.id, + name: report.name + }; + + runningWorkersCount++; + executor.start('report-processor-worker', reportData, onStarted, onFinished); +} + +function startWorkers() { + reports.listWithState(reports.ReportState.SCHEDULED, 0, maxWorkersCount - runningWorkersCount, (err, reportList) => { + if (err) { + log.error('ReportProcessor', err); + return; + } + + for (let report of reportList) { + reports.updateFields(report.id, { state: reports.ReportState.PROCESSING }, err => { + if (err) { + log.error('ReportProcessor', err); + return; + } + + startWorker(report); + }); + } + }); +} + +module.exports.start = (reportId, callback) => { + if (!workers[reportId]) { + log.info('ReportProcessor', 'Scheduling report id: %s', reportId); + reports.updateFields(reportId, { state: reports.ReportState.SCHEDULED, lastRun: null}, err => { + if (err) { + return callback(err); + } + + if (runningWorkersCount < maxWorkersCount) { + log.info('ReportProcessor', 'Starting worker because runningWorkersCount=%s maxWorkersCount=%s', runningWorkersCount, maxWorkersCount); + + startWorkers(); + } else { + log.info('ReportProcessor', 'Not starting worker because runningWorkersCount=%s maxWorkersCount=%s', runningWorkersCount, maxWorkersCount); + } + + callback(null); + }); + } else { + log.info('ReportProcessor', 'Worker for report id: %s is already running.', reportId); + } +}; + +module.exports.stop = (reportId, callback) => { + const tid = workers[reportId]; + if (tid) { + log.info('ReportProcessor', 'Killing worker for report id: %s', reportId); + executor.stop(tid); + reports.updateFields(reportId, { state: reports.ReportState.FAILED}, callback); + } else { + log.info('ReportProcessor', 'No running worker found for report id: %s', reportId); + } +}; + +module.exports.init = callback => { + reports.listWithState(reports.ReportState.PROCESSING, 0, 0, (err, reportList) => { + if (err) { + log.error('ReportProcessor', err); + } + + function scheduleReport() { + if (reportList.length > 0) { + const report = reportList.shift(); + + reports.updateFields(report.id, { state: reports.ReportState.SCHEDULED}, err => { + if (err) { + log.error('ReportProcessor', err); + } + + scheduleReport(); + }); + } + + startWorkers(); + return callback(); + } + + scheduleReport(); + }); +}; diff --git a/lib/tools.js b/lib/tools.js index 7b3074a2..2c508917 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -1,5 +1,6 @@ 'use strict'; +const config = require('config'); let fs = require('fs'); let path = require('path'); let db = require('./db'); @@ -28,7 +29,6 @@ module.exports = { prepareHtml, purifyHTML, mergeTemplateIntoLayout, - nameToFileName, workers: new Set() }; @@ -130,11 +130,15 @@ function updateMenu(res) { title: _('Automation'), url: '/triggers', key: 'triggers' - }, { - title: _('Reports'), - url: '/reports', - key: 'reports' }); + + if (config.reports && config.reports.enabled === true) { + res.locals.menu.push({ + title: _('Reports'), + url: '/reports', + key: 'reports' + }); + } } function validateEmail(address, checkBlocked, callback) { @@ -302,11 +306,3 @@ function mergeTemplateIntoLayout(template, layout, callback) { } } -function nameToFileName(name) { - return name. - trim(). - toLowerCase(). - replace(/[ .+/]/g, '-'). - replace(/[^a-z0-9\-_]/gi, ''). - replace(/--*/g, '-'); -} diff --git a/package.json b/package.json index 4d95835d..80a97843 100644 --- a/package.json +++ b/package.json @@ -33,10 +33,14 @@ "grunt-eslint": "^19.0.0", "jsxgettext-andris": "^0.9.0-patch.1" }, + "optionalDependencies": { + "posix": "^4.1.1" + }, "dependencies": { "async": "^2.3.0", "aws-sdk": "^2.37.0", "bcrypt-nodejs": "0.0.3", + "bluebird": "^3.5.0", "body-parser": "^1.17.1", "bounce-handler": "^7.3.2-fork.2", "compression": "^1.6.2", @@ -56,6 +60,7 @@ "faker": "^4.1.0", "feedparser": "^2.1.0", "file-type": "^4.1.0", + "fs-extra": "^2.1.2", "geoip-ultralight": "^0.1.5", "gettext-parser": "^1.2.2", "gm": "^1.23.0", @@ -97,6 +102,8 @@ "slugify": "^1.1.0", "smtp-server": "^2.0.3", "striptags": "^3.0.1", - "toml": "^2.3.2" + "toml": "^2.3.2", + "try-require": "^1.2.1", + "walk": "^2.3.9" } } diff --git a/routes/report-templates.js b/routes/report-templates.js index 5393ccb4..f411dd47 100644 --- a/routes/report-templates.js +++ b/routes/report-templates.js @@ -75,9 +75,7 @@ router.get('/create', passport.csrfProtection, (req, res) => { ']'; if (!('js' in data)) data.js = - 'const reports = require("../lib/models/reports");\n' + - '\n' + - 'reports.getCampaignResults(inputs.campaign, ["*"], "", (err, results) => {\n' + + 'campaigns.results(inputs.campaign, ["*"], "", (err, results) => {\n' + ' if (err) {\n' + ' return callback(err);\n' + ' }\n' + @@ -136,9 +134,7 @@ router.get('/create', passport.csrfProtection, (req, res) => { ']'; if (!('js' in data)) data.js = - 'const reports = require("../lib/models/reports");\n' + - '\n' + - 'reports.getCampaignResults(inputs.campaign, ["custom_country", "count(*) AS count_all", "SUM(IF(tracker.count IS NULL, 0, 1)) AS count_opened"], "GROUP BY custom_country", (err, results) => {\n' + + 'campaigns.results(inputs.campaign, ["custom_country", "count(*) AS count_all", "SUM(IF(tracker.count IS NULL, 0, 1)) AS count_opened"], "GROUP BY custom_country", (err, results) => {\n' + ' if (err) {\n' + ' return callback(err);\n' + ' }\n' + @@ -213,8 +209,6 @@ router.get('/create', passport.csrfProtection, (req, res) => { ']'; if (!('js' in data)) data.js = - 'const subscriptions = require("../lib/models/subscriptions");\n' + - '\n' + 'subscriptions.list(inputs.list.id,0,0, (err, results) => {\n' + ' if (err) {\n' + ' return callback(err);\n' + diff --git a/routes/reports.js b/routes/reports.js index ecb2db0a..f1d52e9f 100644 --- a/routes/reports.js +++ b/routes/reports.js @@ -6,10 +6,11 @@ const router = new express.Router(); const _ = require('../lib/translate')._; const reportTemplates = require('../lib/models/report-templates'); const reports = require('../lib/models/reports'); -const reportProcessor = require('../services/report-processor'); +const reportProcessor = require('../lib/report-processor'); const campaigns = require('../lib/models/campaigns'); const lists = require('../lib/models/lists'); const tools = require('../lib/tools'); +const fileHelpers = require('../lib/file-helpers'); const util = require('util'); const htmlescape = require('escape-html'); const striptags = require('striptags'); @@ -233,14 +234,13 @@ router.get('/view/:id', (req, res) => { if (report.state == reports.ReportState.FINISHED) { if (reportTemplate.mimeType == 'text/html') { - fs.readFile(reportProcessor.getFileName(report, 'report'), (err, reportContent) => { + fs.readFile(fileHelpers.getReportContentFile(report), (err, reportContent) => { if (err) { req.flash('danger', err && err.message || err || _('Could not find report with specified ID')); return res.redirect('/reports'); } const data = { - csrfToken: req.csrfToken(), report: new hbs.handlebars.SafeString(reportContent), title: report.name }; @@ -250,11 +250,11 @@ router.get('/view/:id', (req, res) => { } else if (reportTemplate.mimeType == 'text/csv') { const headers = { - 'Content-Disposition': 'attachment;filename=' + tools.nameToFileName(report.name) + '.csv', + 'Content-Disposition': 'attachment;filename=' + fileHelpers.nameToFileName(report.name) + '.csv', 'Content-Type': 'text/csv' }; - res.sendFile(reportProcessor.getFileName(report, 'report'), {headers: headers}); + res.sendFile(fileHelpers.getReportContentFile(report), {headers: headers}); } else { req.flash('danger', _('Unknown type of template')); @@ -276,9 +276,8 @@ router.get('/output/:id', (req, res) => { return res.redirect('/reports'); } - fs.readFile(reportProcessor.getFileName(report, 'output'), (err, output) => { + fs.readFile(fileHelpers.getReportOutputFile(report), (err, output) => { let data = { - csrfToken: req.csrfToken(), title: 'Output for report ' + report.name }; @@ -298,6 +297,8 @@ function getRowLastRun(row) { } function getRowActions(row) { + /* FIXME: add csrf protection to stop and refresh actions */ + let requestRefresh = false; let view, startStop; let topic = 'data-topic-id="' + row.id + '"'; diff --git a/services/executor.js b/services/executor.js new file mode 100644 index 00000000..8873a66b --- /dev/null +++ b/services/executor.js @@ -0,0 +1,89 @@ +'use strict'; + +/* Privileged executor. If Mailtrain is started as root, this process keeps the root privilege to be able to spawn workers + that can chroot. + */ + +const fileHelpers = require('../lib/file-helpers'); +const fork = require('child_process').fork; +const path = require('path'); +const log = require('npmlog'); +const fs = require('fs'); +const privilegeHelpers = require('../lib/privilege-helpers'); + +let processes = {}; + +function spawnProcess(tid, executable, args, outputFile, cwd) { + + fs.open(outputFile, 'w', (err, outFd) => { + if (err) { + log.error('Executor', err); + return; + } + + privilegeHelpers.ensureMailtrainOwner(outputFile, (err) => { + if (err) { + log.info('Executor', 'Cannot change owner of output file of process tid:%s.', tid) + } + + const options = { + stdio: ['ignore', outFd, outFd, 'ipc'], + cwd: cwd, + env: {NODE_ENV: process.env.NODE_ENV} + }; + + const child = fork(executable, args, options); + const pid = child.pid; + processes[tid] = child; + + log.info('Executor', 'Process started with tid:%s pid:%s.', tid, pid); + process.send({ + type: 'process-started', + tid + }); + + child.on('close', (code, signal) => { + + delete processes[tid]; + log.info('Executor', 'Process tid:%s pid:%s exited with code %s signal %s.', tid, pid, code, signal); + + fs.close(outFd, (err) => { + if (err) { + log.error('Executor', err); + } + + process.send({ + type: 'process-finished', + tid, + code, + signal + }); + }); + }); + }); + }); +} + +process.on('message', msg => { + if (msg) { + const type = msg.type; + + if (type === 'start-report-processor-worker') { + spawnProcess(msg.tid, path.join(__dirname, 'report-processor.js'), [msg.data.id], fileHelpers.getReportOutputFile(msg.data), path.join(__dirname, '..')); + + } else if (type === 'stop-process') { + const child = processes[msg.tid]; + + if (child) { + log.info('Executor', 'Killing process tid:%s pid:%s', msg.tid, child.pid); + child.kill(); + } else { + log.info('Executor', 'No running process found with tid:%s pid:%s', msg.tid, child.pid); + } + } + } +}); + +process.send({ + type: 'executor-started' +}); diff --git a/services/report-processor-worker.js b/services/report-processor-worker.js deleted file mode 100644 index d4141bd6..00000000 --- a/services/report-processor-worker.js +++ /dev/null @@ -1,133 +0,0 @@ -'use strict'; - -const reports = require('../lib/models/reports'); -const reportTemplates = require('../lib/models/report-templates'); -const lists = require('../lib/models/lists'); -const campaigns = require('../lib/models/campaigns'); -const handlebars = require('handlebars'); -const handlebarsHelpers = require('../lib/handlebars-helpers'); -const _ = require('../lib/translate')._; -const hbs = require('hbs'); -const vm = require('vm'); -const log = require('npmlog'); -const fs = require('fs'); -const reportProcessor = require('./report-processor'); - -handlebarsHelpers.registerHelpers(handlebars); - -function resolveEntities(getter, ids, callback) { - const idsRemaining = ids.slice(); - const resolved = []; - - function doWork() { - if (idsRemaining.length == 0) { - return callback(null, resolved); - } - - getter(idsRemaining.shift(), (err, entity) => { - if (err) { - return callback(err); - } - - resolved.push(entity); - return doWork(); - }); - } - - setImmediate(doWork); -} - -const userFieldTypeToGetter = { - 'campaign': (id, callback) => campaigns.get(id, false, callback), - 'list': lists.get -}; - -function resolveUserFields(userFields, params, callback) { - const userFieldsRemaining = userFields.slice(); - const resolved = {}; - - function doWork() { - if (userFieldsRemaining.length == 0) { - return callback(null, resolved); - } - - const spec = userFieldsRemaining.shift(); - const getter = userFieldTypeToGetter[spec.type]; - - if (getter) { - return resolveEntities(getter, params[spec.id], (err, entities) => { - if (spec.minOccurences == 1 && spec.maxOccurences == 1) { - resolved[spec.id] = entities[0]; - } else { - resolved[spec.id] = entities; - } - - doWork(); - }); - } else { - return callback(new Error(_('Unknown user field type "' + spec.type + '".'))); - } - } - - setImmediate(doWork); -} - -function doneSuccess(id) { - process.exit(0); -} - -function doneFail(id) { - process.exit(1); -} - -function processReport(reportId) { - reports.get(reportId, (err, report) => { - if (err || !report) { - log.error('reports', err && err.message || err || _('Could not find report with specified ID')); - doneFail(); - } - - reportTemplates.get(report.reportTemplate, (err, reportTemplate) => { - if (err) { - log.error('reports', err && err.message || err || _('Could not find report template')); - doneFail(); - } - - resolveUserFields(reportTemplate.userFieldsObject, report.paramsObject, (err, inputs) => { - if (err) { - log.error('reports', err.message || err); - doneFail(); - } - - const sandbox = { - require: require, - inputs: inputs, - console: console, - callback: (err, outputs) => { - if (err) { - log.error('reports', err.message || err); - doneFail(); - } - - const hbsTmpl = handlebars.compile(reportTemplate.hbs); - const reportText = hbsTmpl(outputs); - - fs.writeFile(reportProcessor.getFileName(report, 'report'), reportText, (err, reportContent) => { - if (err) { - log.error('reports', err && err.message || err || _('Could not find report with specified ID')); - doneFail(); - } - - doneSuccess(); - }); - } - }; - - const script = new vm.Script(reportTemplate.js); - script.runInNewContext(sandbox, {displayErrors: true, timeout: 120000}); - }); - }); - }); -} - -processReport(Number(process.argv[2])); diff --git a/services/report-processor.js b/services/report-processor.js index be6cadf1..8318008a 100644 --- a/services/report-processor.js +++ b/services/report-processor.js @@ -1,154 +1,191 @@ 'use strict'; -const log = require('npmlog'); -const db = require('../lib/db'); const reports = require('../lib/models/reports'); +const reportTemplates = require('../lib/models/report-templates'); +const lists = require('../lib/models/lists'); +const subscriptions = require('../lib/models/subscriptions'); +const campaigns = require('../lib/models/campaigns'); +const handlebars = require('handlebars'); +const handlebarsHelpers = require('../lib/handlebars-helpers'); const _ = require('../lib/translate')._; -const path = require('path'); -const tools = require('../lib/tools'); +const hbs = require('hbs'); +const vm = require('vm'); +const log = require('npmlog'); const fs = require('fs'); -const fork = require('child_process').fork; +const fileHelpers = require('../lib/file-helpers'); +const path = require('path'); +const privilegeHelpers = require('../lib/privilege-helpers'); -let runningWorkersCount = 0; -let maxWorkersCount = 1; +handlebarsHelpers.registerHelpers(handlebars); -let workers = {}; +let reportId = Number(process.argv[2]); +let reportDir; -function getFileName(report, suffix) { - return path.join(__dirname, '..', 'protected', 'reports', report.id + '-' + tools.nameToFileName(report.name) + '.' + suffix); -} +function resolveEntities(getter, ids, callback) { + const idsRemaining = ids.slice(); + const resolved = []; -module.exports.getFileName = getFileName; - -function spawnWorker(report) { - - fs.open(getFileName(report, 'output'), 'w', (err, outFd) => { - if (err) { - log.error('ReportProcessor', err); - return; + function doWork() { + if (idsRemaining.length == 0) { + return callback(null, resolved); } - runningWorkersCount++; - - const options = { - stdio: ['ignore', outFd, outFd, 'ipc'], - cwd: path.join(__dirname, '..') - }; - - let child = fork(path.join(__dirname, 'report-processor-worker.js'), [report.id], options); - let pid = child.pid; - workers[report.id] = child; - - log.info('ReportProcessor', 'Worker process for "%s" started with pid %s. Current worker count is %s.', report.name, pid, runningWorkersCount); - - child.on('close', (code, signal) => { - runningWorkersCount--; - - delete workers[report.id]; - log.info('ReportProcessor', 'Worker process for "%s" (pid %s) exited with code %s signal %s. Current worker count is %s.', report.name, pid, code, signal, runningWorkersCount); - - fs.close(outFd, (err) => { - if (err) { - log.error('ReportProcessor', err); - } - - const fields = {}; - if (code ===0 ) { - fields.state = reports.ReportState.FINISHED; - fields.lastRun = new Date(); - } else { - fields.state = reports.ReportState.FAILED; - } - - reports.updateFields(report.id, fields, (err) => { - if (err) { - log.error('ReportProcessor', err); - } - - setImmediate(worker); - }); - }); - }); - }); -}; - -function worker() { - reports.listWithState(reports.ReportState.SCHEDULED, 0, maxWorkersCount - runningWorkersCount, (err, reportList) => { - if (err) { - log.error('ReportProcessor', err); - return; - } - - for (let report of reportList) { - reports.updateFields(report.id, { state: reports.ReportState.PROCESSING }, (err) => { - if (err) { - log.error('ReportProcessor', err); - return; - } - - spawnWorker(report); - }); - } - }); -} - -module.exports.start = (reportId, callback) => { - if (!workers[reportId]) { - log.info('ReportProcessor', 'Scheduling report id: %s', reportId); - reports.updateFields(reportId, { state: reports.ReportState.SCHEDULED, lastRun: null}, (err) => { + getter(idsRemaining.shift(), (err, entity) => { if (err) { return callback(err); } - if (runningWorkersCount < maxWorkersCount) { - log.info('ReportProcessor', 'Starting worker because runningWorkersCount=%s maxWorkersCount=%s', runningWorkersCount, maxWorkersCount); - - worker(); - } else { - log.info('ReportProcessor', 'Not starting worker because runningWorkersCount=%s maxWorkersCount=%s', runningWorkersCount, maxWorkersCount); - } - - callback(null); + resolved.push(entity); + return doWork(); }); - } else { - log.info('ReportProcessor', 'Worker for report id: %s is already running.', reportId); } + + setImmediate(doWork); +} + +const userFieldTypeToGetter = { + 'campaign': (id, callback) => campaigns.get(id, false, callback), + 'list': lists.get }; -module.exports.stop = (reportId, callback) => { - const child = workers[reportId]; - if (child) { - log.info('ReportProcessor', 'Killing worker for report id: %s', reportId); - child.kill(); - reports.updateFields(reportId, { state: reports.ReportState.FAILED}, callback); - } else { - log.info('ReportProcessor', 'No running worker found for report id: %s', reportId); - } -}; +function resolveUserFields(userFields, params, callback) { + const userFieldsRemaining = userFields.slice(); + const resolved = {}; -module.exports.init = (callback) => { - reports.listWithState(reports.ReportState.PROCESSING, 0, 0, (err, reportList) => { - if (err) { - log.error('ReportProcessor', err); + function doWork() { + if (userFieldsRemaining.length == 0) { + return callback(null, resolved); } - function scheduleReport() { - if (reportList.length > 0) { - const report = reportList.shift(); + const spec = userFieldsRemaining.shift(); + const getter = userFieldTypeToGetter[spec.type]; - reports.updateFields(report.id, { state: reports.ReportState.SCHEDULED}, (err) => { + if (getter) { + return resolveEntities(getter, params[spec.id], (err, entities) => { + if (spec.minOccurences == 1 && spec.maxOccurences == 1) { + resolved[spec.id] = entities[0]; + } else { + resolved[spec.id] = entities; + } + + doWork(); + }); + } else { + return callback(new Error(_('Unknown user field type "' + spec.type + '".'))); + } + } + + setImmediate(doWork); +} + +function tearDownChrootDir(callback) { + if (reportDir) { + privilegeHelpers.tearDownChrootDir(reportDir, callback); + } else { + callback(); + } +} + +function doneSuccess() { + tearDownChrootDir((err) => { + if (err) + process.exit(1) + else + process.exit(0); + }); +} + +function doneFail() { + tearDownChrootDir((err) => { + process.exit(1) + }); +} + + + +reports.get(reportId, (err, report) => { + if (err || !report) { + log.error('reports', err && err.message || err || _('Could not find report with specified ID')); + doneFail(); + return; + } + + reportTemplates.get(report.reportTemplate, (err, reportTemplate) => { + if (err) { + log.error('reports', err && err.message || err || _('Could not find report template')); + doneFail(); + return; + } + + resolveUserFields(reportTemplate.userFieldsObject, report.paramsObject, (err, inputs) => { + if (err) { + log.error('reports', err.message || err); + doneFail(); + return; + } + + const campaignsProxy = { + results: reports.getCampaignResults, + list: campaigns.list, + get: campaigns.get + }; + + const subscriptionsProxy = { + list: subscriptions.list + }; + + const reportFile = fileHelpers.getReportContentFile(report); + + const sandbox = { + console, + campaigns: campaignsProxy, + subscriptions: subscriptionsProxy, + inputs, + + callback: (err, outputs) => { if (err) { - log.error('ReportProcessor', err); + log.error('reports', err.message || err); + doneFail(); + return; } - scheduleReport(); - }); - } + const hbsTmpl = handlebars.compile(reportTemplate.hbs); + const reportText = hbsTmpl(outputs); - worker(); - callback(); - } + fs.writeFile(path.basename(reportFile), reportText, (err, reportContent) => { + if (err) { + log.error('reports', err && err.message || err || _('Could not find report with specified ID')); + doneFail(); + return; + } - scheduleReport(); + doneSuccess(); + return; + }); + } + }; + + const script = new vm.Script(reportTemplate.js); + + reportDir = fileHelpers.getReportDir(report); + privilegeHelpers.setupChrootDir(reportDir, (err) => { + if (err) { + doneFail(); + return; + } + + privilegeHelpers.chrootAndDropRootPrivileges(reportDir); + + try { + script.runInNewContext(sandbox, {displayErrors: true, timeout: 120000}); + } catch (err) { + console.log(err); + + doneFail(); + return; + } + }); + }); }); -}; +}); + From c3edf42ada1dc4c5cc67b4003c31611d9e603bfd Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Wed, 26 Apr 2017 05:20:29 -0400 Subject: [PATCH 11/30] Fixes in install script on CentOS 7 --- setup/install-centos7.sh | 11 ++++++++--- setup/mailtrain-centos7.service | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) mode change 100644 => 100755 setup/install-centos7.sh create mode 100644 setup/mailtrain-centos7.service diff --git a/setup/install-centos7.sh b/setup/install-centos7.sh old mode 100644 new mode 100755 index be9ea6dc..524d0310 --- a/setup/install-centos7.sh +++ b/setup/install-centos7.sh @@ -13,7 +13,7 @@ set -e yum -y install epel-release curl --silent --location https://rpm.nodesource.com/setup_6.x | bash - -yum -y install mariadb-server nodejs ImageMagick git python redis pwgen bind-utils +yum -y install mariadb-server nodejs ImageMagick git python redis pwgen bind-utils gcc-c++ make systemctl start mariadb systemctl enable mariadb @@ -45,7 +45,8 @@ firewall-cmd --reload # Fetch Mailtrain files mkdir -p /opt/mailtrain cd /opt/mailtrain -git clone git://github.com/Mailtrain-org/mailtrain.git . +#git clone git://github.com/Mailtrain-org/mailtrain.git . +git clone git://github.com/bures/mailtrain.git . # Normally we would let Mailtrain itself to import the initial SQL data but in this case # we need to modify it, before we start Mailtrain @@ -84,6 +85,8 @@ password="$MYSQL_PASSWORD" enabled=true [queue] processes=5 +[reports] +enabled=true EOT # Install required node packages @@ -105,7 +108,7 @@ cat < /etc/logrotate.d/mailtrain EOM # Set up systemd service script -cp setup/mailtrain.service /etc/systemd/system/ +cp setup/mailtrain-centos7.service /etc/systemd/system/mailtrain.service systemctl enable mailtrain.service # Fetch ZoneMTA files @@ -204,7 +207,9 @@ systemctl enable zone-mta.service # Start the service systemctl daemon-reload + systemctl start zone-mta.service systemctl start mailtrain.service echo "Success! Open http://$HOSTNAME/ and log in as admin:test"; + diff --git a/setup/mailtrain-centos7.service b/setup/mailtrain-centos7.service new file mode 100644 index 00000000..9fcd5b1a --- /dev/null +++ b/setup/mailtrain-centos7.service @@ -0,0 +1,16 @@ +[Unit] +Description=Mailtrain server +Requires=mariadb.service +After=syslog.target network.target + +[Service] +Environment="NODE_ENV=production" +WorkingDirectory=/opt/mailtrain +ExecStart=/usr/bin/node index.js +Type=simple +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +# Alias=mailtrain.service From 2ac89f3365e971f09517108e268640b09bcd0a51 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 27 Apr 2017 16:35:53 -0400 Subject: [PATCH 12/30] Report processor worker refactored to run under another user (nobody) and have its own mysql credentials. --- config/default.toml | 15 +- config/reports.toml | 7 + index.js | 2 +- lib/db.js | 2 +- lib/file-helpers.js | 7 +- lib/privilege-helpers.js | 133 +++++------------- package.json | 5 +- routes/report-templates.js | 2 +- services/executor.js | 85 +++++++---- setup/install-centos7.sh | 2 + workers/reports/config/default.toml | 18 +++ workers/reports/config/production.toml | 7 + .../reports}/report-processor.js | 78 +++------- 13 files changed, 159 insertions(+), 204 deletions(-) create mode 100644 config/reports.toml create mode 100644 workers/reports/config/default.toml create mode 100644 workers/reports/config/production.toml rename {services => workers/reports}/report-processor.js (61%) diff --git a/config/default.toml b/config/default.toml index 54fe11c0..abcc128e 100644 --- a/config/default.toml +++ b/config/default.toml @@ -43,8 +43,14 @@ language="en" # If you start out as a root user (eg. if you want to use ports lower than 1000) # then you can downgrade the user once all services are up and running -#user="nobody" -#group="nogroup" +#user="mailtrain" +#group="mailtrain" + +# If Mailtrain is started as root, "Reports" feature drops the privileges of script generating the report to disallow +# any modifications of Mailtrain code and even prohibits reading the production configuration (which contains the MySQL +# password for read/write operations). The rouser/rogroup determines the user to be used +#rouser="nobody" +#rogroup="nogroup" [log] # silly|verbose|info|http|warn|error|silent @@ -74,11 +80,6 @@ postsize="2MB" host="localhost" user="mailtrain" password="mailtrain" -# If more security is desired when running reports (which use user-defined JS scripts located in DB), -# one can specify a DB user with read-only permissions. If these are not specified, Mailtrain uses the -# regular DB user (which has also write permissions). -# userRO="mailtrain-ro" -# passwordRO="mailtrain-ro" database="mailtrain" # Some installations, eg. MAMP can use a different port (8889) # MAMP users should also turn on "Allow network access to MySQL" otherwise MySQL might not be accessible diff --git a/config/reports.toml b/config/reports.toml new file mode 100644 index 00000000..53d2315e --- /dev/null +++ b/config/reports.toml @@ -0,0 +1,7 @@ +[log] +level="verbose" + +[mysql] +user="mailtrain_ro" +password="S6Woc9hwWiV9RsWt" + diff --git a/index.js b/index.js index 6893ff3e..305ec2ad 100644 --- a/index.js +++ b/index.js @@ -141,7 +141,7 @@ server.on('listening', () => { } if (config.reports && config.reports.enabled === true) { - executor.spawn(() => startNextServices); + executor.spawn(startNextServices); } else { startNextServices(); } diff --git a/lib/db.js b/lib/db.js index 095608ae..2f221d44 100644 --- a/lib/db.js +++ b/lib/db.js @@ -6,7 +6,7 @@ let redis = require('redis'); let Lock = require('redfour'); module.exports = mysql.createPool(config.mysql); -if (config.redis.enabled) { +if (config.redis && config.redis.enabled) { module.exports.redis = redis.createClient(config.redis); diff --git a/lib/file-helpers.js b/lib/file-helpers.js index aca70e02..fdad5caf 100644 --- a/lib/file-helpers.js +++ b/lib/file-helpers.js @@ -12,22 +12,21 @@ function nameToFileName(name) { } -function getReportDir(report) { +function getReportFileBase(report) { return path.join(__dirname, '..', 'protected', 'reports', report.id + '-' + nameToFileName(report.name)); } function getReportContentFile(report) { - return path.join(getReportDir(report), 'report'); + return getReportFileBase(report) + '.out'; } function getReportOutputFile(report) { - return getReportDir(report) + '.output'; + return getReportFileBase(report) + '.err'; } module.exports = { getReportContentFile, - getReportDir, getReportOutputFile, nameToFileName }; diff --git a/lib/privilege-helpers.js b/lib/privilege-helpers.js index ce648d70..708c70bc 100644 --- a/lib/privilege-helpers.js +++ b/lib/privilege-helpers.js @@ -2,52 +2,52 @@ const log = require('npmlog'); const config = require('config'); -const path = require('path'); -const promise = require('bluebird'); -const fsExtra = promise.promisifyAll(require('fs-extra')); -const fs = promise.promisifyAll(require('fs')); -const walk = require('walk'); +const fs = require('fs'); const tryRequire = require('try-require'); const posix = tryRequire('posix'); +function _getConfigUidGid(prefix) { + let uid = process.getuid(); + let gid = process.getgid(); + + if (posix) { + try { + if (config.user) { + uid = posix.getpwnam(config[prefix + 'user']).uid; + } + } catch (err) { + log.info('PrivilegeHelpers', 'Failed to resolve user id "%s"', config[prefix + 'user']); + } + + try { + if (config.user) { + gid = posix.getpwnam(config[prefix + 'group']).gid; + } + } catch (err) { + log.info('PrivilegeHelpers', 'Failed to resolve group id "%s"', config[prefix + 'group']); + } + } else { + log.info('PrivilegeHelpers', 'Posix module not installed. Cannot resolve uid/gid'); + } + + return { uid, gid }; +} + +function getConfigUidGid() { + return _getConfigUidGid(''); +} + +function getConfigROUidGid() { + return _getConfigUidGid('ro'); +} function ensureMailtrainOwner(file, callback) { - try { - const uid = config.user ? posix.getpwnam(config.user).uid : 0; - const gid = config.group ? posix.getgrnam(config.group).gid : 0; - - fs.chown(file, uid, gid, callback); - - } catch (err) { - return callback(err); - } + const ids = getConfigUidGid(); + fs.chown(file, ids.uid, ids.gid, callback); } -function ensureMailtrainOwnerRecursive(dir, callback) { - try { - const uid = config.user ? posix.getpwnam(config.user).uid : 0; - const gid = config.group ? posix.getgrnam(config.group).gid : 0; - - fs.chown(dir, uid, gid, err => { - if (err) { - return callback(err); - } - - walk.walk(dir) - .on('node', (root, stat, next) => { - fs.chown(path.join(root, stat.name), uid, gid, next); - }) - .on('end', callback); - }); - } catch (err) { - return callback(err); - } -} - -const ensureMailtrainOwnerRecursiveAsync = promise.promisify(ensureMailtrainOwnerRecursive); - function dropRootPrivileges() { if (config.group) { try { @@ -68,64 +68,9 @@ function dropRootPrivileges() { } } -function setupChrootDir(newRoot, callback) { - try { - fsExtra.emptyDirAsync(newRoot) - .then(() => fsExtra.ensureDirAsync(path.join(newRoot, 'etc'))) - .then(() => fsExtra.copyAsync('/etc/hosts', path.join(newRoot, 'etc', 'hosts'))) - .then(() => ensureMailtrainOwnerRecursiveAsync(newRoot)) - .then(() => { - log.info('PrivilegeHelpers', 'Chroot directory "%s" set up', newRoot); - callback(); - }) - .catch(err => { - log.info('PrivilegeHelpers', 'Failed to setup chroot directory "%s"', newRoot); - callback(err); - }); - - } catch(err) { - log.info('PrivilegeHelpers', 'Failed to setup chroot directory "%s"', newRoot); - } -} - -function tearDownChrootDir(root, callback) { - if (posix) { - fsExtra.removeAsync(path.join('/', 'etc')) - .then(() => { - log.info('PrivilegeHelpers', 'Chroot directory "%s" torn down', root); - callback(); - }) - .catch(err => { - log.info('PrivilegeHelpers', 'Failed to tear down chroot directory "%s"', root); - callback(err); - }); - } -} - -function chrootAndDropRootPrivileges(newRoot) { - - try { - const uid = config.user ? posix.getpwnam(config.user).uid : 0; - const gid = config.group ? posix.getgrnam(config.group).gid : 0; - - posix.chroot(newRoot); - process.chdir('/'); - - process.setgid(gid); - process.setuid(uid); - - log.info('PrivilegeHelpers', 'Changed root to "%s" and privileges to %s.%s', newRoot, uid, gid); - } catch(err) { - log.info('PrivilegeHelpers', 'Failed to change root to "%s" and set privileges', newRoot); - } - -} - module.exports = { dropRootPrivileges, - chrootAndDropRootPrivileges, - setupChrootDir, - tearDownChrootDir, ensureMailtrainOwner, - ensureMailtrainOwnerRecursive + getConfigUidGid, + getConfigROUidGid }; diff --git a/package.json b/package.json index 80a97843..7d82f7cd 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "async": "^2.3.0", "aws-sdk": "^2.37.0", "bcrypt-nodejs": "0.0.3", - "bluebird": "^3.5.0", "body-parser": "^1.17.1", "bounce-handler": "^7.3.2-fork.2", "compression": "^1.6.2", @@ -60,7 +59,6 @@ "faker": "^4.1.0", "feedparser": "^2.1.0", "file-type": "^4.1.0", - "fs-extra": "^2.1.2", "geoip-ultralight": "^0.1.5", "gettext-parser": "^1.2.2", "gm": "^1.23.0", @@ -103,7 +101,6 @@ "smtp-server": "^2.0.3", "striptags": "^3.0.1", "toml": "^2.3.2", - "try-require": "^1.2.1", - "walk": "^2.3.9" + "try-require": "^1.2.1" } } diff --git a/routes/report-templates.js b/routes/report-templates.js index f411dd47..7fdc4fbc 100644 --- a/routes/report-templates.js +++ b/routes/report-templates.js @@ -174,7 +174,7 @@ router.get('/create', passport.csrfProtection, (req, res) => { ' {{#each results}}\n' + '
    \n' + ' \n' + '
    + # @@ -23,7 +23,7 @@ {{#translate}}Description{{/translate}} +  
    \n' + - ' {{custom_zone}}\n' + + ' {{custom_country}}\n' + ' \n' + ' {{count_opened}}\n' + diff --git a/services/executor.js b/services/executor.js index 8873a66b..fa001578 100644 --- a/services/executor.js +++ b/services/executor.js @@ -13,50 +13,71 @@ const privilegeHelpers = require('../lib/privilege-helpers'); let processes = {}; -function spawnProcess(tid, executable, args, outputFile, cwd) { +function spawnProcess(tid, executable, args, outFile, errFile, cwd, uid, gid) { - fs.open(outputFile, 'w', (err, outFd) => { + fs.open(outFile, 'w', (err, outFd) => { if (err) { log.error('Executor', err); return; } - privilegeHelpers.ensureMailtrainOwner(outputFile, (err) => { + fs.open(errFile, 'w', (err, errFd) => { if (err) { - log.info('Executor', 'Cannot change owner of output file of process tid:%s.', tid) + log.error('Executor', err); + return; } - const options = { - stdio: ['ignore', outFd, outFd, 'ipc'], - cwd: cwd, - env: {NODE_ENV: process.env.NODE_ENV} - }; + privilegeHelpers.ensureMailtrainOwner(outFile, (err) => { + if (err) { + log.info('Executor', 'Cannot change owner of output file of process tid:%s.', tid) + } - const child = fork(executable, args, options); - const pid = child.pid; - processes[tid] = child; - - log.info('Executor', 'Process started with tid:%s pid:%s.', tid, pid); - process.send({ - type: 'process-started', - tid - }); - - child.on('close', (code, signal) => { - - delete processes[tid]; - log.info('Executor', 'Process tid:%s pid:%s exited with code %s signal %s.', tid, pid, code, signal); - - fs.close(outFd, (err) => { + privilegeHelpers.ensureMailtrainOwner(errFile, (err) => { if (err) { - log.error('Executor', err); + log.info('Executor', 'Cannot change owner of error output file of process tid:%s.', tid) } + const options = { + stdio: ['ignore', outFd, errFd, 'ipc'], + cwd, + env: {NODE_ENV: process.env.NODE_ENV}, + uid, + gid + }; + + const child = fork(executable, args, options); + const pid = child.pid; + processes[tid] = child; + + log.info('Executor', 'Process started with tid:%s pid:%s.', tid, pid); process.send({ - type: 'process-finished', - tid, - code, - signal + type: 'process-started', + tid + }); + + child.on('close', (code, signal) => { + + delete processes[tid]; + log.info('Executor', 'Process tid:%s pid:%s exited with code %s signal %s.', tid, pid, code, signal); + + fs.close(outFd, (err) => { + if (err) { + log.error('Executor', err); + } + + fs.close(errFd, (err) => { + if (err) { + log.error('Executor', err); + } + + process.send({ + type: 'process-finished', + tid, + code, + signal + }); + }); + }); }); }); }); @@ -69,7 +90,9 @@ process.on('message', msg => { const type = msg.type; if (type === 'start-report-processor-worker') { - spawnProcess(msg.tid, path.join(__dirname, 'report-processor.js'), [msg.data.id], fileHelpers.getReportOutputFile(msg.data), path.join(__dirname, '..')); + + const ids = privilegeHelpers.getConfigROUidGid(); + spawnProcess(msg.tid, path.join(__dirname, '..', 'workers', 'reports', 'report-processor.js'), [msg.data.id], fileHelpers.getReportContentFile(msg.data), fileHelpers.getReportOutputFile(msg.data), path.join(__dirname, '..', 'workers', 'reports'), ids.uid, ids.gid); } else if (type === 'stop-process') { const child = processes[msg.tid]; diff --git a/setup/install-centos7.sh b/setup/install-centos7.sh index 524d0310..938f43db 100755 --- a/setup/install-centos7.sh +++ b/setup/install-centos7.sh @@ -36,6 +36,8 @@ SMTP_PASS=`pwgen 12 -1` # Setup MySQL user for Mailtrain mysql -u root -e "CREATE USER 'mailtrain'@'localhost' IDENTIFIED BY '$MYSQL_PASSWORD';" mysql -u root -e "GRANT ALL PRIVILEGES ON mailtrain.* TO 'mailtrain'@'localhost';" +mysql -u root -e "CREATE USER 'mailtrain_ro'@'localhost' IDENTIFIED BY '$MYSQL_PASSWORD';" +mysql -u root -e "GRANT SELECT ON mailtrain.* TO 'mailtrain_ro'@'localhost';" mysql -u mailtrain --password="$MYSQL_PASSWORD" -e "CREATE database mailtrain;" # Enable firewall, allow connections to SSH, HTTP, HTTPS and SMTP diff --git a/workers/reports/config/default.toml b/workers/reports/config/default.toml new file mode 100644 index 00000000..9e7fb70d --- /dev/null +++ b/workers/reports/config/default.toml @@ -0,0 +1,18 @@ +# Process title visible in monitoring logs and process listing +title="mailtrain" + +# Default language to use +language="en" + +[log] +# silly|verbose|info|http|warn|error|silent +level="verbose" + +[mysql] +host="localhost" +user="mailtrain" +password="mailtrain" +database="mailtrain" +port=3306 +charset="utf8mb4" +timezone="local" diff --git a/workers/reports/config/production.toml b/workers/reports/config/production.toml new file mode 100644 index 00000000..53d2315e --- /dev/null +++ b/workers/reports/config/production.toml @@ -0,0 +1,7 @@ +[log] +level="verbose" + +[mysql] +user="mailtrain_ro" +password="S6Woc9hwWiV9RsWt" + diff --git a/services/report-processor.js b/workers/reports/report-processor.js similarity index 61% rename from services/report-processor.js rename to workers/reports/report-processor.js index 8318008a..f1c95c8d 100644 --- a/services/report-processor.js +++ b/workers/reports/report-processor.js @@ -1,20 +1,17 @@ 'use strict'; -const reports = require('../lib/models/reports'); -const reportTemplates = require('../lib/models/report-templates'); -const lists = require('../lib/models/lists'); -const subscriptions = require('../lib/models/subscriptions'); -const campaigns = require('../lib/models/campaigns'); +const reports = require('../../lib/models/reports'); +const reportTemplates = require('../../lib/models/report-templates'); +const lists = require('../../lib/models/lists'); +const subscriptions = require('../../lib/models/subscriptions'); +const campaigns = require('../../lib/models/campaigns'); const handlebars = require('handlebars'); -const handlebarsHelpers = require('../lib/handlebars-helpers'); -const _ = require('../lib/translate')._; +const handlebarsHelpers = require('../../lib/handlebars-helpers'); +const _ = require('../../lib/translate')._; const hbs = require('hbs'); const vm = require('vm'); const log = require('npmlog'); const fs = require('fs'); -const fileHelpers = require('../lib/file-helpers'); -const path = require('path'); -const privilegeHelpers = require('../lib/privilege-helpers'); handlebarsHelpers.registerHelpers(handlebars); @@ -78,27 +75,12 @@ function resolveUserFields(userFields, params, callback) { setImmediate(doWork); } -function tearDownChrootDir(callback) { - if (reportDir) { - privilegeHelpers.tearDownChrootDir(reportDir, callback); - } else { - callback(); - } -} - function doneSuccess() { - tearDownChrootDir((err) => { - if (err) - process.exit(1) - else - process.exit(0); - }); + process.exit(0); } function doneFail() { - tearDownChrootDir((err) => { - process.exit(1) - }); + process.exit(1) } @@ -107,21 +89,18 @@ reports.get(reportId, (err, report) => { if (err || !report) { log.error('reports', err && err.message || err || _('Could not find report with specified ID')); doneFail(); - return; } reportTemplates.get(report.reportTemplate, (err, reportTemplate) => { if (err) { log.error('reports', err && err.message || err || _('Could not find report template')); doneFail(); - return; } resolveUserFields(reportTemplate.userFieldsObject, report.paramsObject, (err, inputs) => { if (err) { log.error('reports', err.message || err); doneFail(); - return; } const campaignsProxy = { @@ -134,8 +113,6 @@ reports.get(reportId, (err, report) => { list: subscriptions.list }; - const reportFile = fileHelpers.getReportContentFile(report); - const sandbox = { console, campaigns: campaignsProxy, @@ -146,45 +123,24 @@ reports.get(reportId, (err, report) => { if (err) { log.error('reports', err.message || err); doneFail(); - return; } const hbsTmpl = handlebars.compile(reportTemplate.hbs); const reportText = hbsTmpl(outputs); - fs.writeFile(path.basename(reportFile), reportText, (err, reportContent) => { - if (err) { - log.error('reports', err && err.message || err || _('Could not find report with specified ID')); - doneFail(); - return; - } - - doneSuccess(); - return; - }); + process.stdout.write(reportText); + doneSuccess(); } }; const script = new vm.Script(reportTemplate.js); - reportDir = fileHelpers.getReportDir(report); - privilegeHelpers.setupChrootDir(reportDir, (err) => { - if (err) { - doneFail(); - return; - } - - privilegeHelpers.chrootAndDropRootPrivileges(reportDir); - - try { - script.runInNewContext(sandbox, {displayErrors: true, timeout: 120000}); - } catch (err) { - console.log(err); - - doneFail(); - return; - } - }); + try { + script.runInNewContext(sandbox, {displayErrors: true, timeout: 120000}); + } catch (err) { + console.error(err); + doneFail(); + } }); }); }); From 7a08ffa596b62acb7ed208d03996fab30dea34e6 Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Thu, 27 Apr 2017 18:14:15 -0400 Subject: [PATCH 13/30] Fix - reports crashed if the user could not be switched (because mailtrain was not run under root). Now an error is reported. --- config/reports.toml | 7 ------- lib/executor.js | 13 +++++++++++-- lib/report-processor.js | 20 +++++++++++++++++++- services/executor.js | 25 ++++++++++++++++++++++--- 4 files changed, 52 insertions(+), 13 deletions(-) delete mode 100644 config/reports.toml diff --git a/config/reports.toml b/config/reports.toml deleted file mode 100644 index 53d2315e..00000000 --- a/config/reports.toml +++ /dev/null @@ -1,7 +0,0 @@ -[log] -level="verbose" - -[mysql] -user="mailtrain_ro" -password="S6Woc9hwWiV9RsWt" - diff --git a/lib/executor.js b/lib/executor.js index 80db9b6b..1cf1d959 100644 --- a/lib/executor.js +++ b/lib/executor.js @@ -30,6 +30,14 @@ function spawn(callback) { requestCallback.startedCallback(msg.tid); } + } else if (msg.type === 'process-failed') { + let requestCallback = requestCallbacks[msg.tid]; + if (requestCallback && requestCallback.failedCallback) { + requestCallback.failedCallback(msg.msg); + } + + delete requestCallbacks[msg.tid]; + } else if (msg.type === 'process-finished') { let requestCallback = requestCallbacks[msg.tid]; if (requestCallback && requestCallback.startedCallback) { @@ -50,10 +58,11 @@ function spawn(callback) { }); } -function start(type, data, startedCallback, finishedCallback) { +function start(type, data, startedCallback, finishedCallback, failedCallback) { requestCallbacks[messageTid] = { startedCallback, - finishedCallback + finishedCallback, + failedCallback }; executorProcess.send({ diff --git a/lib/report-processor.js b/lib/report-processor.js index 09308d37..82653849 100644 --- a/lib/report-processor.js +++ b/lib/report-processor.js @@ -38,13 +38,31 @@ function startWorker(report) { }); } + function onFailed(msg) { + runningWorkersCount--; + log.error('ReportProcessor', 'Executing worker process for "%s" (tid %s) failed with message "%s". Current worker count is %s.', report.name, workers[report.id], msg, runningWorkersCount); + delete workers[report.id]; + + const fields = { + state: reports.ReportState.FAILED + }; + + reports.updateFields(report.id, fields, err => { + if (err) { + log.error('ReportProcessor', err); + } + + setImmediate(startWorkers); + }); + } + const reportData = { id: report.id, name: report.name }; runningWorkersCount++; - executor.start('report-processor-worker', reportData, onStarted, onFinished); + executor.start('report-processor-worker', reportData, onStarted, onFinished, onFailed); } function startWorkers() { diff --git a/services/executor.js b/services/executor.js index fa001578..99a01028 100644 --- a/services/executor.js +++ b/services/executor.js @@ -15,26 +15,36 @@ let processes = {}; function spawnProcess(tid, executable, args, outFile, errFile, cwd, uid, gid) { + function reportFail(msg) { + process.send({ + type: 'process-failed', + msg, + tid + }); + } + fs.open(outFile, 'w', (err, outFd) => { if (err) { log.error('Executor', err); + reportFail('Cannot create standard output file.'); return; } fs.open(errFile, 'w', (err, errFd) => { if (err) { log.error('Executor', err); + reportFail('Cannot create standard error file.'); return; } privilegeHelpers.ensureMailtrainOwner(outFile, (err) => { if (err) { - log.info('Executor', 'Cannot change owner of output file of process tid:%s.', tid) + log.warn('Executor', 'Cannot change owner of output file of process tid:%s.', tid) } privilegeHelpers.ensureMailtrainOwner(errFile, (err) => { if (err) { - log.info('Executor', 'Cannot change owner of error output file of process tid:%s.', tid) + log.warn('Executor', 'Cannot change owner of error output file of process tid:%s.', tid) } const options = { @@ -45,7 +55,16 @@ function spawnProcess(tid, executable, args, outFile, errFile, cwd, uid, gid) { gid }; - const child = fork(executable, args, options); + let child; + + try { + child = fork(executable, args, options); + } catch (err) { + log.error('Executor', 'Cannot start process with tid:%s.', tid); + reportFail('Cannot start process.'); + return; + } + const pid = child.pid; processes[tid] = child; From 92df915a7ec6bd9df5d6c04fb685413b5132d04b Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Thu, 27 Apr 2017 18:25:05 -0400 Subject: [PATCH 14/30] config.user/group used if config.rouser/rogroup is not set --- lib/privilege-helpers.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/privilege-helpers.js b/lib/privilege-helpers.js index 708c70bc..07d39844 100644 --- a/lib/privilege-helpers.js +++ b/lib/privilege-helpers.js @@ -8,9 +8,9 @@ const fs = require('fs'); const tryRequire = require('try-require'); const posix = tryRequire('posix'); -function _getConfigUidGid(prefix) { - let uid = process.getuid(); - let gid = process.getgid(); +function _getConfigUidGid(prefix, defaultUid, defaultGid) { + let uid = defaultUid; + let gid = defaultGid; if (posix) { try { @@ -36,11 +36,12 @@ function _getConfigUidGid(prefix) { } function getConfigUidGid() { - return _getConfigUidGid(''); + return _getConfigUidGid('', process.getuid(), process.getgid()); } function getConfigROUidGid() { - return _getConfigUidGid('ro'); + let rwIds = getConfigUidGid(); + return _getConfigUidGid('ro', rwIds.uid, rwIds.gid); } function ensureMailtrainOwner(file, callback) { From 540c9044aeb7d86f7744faf3bbf4832c74b067f5 Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Thu, 27 Apr 2017 18:41:03 -0400 Subject: [PATCH 15/30] Updated setup scripts --- setup/install-centos7.sh | 11 ++++++++++- setup/install.sh | 11 +++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/setup/install-centos7.sh b/setup/install-centos7.sh index 938f43db..5aa6bcee 100755 --- a/setup/install-centos7.sh +++ b/setup/install-centos7.sh @@ -30,13 +30,14 @@ fi HOSTNAME="${HOSTNAME:-`hostname`}" MYSQL_PASSWORD=`pwgen 12 -1` +MYSQL_RO_PASSWORD=`pwgen 12 -1` DKIM_API_KEY=`pwgen 12 -1` SMTP_PASS=`pwgen 12 -1` # Setup MySQL user for Mailtrain mysql -u root -e "CREATE USER 'mailtrain'@'localhost' IDENTIFIED BY '$MYSQL_PASSWORD';" mysql -u root -e "GRANT ALL PRIVILEGES ON mailtrain.* TO 'mailtrain'@'localhost';" -mysql -u root -e "CREATE USER 'mailtrain_ro'@'localhost' IDENTIFIED BY '$MYSQL_PASSWORD';" +mysql -u root -e "CREATE USER 'mailtrain_ro'@'localhost' IDENTIFIED BY '$MYSQL_RO_PASSWORD';" mysql -u root -e "GRANT SELECT ON mailtrain.* TO 'mailtrain_ro'@'localhost';" mysql -u mailtrain --password="$MYSQL_PASSWORD" -e "CREATE database mailtrain;" @@ -91,6 +92,14 @@ processes=5 enabled=true EOT +cat >> workers/reports/config/production.toml <> workers/reports/config/production.toml < Date: Thu, 27 Apr 2017 19:30:11 -0400 Subject: [PATCH 16/30] Additions to the install scripts --- setup/install-centos7.sh | 6 ++++-- setup/install.sh | 1 + workers/reports/config/production.toml | 7 ------- 3 files changed, 5 insertions(+), 9 deletions(-) delete mode 100644 workers/reports/config/production.toml diff --git a/setup/install-centos7.sh b/setup/install-centos7.sh index 5aa6bcee..9ec03089 100755 --- a/setup/install-centos7.sh +++ b/setup/install-centos7.sh @@ -48,8 +48,7 @@ firewall-cmd --reload # Fetch Mailtrain files mkdir -p /opt/mailtrain cd /opt/mailtrain -#git clone git://github.com/Mailtrain-org/mailtrain.git . -git clone git://github.com/bures/mailtrain.git . +git clone git://github.com/Mailtrain-org/mailtrain.git . # Normally we would let Mailtrain itself to import the initial SQL data but in this case # we need to modify it, before we start Mailtrain @@ -77,6 +76,8 @@ useradd zone-mta || true cat >> config/production.toml < /etc/logrotate.d/mailtrain diff --git a/setup/install.sh b/setup/install.sh index 649b8d3e..8feadcfb 100755 --- a/setup/install.sh +++ b/setup/install.sh @@ -101,6 +101,7 @@ EOT # Install required node packages npm install --no-progress --production chown -R mailtrain:mailtrain . +chmod o-rwx config # Setup log rotation to not spend up entire storage on logs cat < /etc/logrotate.d/mailtrain diff --git a/workers/reports/config/production.toml b/workers/reports/config/production.toml deleted file mode 100644 index 53d2315e..00000000 --- a/workers/reports/config/production.toml +++ /dev/null @@ -1,7 +0,0 @@ -[log] -level="verbose" - -[mysql] -user="mailtrain_ro" -password="S6Woc9hwWiV9RsWt" - From b0d51c7dadbe3c34982a85b2d2b433a5a0afcfc4 Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Thu, 27 Apr 2017 19:31:39 -0400 Subject: [PATCH 17/30] Updated .gitignore to ignore custom configs in worker/reports --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 5f408652..710ec373 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ npm-debug.log .DS_Store config/development.* config/production.* +workers/reports/config/development.* +workers/reports/config/production.* dump.rdb # generate POT file every time you want to update your PO file From 92bffd78de23699d7bb5ae0cf1b8de0443da2b4b Mon Sep 17 00:00:00 2001 From: Alejandro Fanjul Date: Tue, 2 May 2017 18:38:54 +0200 Subject: [PATCH 18/30] RSS Improvements to allow more template Tags like ([RSS_ENTRY_TITLE], [RSS_ENTRY_DATE], [RSS_ENTRY_LINK], [RSS_ENTRY_CONTENT], [RSS_ENTRY_SUMMARY], [RSS_ENTRY_IMAGE_URL]) --- lib/feed.js | 4 +++- services/feedcheck.js | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/feed.js b/lib/feed.js index 6758f798..10611eb2 100644 --- a/lib/feed.js +++ b/lib/feed.js @@ -50,7 +50,9 @@ module.exports.fetch = (url, callback) => { date: item.date || item.pubdate || item.pubDate || new Date(), guid: item.guid || item.link, link: item.link, - content: item.description || item.summary + content: item.description || item.summary, + summary: item.summary || item.description, + image_url: item.image.url, }; entries.push(entry); } diff --git a/services/feedcheck.js b/services/feedcheck.js index 3c506154..5f43c479 100644 --- a/services/feedcheck.js +++ b/services/feedcheck.js @@ -132,8 +132,11 @@ function checkEntries(parent, entries, callback) { let entryId = result.insertId; let html = (parent.html || '').toString().trim(); - if (/\[RSS_ENTRY\]/i.test(html)) { - html = html.replace(/\[RSS_ENTRY\]/, entry.content); + if (/\[RSS_ENTRY[\w]*\]/i.test(html)) { + html = html.replace(/\[RSS_ENTRY\]/, entry.content); //for backward compatibility + Object.keys(entry).forEach(key => { + html = html.replace('\[RSS_ENTRY_'+key.toUpperCase()+'\]', entry[key]) + }); } else { html = entry.content + html; } From 85cefc14e8aeed3457b896be6f8063d44a01e9d6 Mon Sep 17 00:00:00 2001 From: witzig Date: Tue, 2 May 2017 23:51:55 +0200 Subject: [PATCH 19/30] Added option to disable the sender header when using VERP --- config/default.toml | 6 ++++++ services/sender.js | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/config/default.toml b/config/default.toml index abcc128e..64d45fbe 100644 --- a/config/default.toml +++ b/config/default.toml @@ -103,6 +103,12 @@ db=5 enabled=false port=2525 host="0.0.0.0" +# With DMARC, the Return-Path and From address must match the same domain. +# By default we get around this by using the VERP address in the Sender header, +# with the side effect that some email clients diplay an ugly "on behalf of" message. +# You can safely disable this Sender header if you're not using DMARC or your +# VERP hostname is in the same domain as the From address. +# disablesenderheader=true [testserver] # Starts a vanity server that redirects all mail to /dev/null diff --git a/services/sender.js b/services/sender.js index 0610a7bb..bd0c72cd 100644 --- a/services/sender.js +++ b/services/sender.js @@ -318,6 +318,7 @@ function formatMessage(message, callback) { } let useVerp = config.verp.enabled && configItems.verpUse && configItems.verpHostname; + let useVerpSenderHeader = useVerp && config.verp.disablesenderheader !== true; fields.list(list.id, (err, fieldList) => { if (err) { return callback(err); @@ -389,7 +390,7 @@ function formatMessage(message, callback) { name: [].concat(message.subscription.firstName || []).concat(message.subscription.lastName || []).join(' '), address: message.subscription.email }, - sender: useVerp ? campaignAddress + '@' + configItems.verpHostname : false, + sender: useVerpSenderHeader ? campaignAddress + '@' + configItems.verpHostname : false, envelope: useVerp ? { from: campaignAddress + '@' + configItems.verpHostname, From 408db13fd45442adb3ada69dc6d59e5d34cc77fd Mon Sep 17 00:00:00 2001 From: witzig Date: Wed, 3 May 2017 23:13:05 +0200 Subject: [PATCH 20/30] Updated RSS merge tag reference --- lib/editor-helpers.js | 17 ++++++++++++++--- lib/feed.js | 2 +- lib/helpers.js | 27 +++++++++++++++++++++++++++ routes/campaigns.js | 19 ++++--------------- views/campaigns/edit-rss.hbs | 4 ++-- 5 files changed, 48 insertions(+), 21 deletions(-) diff --git a/lib/editor-helpers.js b/lib/editor-helpers.js index c81eebad..e4c4e79f 100644 --- a/lib/editor-helpers.js +++ b/lib/editor-helpers.js @@ -6,7 +6,8 @@ let templates = require('../lib/models/templates'); let campaigns = require('../lib/models/campaigns'); module.exports = { - getResource + getResource, + getMergeTagsForResource }; function getResource(type, id, callback) { @@ -53,7 +54,7 @@ function getMergeTagsForResource(resource, callback) { return callback(err.message || err); } - if (!resource.list) { + if (!Number(resource.list)) { return callback(null, defaultMergeTags); } @@ -62,7 +63,17 @@ function getMergeTagsForResource(resource, callback) { return callback(err.message || err); } - callback(null, defaultMergeTags.concat(listMergeTags)); + if (resource.type !== 2) { + return callback(null, defaultMergeTags.concat(listMergeTags)); + } + + helpers.getRSSMergeTags((err, rssMergeTags) => { + if (err) { + return callback(err.message || err); + } + + callback(null, defaultMergeTags.concat(listMergeTags, rssMergeTags)); + }); }); }); } diff --git a/lib/feed.js b/lib/feed.js index 10611eb2..66bd2f20 100644 --- a/lib/feed.js +++ b/lib/feed.js @@ -52,7 +52,7 @@ module.exports.fetch = (url, callback) => { link: item.link, content: item.description || item.summary, summary: item.summary || item.description, - image_url: item.image.url, + image_url: item.image.url }; entries.push(entry); } diff --git a/lib/helpers.js b/lib/helpers.js index 2cac3dbb..597c36d8 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -16,6 +16,7 @@ let hbs = require('hbs'); module.exports = { getDefaultMergeTags, + getRSSMergeTags, getListMergeTags, captureFlashMessages, injectCustomFormData, @@ -59,6 +60,32 @@ function getDefaultMergeTags(callback) { }]); } +function getRSSMergeTags(callback) { + // Using a callback for the sake of future-proofness + callback(null, [{ + key: 'RSS_ENTRY', + value: _('content from an RSS entry') + }, { + key: 'RSS_ENTRY_TITLE', + value: _('RSS entry title') + }, { + key: 'RSS_ENTRY_DATE', + value: _('RSS entry date') + }, { + key: 'RSS_ENTRY_LINK', + value: _('RSS entry link') + }, { + key: 'RSS_ENTRY_CONTENT', + value: _('content from an RSS entry') + }, { + key: 'RSS_ENTRY_SUMMARY', + value: _('RSS entry summary') + }, { + key: 'RSS_ENTRY_IMAGE_URL', + value: _('RSS entry image URL') + }]); +} + function getListMergeTags(listId, callback) { lists.get(listId, (err, list) => { if (err) { diff --git a/routes/campaigns.js b/routes/campaigns.js index f239bdd6..424aebbb 100644 --- a/routes/campaigns.js +++ b/routes/campaigns.js @@ -9,7 +9,7 @@ let campaigns = require('../lib/models/campaigns'); let subscriptions = require('../lib/models/subscriptions'); let settings = require('../lib/models/settings'); let tools = require('../lib/tools'); -let helpers = require('../lib/helpers'); +let editorHelpers = require('../lib/editor-helpers.js'); let striptags = require('striptags'); let passport = require('../lib/passport'); let htmlescape = require('escape-html'); @@ -186,25 +186,14 @@ router.get('/edit/:id', passport.csrfProtection, (req, res, next) => { view = 'campaigns/edit'; } - helpers.getDefaultMergeTags((err, defaultMergeTags) => { + editorHelpers.getMergeTagsForResource(campaign, (err, mergeTags) => { if (err) { req.flash('danger', err.message || err); return res.redirect('/'); } - helpers.getListMergeTags(campaign.list, (err, listMergeTags) => { - if (err) { - req.flash('danger', err.message || err); - return res.redirect('/'); - } - - campaign.mergeTags = defaultMergeTags.concat(listMergeTags); - campaign.type === 2 && campaign.mergeTags.push({ - key: 'RSS_ENTRY', - value: _('content from an RSS entry') - }); - res.render(view, campaign); - }); + campaign.mergeTags = mergeTags; + res.render(view, campaign); }); }); }); diff --git a/views/campaigns/edit-rss.hbs b/views/campaigns/edit-rss.hbs index d9080534..3c13c859 100644 --- a/views/campaigns/edit-rss.hbs +++ b/views/campaigns/edit-rss.hbs @@ -71,8 +71,6 @@ - {{> merge_tag_reference}} -
    @@ -81,6 +79,8 @@
    + {{> merge_tag_reference}} + {{#if disableWysiwyg}} {{> codeeditor}} {{else}} From ecc777931279f51dccef5dbf64b401f5903f7e6b Mon Sep 17 00:00:00 2001 From: witzig Date: Thu, 4 May 2017 00:47:08 +0200 Subject: [PATCH 21/30] Updated de_DE translation --- languages/de_DE.mo | Bin 79765 -> 83283 bytes languages/de_DE.po | 1016 +++++++++++++++++++++++++++++--------------- 2 files changed, 679 insertions(+), 337 deletions(-) diff --git a/languages/de_DE.mo b/languages/de_DE.mo index bd4ae3497909b24920caf17ee0e07165e3bb8c41..fafb242bf05166f0bfcff4b00b1bd27de327f0fb 100644 GIT binary patch delta 22196 zcmZ|W2YeL8|Nrs5Kq#SwUJ~HwJ%nDRBOU2oFy#Uvm0UuH1Ee>>pcLuF3ZfiMP*8#h z3Zfu_N)bd*5d{k>i2h&in+c-7?|&aop7WWRot>ST-Mauj^JC7_^MidCi{yO9;h2%b zal)}SKVHx2I4zs1l%t#D}mU#A4VA%VJ-wies=QuE4sOh2`;QEQ3Y6 zJ5FiG@i`G>YEaM_yJ8yV!ncqyIfwCiJc@-erHA9>#_3ob=V1u0!&3M%mcuO6z^8N%KtjjPT@fs|MFJTGXiyG)j zTYecsiEm*rx*iMMxsw}nbA6`*8I7z4>JGY~I*zt+GR6?kL3Mls3*mhnf(3e+I~|3Z z@+qhpcoEg^Jsgc^ZGF4mj#Gy?34Kk-JV&Mop2h-r4Ijl{P)pLHkK-ibY*hKr7>UjM zIu5JiBp}P`>_xT9+0Wd_7*xOWQA@D~wP$vqZtzGy=Dz`%QxvqpT>Z^jc0x7mg9R}T zHLwiKk56JKF2TaM!Nz{n1rMVxa2B;>U)lQat@lw&T5tgKuZE=tm<}tW)~FF`m$pD2 z24?`O-B8qK8i^W63ab5N)P-iC2D-q;D{Z_H+3n72r~$u^x`FdPG8*ws)Z^m}G!4t4 z8Z@igHtT+@iic2B{4Hwc{=(u|WRRKB>Zk!WK;1w~EQP)fWOQebTjNm; zGEf(of?Cs=s5`rjdffg-ZLW~PCN6^-Ko!)IHAme*7p#p#kpG-H{OE$0kpcOfT5Msx zcp9Rnytg$GOX~#G%xpllJ7~+#qweqymc;x+9mkE8QRN-66b?o8lWyb17)Ja8R?_pI zMaE6Rb<|^+XPB`%Rv_+-daOp<@@KFj@h+@{=WP5Z)*vqbxVhslScP~j>Tz6+k+>hL z;rAHM^_^nF9j6x7Lv`>tR>wK0J9`avM@MaZ5p`#GP18Q^iMqMZlHGnConOTaJ@debTJC3@; z3#fj6MLl+|Xwy$o)MH&e+Gi^2P@ofAqZ;-?H%6l_^dxEk%TWW^YRy93;paB~4s~G{ z@9TkB8g)ZsQ0;uE8+r;gu+=J1=0#M)oi;v%n!*oJ1Gs?d;I=Jy#hSGZMb+2Fyx0bH zURP9qgHipD#5(B3RNR2t8@?hQb4L+ao`R03-R?oXFqWXEcqKB6&UWmLh2zZQ7=x{e zr=e!>ZLEYJqXzUV2HqE#lQ?9g8CW4?rhQIXGCHv?s$m<{iQTOIQA;ucbthv`OECpC zl~1GYbe)a2p_X7DYQ|1sFkV6R^9}0!yI6|rJB3D>g6gP_9zpHWZZ`IyX26S`a2{%) zA7fcOkGb)#Ef0z}o3Q{!P+lK(UKDBxUP84$i@{vqxnv8zMs2Q}s5{L$+B|NdsJIg5 z!n&xnYmAz?J~oa)4cv!$a3yLlY{U?J9o7GvHa?BMG8A026@Q>E5Sn1#a3xV|*&Hk2 za9chNwU(Pu?O#M)a65+JUev&{Z2XD!66OtZobOOG^Gg!*uZES9%^fyG#cePz_C`(p zP}EwFLEYh0TfPFT6Tgbuyl1g0{)QT0xfJtsR6@0Df(7vjEQBd3%)bhJ6y(FjsI^&z z8puY}0Q|_nonxrYbR5<06zcK&9CiK=7>ReW5Y|dHeNY2T@{!ThrJ*h` z$;MMrQ#%LS<6_LjQ@9x8#+c{$S6r(&jYk)Qy^hlpyI~Vtg|FZ#q^UDCozD%th11Y? zJ%d6rqsH>O#p~Dz-Q$b{Q1ASO7>esqGxi$lX*p=?PoS3UOVrGKhq|$!P0FXpMwB@%@PfxzNroIyD{Pw6#-50CkAgqf%ADMb&wxc?_ zjGCgKu@^e?%$@f}br6RdKmuybXQQV2CCrPvP;a_}sF`{n)!#+bKyKhPyl3it&gA*# zd0v9KsMu;_Kei=4i3M4T!VBz+X`$(GfHeknUaF19p$0V5mM_8D#LH1re;6y^1+1dy z|8FvtDX6l@yhyrYbK)q}W?GGTa3g97Ua|2p)MIxFb!V4RFQVJ1^YSb<11pFsFOFeY z8^f_P=J#=D9y0M1q&;P(_&nAizKpt)d`rw!mPB<_3w6PEsHNzG+AB$@0X~WOaRU~> zS8e+lcsso~0{X3d^Qo$xwpN)Mto*%2FG!%*Uzs5|%zHNdc^%>b*RHgOA7 zc~8_phoH_IjRkQMYLm`?n)xq6W}_|Gg>K>#sHytFmWO1T@`|Vq+M_z?gSl}uY9`W9 zQ@;p{U?!^l^VVIc_rYP*d7ovn{<_o46le;+L5=(`d;vq2nI+kQnyEe3!x%#RF_y;j zsPpfjo|62}n0^bR;!>!YEsJ@v78b@vJ~Ha46K=ph*cN|5jj-{v=EAK|Q`yZr6xBfj zYAL3mcK0gOX7!_P>`m;6S*X2HWVxA{NK`vtdont)JC;NbYAUDLc!l*fR0l_G{Wa9y zaIG*)RRJF%egt*?By5Dwq8pE+X5c3B=sWkY7}s}Ztuz;2g~h4Zfm(v2SRFq>&BT3M z9=yujd12JrRzh`LA2ma5Y<({qN2B&q8mj-rs5{?`p?dzek;$Uq0BUV!JZCPr5VhvZ zus&`;UGNjsntp{^f^Sf}`!1Hke5=g`E2GYDgF3GVs@*W;&YdL8#r2&TWOU(qs17qR z47b?$9n>BVCu1ct3Ke}2#v^-3O#Rd6NN!-J?7+->ZDCDxjy9EySGe*&2j zRLn*VV2zEpqdGc*74Um3iv`!2DX)!c-yEA`cht_xXc}q;SD|KVGio5)kyUYaVj1kU!OTbk zYDU&<@R=z)M8R|lzQ$-AxzUurk9CM|;sgxaWKMhrn-K4_@lSY?xa4NDSFU4s;xb#z z430$Yp+(pQ-^O$d@jY)php{IG+fi%#7uLeMyoKU$C??_|9IWye&7ICe-RUyaRIfzU zZ_`4fv04|v%u)o9cnnvA*8H^-)XF4RwBhRKKH8kKcII^%f%o_c?3G)4m;JA27!D)VnQ9o0Y$pgrou zUbcJ?79viw&OqJC(>NPnvE}t&GXrmjx}gE6y^(|(_*m3{7oe{anJu>B7%m~ch+{D2 zbsiC)87L7t7(_sD8@rGH=o*sIT*V@iEF{c5CPH zDY=`UTH^+L%v5#4TExRqn{7Vo4t8NZyoSZF)Lyd*>!9||NYt+PqLyw7YKrIMK-`QP z;Gd}Wt~bmK7Vwc#MHp&|-B=Qkmr$?ZeOMXKVG+EK>NxaG zGb6RICsW@DH8V5cGE2D>*_=LS6B(Vj&sLm3P5mXTgCPfuO|5ZQo$_T^8s9=)@GREH zpKt_LIcNqv3-x|kfIJ}1Q>eYv`fbgyk3VE0qc_uF48asEj}y_2t56r(k6My5r~zES zikRyi^Nz29I{z`$ObtQJ;1j3|CZc9;Drza8QaRUmHj~ks?zR>0qnr2=hM{xFoDhzB zp6j8eva^l*qXw3UT8eZmiz`v>_MmR)Fiys+s2PvSV*d4*rIXR-n29>^7HZcQJZw5_ zjk=RqoP(*@1b@dC82PSwMJJ*zv<-EqCvXs6#L?K~h&g{F#t_$hkNIy;X7PJW6Q062 zY;cs{cySd5Hq$ZFVJ!@&yd~-m24fyf#4ebDz3?FF#zNmW?*TVz=9;0Vz87jqJnu9A zn)-1R$OYDQwt*jY=kH<&evG>CdFzjumpJGH(=I>i&P!oVtbv-*2pe}tUAQ-D$zptD zv_=W23yeo~_#~>MMW{Pmi~8_*0n6cTR7Yn}1Ge#IJq4Tu+FOMDw6 z@IE%i+Mk+%$Dpn=7uEj`^l2m)Y(el@^QTggs2S;pO>h#{K|g9QT(%ZGX9m;)i%>oi z3*tl@&$IDr8^3~us6T)u@xybhe+e?zCxwWN2kD3<-4`PT*OeQrk76*WcCr~xgsZ8#KIa!EE`ge{8dwYmpq`cl)C|n?kqINS0W~#m*@_d^uQ7x2`=|?h&l_i9 z0pjI0-h!I?J*a+;*!WY_jeL!|<2yDExnRnDVPy1J)J8SviF(eXZG$A#g{ESDT!@;X zm6(WGSP3I9nj7hd#feiGf z7g~ziR9jFTY(=f<0W6KDQSEM`2JE_GJ`F=raU_OgYaEKhFdx@<_L8Z9hf%xq8tMYK zup9<`WjxX;GjRtsGp?(~5Y(L(McsKV)BszemT~}UK(XknOvXz_Pr(X| z#MiMVUO{zS;A=C*B~Sybis~>Db7NPmf_<<&jz`VZ3apOnP%o~dsQ1b*s67;Rjrk8I z)A5>phg$CLck~>@ci> z$=@>nmB_51KzIBmD!z^yalY@&m&YbphPVaR#{sCdpNG2OTGW)jYJCeebH`CLdj|EQ z`vG%c@C|drd3|K^QV@!IY|3ChjKpf#619f0sK+k@bw@K$n`sH=#BHbx?m#BS*^ipR z1DFesU{5@O?_&7(>^JmXAv2%M3pe>=60HA&`LMZ;al{pVG$+o*GsI_c7H+%6gYKrDzK3tEwgRRz` zs5^cWwM3tx&byCVf_%U7n=OW63ONx5i$3kz zwPbYSHY|p3VO2bZZu|pFW0|{VRf*sBT!R1z&a7tey#N#)PS#|*7grsCLOqseurD25Lrr<} zoW>5Q7gbNJM12(M#%2Y(%x_4j`V&4fTKg|i7ybdY6izO4flyRj0ku^1Y#C>*J+Kr$j=I2D)X3+fF0cc&s}Ey! zJcjl0C+vV#@|g2RqXs+``{N?iK)yoliQkd)ea_!xT2N4(zmU}lu~si?Kr^reK8+f{ zOQ?bE$LH`Qs$D{exsgez3v9%2duf5<^RE;3P>>rx!U}i}bq9A*QnX*dO=BN&Pqn0KKb?0MI z?Wdy#xEkNUZK#*03vT z6M9e=Oh>KxOw#~8+fk3zyQm8vN8QL-)RO#wY8UD@Gf)+^WRd7=MW!Vg zjcgLu#g|cQ^f}hS+!b7bKX7P-k;LiP7q_4r|3F>1Oht2{DyYY+1?sVU4E22>9&6$T z)C=r*MV^0+Ql06HCNz&W*LmS9{>KzeELck_E{o~9RfnSldi>ADl-#4i}-Tjw|pj+4mF~t=-?KI}M32Gf)V} z5tqU!2DAh<#Xr?GYn(UIY@+h0rKyFQu?DCmYG>_-+I(@SrJ0QCXCATyK4(1{-TAAi zDLISUG)+FpgJO*pveyoaL+w%PNU4cJM8-U&Q z{4XJ+sk(rAK@@0U22c{!K|Rza8jAX?7>9b^m!Jmx3bw%yQFm6Tq1huWRYADN*P3`cF2eW*2k z*Lo7QCqBb;yn`BGLR-^56?I3GQ04PckKq%iHSw|_y@NOga?(cJfSX9+wv!xOuq8=H1Wx8e zm#x!Nuj2$|74SQ3VcY&h{3Y$a#KDw5NB$#H9O*D+Uz2*1UJv5>Su_ac#3lF<$!9w$ zirsCVwt=G$aT(H0qGZx&+TW+s@+3ETz92dt>OY}Q@5#c%IzlM>4NGH9+SMcJsH*#4 zMerPV#%Hiy%LlvRgQj zvgwqqCBL5Z9OYl*Kzu96$;@h6+*j51F`9Ojsar?dK$=IJ?@75yI_^>b1QxdK^~~xB zBTk{cj?E-JyBW0A>vK0L`*_~A$&UM*etga%D%NmG=PYS2`I1vMYB))Vw z&)CM7iN7#?1b*ubBR_z$AksEl{uN#(y~H_NNS$o?Z{&X@m10nQ7&$dKrw~a;4gCo0 z0Suw=d(yMye<%G+dW@51kh*i?GLnZfedRa?Z_+lAyk72=Nppz9NILFgS?X?(U&47u z@G{1$@}c8>@;<(PIhAQVoTTGd;_^6>PTG>(KFaM3?%aQkz53v^}8#oFQ z>l11>{y@8}B)4sokNh4|cj_O-ZKMy#>w|(nhH;)H*71|pe;z>{(kTi$aWNeo@iWTS z)A$haRN~{r`nGZhU$+-JMqbAaYYEE7&~}`yH?@3eBwlKRZshq)aq4o;daZv0dr{RV zQ+R{Q4{?(1ROL;`KSsHZO0?fW+=DoQ{2a=%NIuHW**3N9xwnY>Qg@ShtF6;76vc>V z5(oBQ4O>CEGl=}JRF0?P(Zv0504bC5BevaP>SIX1lTsf#S0C^?-XWc$PRCfBPO4Fm#-{kHMTn7~Q;t~J{>{FAbJ3_wRgyi5Fw37z7^DK;W4^b&1qsE+(T zQY0xC^{-LB3LlY_UDan$2^ub>U={+{-Ht| zUZ<=Sd42&2d_OOieI?3jkq@TaL;8fak%6=MqjTad_I+fB6DLs-^Z#Z=&$$vyu74pMKI!^25 zp<@H-f^9sB{O6=5)P<2=AU~9p+qQk$wkv4|@(JbBD9d5Xqi7pT*<4aj+KfaUV+{{} zj;796kIGgQ^|2?9!@nsjVoy@*u9PJcmm_^cDrMWMu08oQJCJaEj<^=(r)`^W$)|D7 zn`+FFk2c**)``*jw;?!S8{2Z{ZyVRgnw;E~lSbhS#DCN18(Y8Bs`GAAKP9^Ze4f)3AFx3{srO6{b47O63174d7^W(aW%`A;c3jJ{Dc?nkBz1+8tPqvU@f?V|1} zoJKlNz9RMI@Mltf%1V-Syhiyh(i+Njd`x~i7aB#HKv^9AKv_l1g{iog@^AF7D&C~v zandvjbp&U=-m;``xb5l-YIIyQ1b#NREsGItf2ds9mbAZv9?l<6I!n4l*%Hde;!F6U z?USsNo4!^O&$8E3S!aDMSz!y>(%`mAIf~e_w@l~+abkB{mY;UDDGw#J)&QyNLR|yW zQ>2y@{Xkky%3%jEnRX|LL#eMySvsj}ppI>MlFXA-tPK?K%N))o*6}j=*5rRAU)|Pc z+PWFUjmh7|H>h7lz9K1}c!6!x+&Yu`=A=hS>quVPSKwozD2*0T(4X`z>7cEwVoxqk ze2vt`miM89&q#}jGf~HK@_D(-M@grN8wE#ja3RfwFJuAV29VQgPCDQcl{8wHSz@=~Vb zB4w!>!+R!nir9`6o}|%o(iIw&CMDUrJmfQM2NNheMVw~ihQvv>uWbIHexeNu;}^DV zU&C$ox#OW!0?HT>j)oy*V;3bm>;alN1%7=uS^@ zM<+(bBqYXr)BR1mHgJXX&4~8Kq{T;j(lR~W##c_wh)#@;ac6ivX>q@M|qOG zp2V>pum96-@xe81FHXDc{yeb{=iTwiaVaWDPVl$veZdv#j!#NWNlW*{x&uS;`iu7c zEvTwTWlsB)jKo-Xa!R^8Ce0I-?s2CD%H8Rnq|`)W|8M=>uHZmfX4Js&T&6m6(7-C0 zB?jKB@}JfZbr%<(9BVtv96YF2aG<6Chk>8CN<6q^Mr!Oom&|;7@WOCAC^M8kW+?r# zhtht?OjqG3cWPQnjK}MZPaf%xNs0CNvxYw7%BiNAJs%%f%*=LNT1wJG^S$QrZaKnC zcTUIH_;mMwy3V}v!~lQ&s2V{Rfy#l2Nq2YY;Pi+$cV$bmmw+mNiRp)3g`yG@Q^qld=n3xjkM-&6KQrTM?wnEH zl;q6a^T+#=V?Ak_lkC$RdyhS%l04b@?5X^3?9DES^(1=ODA{2$=emZ;A&&4*Ui@cJj);f|f6CG}LHXE4sVUz0^mx|imCW_!|9M!|<;cX8=%~bh zwrF#Ivt=&@Wq$JPD*uV)%YtfXgScZ;Jl?>w8CZF@eM8t6L#lfAMr00KbK_Mt_GlX1hY#MyDhv zdy>;>czEA}h_;D|o@BQtEio!1+LO$f<2_za;Gx$xPmE^nC-MyIq9{*qO-R>SvD12ymP|d8>?26R1tT*F4x`2HP(;uU7o1xZe8<{nHH*@#LPn5EY6P4j*i#)tQ`A;3s zQQnM@*No}pzgNlp;LK!yn@^VpmDZbCuhzi3+3g9uu9;I$vj3xV@8&ArDJ>&4H898S zf7aZ;?9#TJ{uNh0&E2}A*X@n>j7oQVQ``)tcA%$GS)0?fD1DjKhqnUj#|?QOYy@7} zN#74ERNYQzmk#bY)gx1ya8Pja2?3jfi2m4ZUeHcNMp-*AyR%Qvp5BMv+yAXHyW+tdxkq{uvvzx9MrDsA^X&^IbD2T1hQ+eSYft+> z&9l2t)_KMLwO2guaT#f`+6l#c|EY*dNY9AkHKoaRI{bG#dfX4*xPcyc*aF)pdx-9T zoo>4R=am06_Bb8W;`VIK?W&ive>c6VM<(+)B?tCVN_0Grzq?;lv^J+%Q@(n5k^-w4 zl@S+s_C|ZgdEygk=^lcfWM0`xY(l<`#Uyx=Bldid&$S^(fwoCJ{hXW3NS@!5P{=ha zC~sQ2eTy%@QP{P;vKCR>*Sx=J`QT;1rQ=u#w&(I!i@3t}tSaKlna2@T-E7{t>)$dO^ot#e@@$fR?gP( zX%`>8r)xFWu%NO7_~z8klbn%0k&ee@Z61{v9mSWKNBKbTr0v;K!*w-p>3_Yw_&8t> yIDL8YW0(Kkz*VDr_J_ekFMDq~A2v~GvH$*9cu@NA=fZ=+zz4&gZVg>sbNoNs)-P26 delta 19363 zcmZA82b@gT|NrqjTeQ`-=w*jhR$nc8??eki)a)|WvdtDPm=Ht>A{!+{2_l6cCM;1B z(V`_GdM^`gKAeF0aTbQ-VvNA`SPJ)G1-y!tF;_dsse!dI5(gtg zIIm$b$MHJL$W)?YD|W-x5SfGRB6vg6L8f&8_ z_5!MZJZb_dSOVu_S;lwPlF3EIH>iP6qweq$=Eggy_9s@&QW43G>Q@LgP&w2D8`%0z zSdemG48{@29XlgY6PksQjPGQT(ae3Q3m>uNGdP&?1Js3kb>d{+0#xRRQ~~}B}nK_{58->DzruuQENQ~HIdo2 zyu_AQqtT1w#scVlY%6l~Gy{jCmZAb` z=5?_uw#An)9eEI(qo@fL>1EybW}uUquwuTZT;6+NYDQ@lW~4W-BJD* z&50FJm?QOZA{Cio930>ucEtb=8+E2@1Ys{d@;z6J~E`QJ?@ z9FJib-au{2-%)ED+}9Y2YA=QAA7krVqn4~2YE1{(ataosJP~z6i%@&#E7Z~+La#Q( zc`_R47HZ(%FaiVmIZk0Lf*Pdr>l@-)#ja z_0$9mGLa1Nl8L3F4D#-9Uc-`j5}V;eRLE+*Y<6=i)P&+u&+$l9h{vNQHVGB_xv2Br zL-pH)I&Y`-0IHw&7#W4?I_d)VQRSzo5Qi{3MJ5V$LT%LEXoK3#eQh}%b^dhJrdwdk z8&P{^7q-I_s0o#f^S>9oPDL`>m2FTBov|GD!Rj~xb>ccys2`#Fm*t~Tuk6aGattcu z^-)XH)!GmBCXB}tn2d_dOn=$yI7`TA#@VQ~IfmLC=TH;6g}U$~TMijwp6|k_`kJT# z+GAPlicy$`WpIJ5--0xYQGOA%`}<)url2CS2(=eJ zu;pyj1omT2JdV17bEpa2M&|9f63kKsq59=PofnqC^B+T|G!?pVSKBZM6_F8G97mxh zHV<>*0@Q_<+ww|ONY`R3+=N;980X;3;pXuyHiAc5Wo(0$5{Z8oGAW7XE7C6PMENnU z#g<7tVt5xH8JPt?!xvMnr0r?zNibQq0XC%TFTX^-M%C$do}ixV@O1Ne9gSMUwx|>PVC9ioj7+#Ll7i&NWp3hp37Fjhb*+rs-eK zS`)PtO|cl`JDtfCq9Pvk>YacUaItka#!&tVqp{d1^Db|RbtosHCb$v{;fJV+?Ltj# zKk7Yj3biD^qas&iG%~)^kW4uoV4aM5fqY;+jJnW0)CK-UMX2-`z5!!*ERSu*GLVZs zgKa6#9!Iz-pTJd=%a1n^K8T9Icj(p3FO$&{+`tHYfEqBz1QWW#n41elN$RUjG}gm# z$}O+}?Y&T&*27)tIpvJqeZE)Chm0E)kp`#> z4nVb!wB;$N&@Vy_upUd{F4TlBU?e`lB3NXSDc6`}2kc6PHrF83+7H1RI16jycGQgT zpf=yX*bVbeHg`Gzb$%LZz|mM1-$F%pGb(bsQBT2P)OAjI$!Nyc@m2g4b%zZw?5%WJU(7f;ct=7L?U{ZQu(v*i@jM8@0t=~$JrcP^QtWOiT~Jc;G; z36{fB)69#aB{rn|5(eYjs29~@RH#?k@@~}TI)u8hvsf5!q0Vz&HxtW&)O($LWb}e5 zhb6H&=H<=?U;^dg(@khkU?s|DQFjtJ!$hh8YJe!zfDKVg&=IvKJg5o2f(rRNs8{eB zf0_0FjEvTLH!jBq{sum5W}3BHhC1OR)F#Tt9C#YFc`uqN{${VXho3sC28 zMD^c~y6$0HK92tH|EJ01rr`!^m)}EO$TgcUkeC-+;Cj>q?_*y49ToE6ImSY$^UI?m zQV+GadZPA95^4gYP)jml4(qRwEU_KdqfW?1J*VHHLim#{2hKJ1B~TZrjcV_XTAD=E zUU~zY;D@&TN32WP^``mRu^#55+~-Z=&vH9Msn8PKM-3c2&xEcND)hB68k?aaFbqR6 z74zd{)CK2Z0bGuuxWSfpqb7C?wIp{?5ef9ZWj0Y>+(Shv)KXkU4Rjk7>R+)I>T8V# ztc_aJmKcidupqvKir{e6fUlv>UvAsip>A|1YAL+O$!Nf{s0&?3o%owAhrVt0LKG?z z)v-D@#44D8WpO^1$E{csFQMKa1?M|XYpjb}vT>*hEkghE|2`QFyu(yD-=HpZ1NA}* zSzu1Ah6-^fRR5Q-A&x*rVht+vYf+)yj*7@$)YEVhb>2PH1Y8TX)U1DgGTN;bP$#sq z_QZjdJ*a{AqVC{0>Vo%Ck;}8lL?{9^kw{#E;9TTxysLvm#7nwLb zZ5tXccAT-42cS+kjrH(vtb=v9y6O|~TbzU18w1}pzowgqir^(web7?#E1Pf06=7HdwKgqL7wBrsv8W|V#-cdOx*D|vJ5lE!K<$BZ zSO|Yc4XCd_TABhFh!NNTi>@O670J9ng&c>?aV1v9>$YS5)uz2Y>P}LzFpj~}I2ViH zdh34cCDeQ4F)9*~@0;_gp#Mg_WE8Tls5>5vdM-0D0>`87U>+936{vweM@{6oZ9j)P z?-usJ`>2SuUSrOSMGg20>b&W;-uosQh3sSN=co`L!fANj)(`){OmrOT@tT90;6l{I z)?pOxxAi|GFH`4V9Eop!$d?!l_{e-3et;~U*ZG8u_CPj9;y%oYH&6rJ!M6AqW3b7` z=5b2KNt9P$8!WrlOlX*O0_sNILfzn6ROGg!BK^I;o%O##MtAZQHE{Skvsp@@*4B-> zP%|uneNf+aGq5Y=DWB*z_?nGc%AV`Z8V|!Nl)b3Ev=(&(r?4jG+`zaxu>l$F#x59w z(=j)`jd^hyD#UBCH}1nAEVa?}FN=97S4Y)1z+h~OeXup^&X=NYa5L&gcA{4yK1(J5 zuc68}taq(X@LTGgPkG(Kcc_6HZ!&MV7N`OGqn?Tk?2EHdH}oUw`VUbP`wa`?Uz-SA zDKdF|=8aYv!zg#f;uwdD#02b0$X`cA#9{0V9xA1Y+~P@DCXt-p=ol>f2yq1()zS3s?C zcU1o*ER2&;5q!s%y=%y5Q|!cYxDT}?KchP4`rO<}AskP+E-KWUPAH{4}@x3dgirhE;D zVyiEBTjIx99Nk}<>ovlXl%GdUI03b}$72Vaiu(H}=UXxbsBrH#&v^_gR2@*Edl|I^ znW#{|Ze3#CVB7bg?))Tb39q6C{>Az)DpFxznWrZPLmA(xOGZo34i)mQs0;K*?f!UF zghrz7U^3>%S*RD(V$=k;p$0sRMeq_<#>c3oEwjh;Yl|xPL9hSuB9lSI>!|YIsK+M9 zUK5czScYdl5j}0(*Kko!=hOKcYYJh+Pd^5(}SRR{T4IFICS*RP?X3OUf5Px-a zeQkd3uZU$ScfoQv(z?L9-FgKT+TercT^@rf$6-aBZQYD@C||@%73iy5N1RjKzizd-D1o8Z262W-$bqbpQufi=Nt31lttAyKyAhj)_&F`)RKBp?}5db zOV9r)G8*_YY7HNn2ImjVO*!COvm|*@k6#Vc=4pkRKz~#uGEh&+GF!g^Lnt3WP3U_J z#v7>dZu{$5|EIR1;CCh@QK-$<4YjL1sDWO?61W5lV79G4V7-ib5j{Z-m~_nO#e9_K z+46GCPx(`+=l@GG1@T+d9bU2JN7mruroJ%dp}jJye_hmt+hShqiHbxlCgLpA9{3$K z@si)052^ZCopKlSDnt{=xN!#hU!^#NvipRIz*Ou`c@>7@Z>UY1^Q5_x7-TJ-W~d3a z$8hX{id4KUkGADmw!Gvd>#sZ6K!rm5C5GV{>uu{()E$JLG8e9fnn(+bz#gdg#4y`F z85QCM7>V!O@;=mO!U^n$4^9z(MWFL(^I~`jwdr0#4KNk;id~4!aSQ50>IpW)ZfDFL z&q76Dfps|+q5L802ERlt)iKn>E~6gfyIwLy$%LOZUkIHTbb*I0go}%z`W{HYp70Rtp?|~H59$A2T8oXbU(FyylM^P6(fg1RNE&qaA znnzeCfIjEVo2~Ihvm_%?{imXqWIpPSKSo7xD=MO=Q4_d@Y)=0D$0c*e^-#Mt4mIOx z7>Jv&IBvmOcnmc_&}B1VF;qw^S?i-B*A5lgZde!-P}h4Eb;DCIH{(0A$Y>ADN4>k> zL#^3C)MIrHbtgAbo98zS!mul5pdv^>95*U*QK$i`U>B@~`*0HO#dcTu#KgSU_}!zI z%tA5^@cHZJ8_hJ-iFfctZ2SWsp_qkJG2sRSy7)~7uAuzzN4_tr{ib`PfAz?yLmN~i`lIe-m^Br3=VLGm=c6LB4YlS6a1@@#6zuY| zd0cm)u6qvkvHQT5!*83-TKP8d*9AIKp*8Mn8!}M?&c}+l35(%5)P){lAuRlhc|lc0 zO{^mZVKP?5bZm($uo_;+To`)C+(2>tg@Y=hZADGggqoq&uq(E}7cn<3!eCr!{SeiE zy=~ux+T~wkUOb1ok(;Oq2i-NBwK(d$N?tNrlLlA;yPz90QFpumtKmkhhnKJ+7QSb` z*>uN7lqXqtq9*U`(ExxwP7iA7;qtc#V<+lP!oJlXm&DwM~qk5MNSe_(8an()i0 zrO3oOxDZRY`1?PsP5GyXy!R=W{FP`@p7Mz0pu89L6x4t0kEqxAhfF9HIiHx&7Q+zA zk*E)mDya5mSP;8o4=xmkT9Pfl8^1)opblXa?H5ru=K9lYwo0h>4ww)7W2m10;baO> zF&;I*8@9X@^HcuB*6%<~=qN_wRn){>f0^^bQRQ-|2t9|oZYNYk`=KJ~MJ@G83}<}j zAQ{c{BI*Lcf16!g1fwaJM7{aiU~3$Mn#fMn1dm}ayn_CL|1o=@6YBgIuo1q3>Yr`h zk6z92BpD5S19jmixCBF@dggWfPV=qA&55!;A*?2e+|4^O;gv>StRn2Ip0PdO6TVINeef^(WXj6n5|L^oDL zMW736^Y%hTXees8Pev{2d@O?>q0T>ynxOYA8HMPPH6)k0Kv`4>o1#M74t2r{sDYAj z8%{)pvOa%7tcf;7?WxYF$9Mpi#{|?p* zJv@VdqBhz0c}%~OAtrL=P!p?(TI-gm8|#VMOCwMb$U@!NX3VGOe;1j`R2)J7?nli$ zgujK<2}P_8PciqTYAs#-wowr%g<894tb)&>LK%m;<3v;hCZg_qE^5ivpdz^&HO_aa>s~>x z2Kt+fE*w_SoZ!Z9DK|n591?0mRv0xvBv!{5)CC8jmMjG|k&%Y+{4;9+Q;bCUL;;1_)k6MDpsD1-ccbtJ*qKT-9%)}bF4Yd@vuo@N)cR5Y5 zHOAm;*aJU9H|C1q`PUs*iZBDzLG6K#sE^NqSP#db_Q*!m8lOi^z$xVNf6vd0ttpm6 zZRP}2M3YgG8IOw06jVeOpl)!pmkf`AbHrAhMXk+!RH*Y7HYb+CM9MW#7k(FYM<1jA zjfUFQPf;PxS;S1FFm9w=0*l}gRK$Kj-HB>WMz$%Ykk`TzO-+o*vW z6?geROa@{X%8M}?Z(%7Hdx$Rq+TAfFUH)HIC!n5&-KhFoI2_B;d77U8*<|$2k0|YO z#$qkhTCPKd`U1{CSES3Cjk7QXi@D9x%|PAx+c+ARVk{FWRK|pObXl{sb5KjR8nq-F zF~6Swtz@(|2drn&|M;Pn#ua5QlphuP@~AtmjfzNr)L!wT_R>43rCW{KOPf(o!$H)p zzl4E!SL*qHNTvfm#!A?{ocZt>j;f!B4e&fZkA=#c2*qJ;%5R}2@Gk27E$Dx~u_)!g zQSX656eK5y>cYRsLGwnN52squJpUS?4HatWfeQUt>s-|1@;-iudr=ces$?cK12ym( z)E@Go8;_xu=ppJ(OH_9G|J`3LoTql&hl8u|{OftGRMp&Rb=2B5MuoI9YIDU~Q?V=M zu~-KWqL$_gL8e*yKbuUy^O5f$m-sOwKg?VT(y89lFCtou-(+b2*1-$YID zKI#j}Z>S5EtYOx+4)UMVnLqkdKOx5DyoKjbOOaI5^qYi=$YQL9d$1;YACRd{CaRX% zrM$IqkIwmT|915tY=2_NGtI3LsMnlB)^o--HDk6QDps7STK5!eqk!2=k8 z$1zyX|4A~M(RtLH>JQW_vP?a**}9=NVInFLvyh1Uk3+f=$6L0-7X5W~dC1j&BWBlQx0nRDgFNu6Ry-7F{{->|roOf(p zMfxZrE_?&iNu4;iC3?%!sW}bjDa@wwHfaL+Fzi7pL3>%+yW%41Um!g@;;Cyz%0W9D z-g%$=3fxTx{e$Hx7)9G~d%`&^Yx61ejG?%_l@l}6sCQbfZl8$4vbs*_@5Wx8BP2T@;=%b;X?btyV zq?|;1LEQ!UT(lJ>A3~aM`|0$YHvbxZ?)|6!6y9|zf97L?4j2GIWmQ4 z_>p`cY-LZ-^Q?{dH}yIi(Wc{f%Hwq&NQ-k{bDQ{2~%}(x(xVcy`>U zzn71Br<|=ci@|5Kz347_?jwI3KgO|`h^;v>nB=zS)gqtA<~Pu17HP7*#u)sS_O3V% zb=1_krrivwH(qBd86t-!I=yuCBsHV%9CbgF?@s-f_%)6u@se?Pb2)uTIzGZ7q+X=Pl=*~m?%{{{HR}J* zEKGhFDS&Hw@AAhP8pcsE6!kX$iFZ9t-4eQhIP104$Ur^3Z`vb}cD7Pig z2ZmDw`JU%LF7szzd;S*c9+U4)Dq!lo&JilVvK`jo=jJ?T8TERf>Uf>>KINmR5BP%Q z^D&5ykL`hQ5> z$bVt`sADUeUxc|y`c?K@_S`he-;+oj!0 zg;<4jkqZvNQnd56*U2QW&(0S~dq|T=I{qc?pzV7+LBEdl??_q4dw7_%l=@d~o$B*! z=8qdx)DC>M9Y3Jv(SN$An~pi8Eu?TRt0MyS-l>6=uoh`8=S?R?)4t1IV<;Xct)l&T zl8*kk%4D6-$u}d#lDrM*ScFV#8mp84QJwHbQY7^gNNwx{)YOMGnsl1>Nu*b3FHHMc zHF7Mb{OssuGq2F^1nEWE-o}d5Hze)XzYtnaj--NW3y4Uzx?gY}|5`~#m>bOcilJpy?xvi^#|4_a_(%0F$ zl$VjejQOxJ-ouq77ilpmKXsM3c17}oNIFK6R*fMuq|z+wEkIS>Qb?ehIjE12l{SJL|D;lyW0U-Azr%qP{c9j~d8&QX%187T*8C8;~< z*>RH0NX~Ch-zKPI5ZBmf^EEJ)ax|&4y>^`5CZCb=QgMhACX#Q#1)d$n$kZkEqyCaT zNp0WxKb!qeFr@}|H0L?Lk=N0ibc_CNNK?rNaKT#mJn0UpA89A) zbK1+$b`w9~+&Ap`qi8En-LoS~J4i=4Di=Jf#R$qXZMhlwAO6!e&|a*Vz0efOf6~qm zi2kD%0}oJ+a#Ql%>Guu!Gg#X8X-htp)KQ<$I))hhe+F~X19H>o{E|JvL+3zJ6Wgye z`LAf}M`}Uc1zX>q`dj4P_!(_`NY9Qy@(W2Zw&FYTZ+PjroOF<+9|LuiU;;XZ+mlD& zVpHY+b1!`>QdbYJll~>Y6VH>D*?voLJmr(L4I^KbYx>A1kROaWNKeRnx6|p_(Vong zlvmSO6x)%mkn)oTlOk-#`Q&qu=F(5cD+b3yc{B9|C^xWm_3$w1W9oa@I#GqbpJ@H7 zQh1)mlBAJTenco*oT0mP9>T8of zOP>*>MEzhjkBp9d1}Dar_j1BZ)a|F7MZP)t0n)as`*!`ov4hl-wga>kB()(whcuNG z#-QQUT~SAl^#7dysrKI_I(<*dAVpF>hW|Y#bHaUF@e}z^NRR*1;UM`f)P-YJ48eGN zjrRX@t<|*GWI}Pa-unZY$(&FgXVG|+q~j0D*GOkbp`6f*`qxa8vx~Z1l#AQG4Jqpg zH8{6zeMibqNef9|Q#Tiz+P1FbIlRsfRFt(B*hIbyX)NVAw&Q#BT1Ea%JWYKCTVH`m z>3GSO-^66f;q>uf8`2TmR>}Gkbvo*CZV>(a&qfQ|m`(8uQXNt#b@kP3+w_^sWSEHw(mpoA3MIM9n%A|GP|@ZnCuzt9-Nt;k&@&dn&3$s z;`^pcZC6%kw>01SZgIiBCNG|I`KtAK5a8?GFP|&x)Bb<@q6U8I%KBx{Oy821Uvc@W z#LaVM9U9!(S9?gSz^sMw-F$cAqXK+U!?S~ZcT;W#w$B*mac7K8^|%w#-4#<(GZIpg zE4rync8^WTbjK#9r?`_no+0jx6nA>6XK=#Mv1)av#-@3aGu)m;Pm=7=+IK8{S`Ocw zaeean!l!R{`L4~loHOg|d6~Y9tUiIh5(^&&_=+vA7mzjR-FJOemo5y*I=1|cUPDvT z{8x)lOUX=qcCqU24ny6^DH-l`PexTUl~J*YnVxia%20oAJ7~I_Ik<h~UJ8^o;7h2dh2^@-6-F zR#4XRPpYVkBYN4mSM_Svtyr@69b?3wCows%hsU-iQS0(@JKUJmr#J~qtd`{etk zF5ep`Hw0wGpIPLqboLXM@9w#CuB-cz3@qxYkc;Z z2v?n;?DS%;xq;aQOSz^5EL<7s`Z#-*+m$;o>-Dd~7Je4xn&fd0-LoT&S){w$rDdk3 zdXnu1tM2aYVP?q*gNJ3f<2`K0anEKu+LJbf$!dTm4k-=OK91nz6JN8Qss+VIIKEK8gxHZ4v~$xer1Jb!H# iE^Xm*N3~(P#E`_6jrZt|wbGvC|G#CkbGLML4E#SWRW$Gb diff --git a/languages/de_DE.po b/languages/de_DE.po index 69020cc7..4ee3db99 100644 --- a/languages/de_DE.po +++ b/languages/de_DE.po @@ -2,21 +2,22 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-03-21 13:18+0100\n" -"PO-Revision-Date: 2017-03-21 14:21+0100\n" +"POT-Creation-Date: 2017-05-04 00:45+0200\n" +"PO-Revision-Date: 2017-05-04 00:46+0200\n" "Last-Translator: \n" "Language-Team: \n" "Language: de_DE\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Poedit 1.8.12\n" +"X-Generator: Poedit 2.0.1\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: views/archive/layout.hbs:1 views/layout.hbs:1 msgid "Self hosted email newsletter app" msgstr "Selbst gehostete Newsletter-App" +#: views/blacklist.hbs:1 views/campaigns/blacklisted.hbs:1 #: views/campaigns/bounced.hbs:1 views/campaigns/campaigns.hbs:1 #: views/campaigns/clicked.hbs:1 views/campaigns/complained.hbs:1 #: views/campaigns/create-rss.hbs:1 views/campaigns/create-triggered.hbs:1 @@ -36,71 +37,113 @@ msgstr "Selbst gehostete Newsletter-App" #: views/lists/subscription/import-failed.hbs:1 #: views/lists/subscription/import-preview.hbs:1 #: views/lists/subscription/import.hbs:1 views/lists/view.hbs:1 -#: views/settings.hbs:1 views/templates/create.hbs:1 views/templates/edit.hbs:1 +#: views/report-templates/create.hbs:1 views/report-templates/edit.hbs:1 +#: views/report-templates/report-templates.hbs:1 +#: views/reports/create-select-template.hbs:1 views/reports/create.hbs:1 +#: views/reports/edit.hbs:1 views/reports/output.hbs:1 +#: views/reports/reports.hbs:1 views/reports/view.hbs:1 views/settings.hbs:1 +#: views/templates/create.hbs:1 views/templates/edit.hbs:1 #: views/templates/templates.hbs:1 views/triggers/create-select.hbs:1 #: views/triggers/create.hbs:1 views/triggers/edit.hbs:1 #: views/triggers/triggered.hbs:1 views/triggers/triggers.hbs:1 #: views/users/account.hbs:1 views/users/api.hbs:1 views/users/forgot.hbs:1 -#: views/users/login.hbs:1 views/users/reset.hbs:1 app.js:176 +#: views/users/login.hbs:1 views/users/reset.hbs:1 app.js:169 msgid "Home" msgstr "Home" -#: views/campaigns/bounced.hbs:2 views/campaigns/campaigns.hbs:2 -#: views/campaigns/campaigns.hbs:7 views/campaigns/clicked.hbs:2 -#: views/campaigns/complained.hbs:2 views/campaigns/create-rss.hbs:2 -#: views/campaigns/create-triggered.hbs:2 views/campaigns/create.hbs:2 -#: views/campaigns/delivered.hbs:2 views/campaigns/edit-rss.hbs:2 -#: views/campaigns/edit-triggered.hbs:2 views/campaigns/edit.hbs:2 -#: views/campaigns/opened.hbs:2 views/campaigns/unsubscribed.hbs:2 -#: views/campaigns/upload-attachment.hbs:2 views/campaigns/view.hbs:2 -#: lib/tools.js:125 routes/campaigns.js:35 +#: views/blacklist.hbs:2 views/blacklist.hbs:3 views/layout.hbs:7 +#: views/lists/subscription/edit.hbs:15 +msgid "Blacklist" +msgstr "Blacklist" + +#: views/blacklist.hbs:4 views/users/api.hbs:55 +msgid "Add email to blacklist" +msgstr "E-Mail zur Blacklist hinzufügen" + +#: views/blacklist.hbs:5 +msgid "Add" +msgstr "Hinzufügen" + +#: views/blacklist.hbs:6 +msgid "Email" +msgstr "E-Mail" + +#: views/campaigns/blacklisted.hbs:2 views/campaigns/bounced.hbs:2 +#: views/campaigns/campaigns.hbs:2 views/campaigns/campaigns.hbs:7 +#: views/campaigns/clicked.hbs:2 views/campaigns/complained.hbs:2 +#: views/campaigns/create-rss.hbs:2 views/campaigns/create-triggered.hbs:2 +#: views/campaigns/create.hbs:2 views/campaigns/delivered.hbs:2 +#: views/campaigns/edit-rss.hbs:2 views/campaigns/edit-triggered.hbs:2 +#: views/campaigns/edit.hbs:2 views/campaigns/opened.hbs:2 +#: views/campaigns/unsubscribed.hbs:2 views/campaigns/upload-attachment.hbs:2 +#: views/campaigns/view.hbs:2 lib/tools.js:126 routes/campaigns.js:35 msgid "Campaigns" msgstr "Kampagnen" -#: views/campaigns/bounced.hbs:3 views/campaigns/bounced.hbs:4 -msgid "Bounced info" -msgstr "Bounced Info" +#: views/campaigns/blacklisted.hbs:3 views/campaigns/blacklisted.hbs:4 +msgid "Blacklisted info" +msgstr "Blacklisted info" -#: views/campaigns/bounced.hbs:5 views/campaigns/clicked.hbs:5 -#: views/campaigns/complained.hbs:5 views/campaigns/delivered.hbs:5 -#: views/campaigns/edit-rss.hbs:5 views/campaigns/edit-triggered.hbs:5 -#: views/campaigns/edit.hbs:5 views/campaigns/opened.hbs:5 -#: views/campaigns/unsubscribed.hbs:5 views/campaigns/upload-attachment.hbs:6 +#: views/campaigns/blacklisted.hbs:5 views/campaigns/bounced.hbs:5 +#: views/campaigns/clicked.hbs:5 views/campaigns/complained.hbs:5 +#: views/campaigns/delivered.hbs:5 views/campaigns/edit-rss.hbs:5 +#: views/campaigns/edit-triggered.hbs:5 views/campaigns/edit.hbs:5 +#: views/campaigns/opened.hbs:5 views/campaigns/unsubscribed.hbs:5 +#: views/campaigns/upload-attachment.hbs:6 msgid "View campaign" msgstr "Kampagne ansehen" -#: views/campaigns/bounced.hbs:6 -msgid "Subscribers who bounced and were unsubscribed:" -msgstr "Abonnenten, die bounced und abgemeldet wurden:" +#: views/campaigns/blacklisted.hbs:6 +msgid "Subscribers who blacklisted by global blacklist:" +msgstr "Abonnenten, die von der globalen Blacklist aufgelistet wurden:" -#: views/campaigns/bounced.hbs:7 views/campaigns/clicked.hbs:15 -#: views/campaigns/complained.hbs:7 views/campaigns/delivered.hbs:7 -#: views/campaigns/opened.hbs:9 views/campaigns/unsubscribed.hbs:7 +#: views/campaigns/blacklisted.hbs:7 views/campaigns/bounced.hbs:7 +#: views/campaigns/clicked.hbs:15 views/campaigns/complained.hbs:7 +#: views/campaigns/delivered.hbs:7 views/campaigns/opened.hbs:9 +#: views/campaigns/unsubscribed.hbs:7 #: views/lists/subscription/import-failed.hbs:8 views/lists/view.hbs:19 #: views/triggers/triggered.hbs:6 msgid "Address" msgstr "Adresse" -#: views/campaigns/bounced.hbs:8 views/campaigns/clicked.hbs:16 -#: views/campaigns/complained.hbs:8 views/campaigns/delivered.hbs:8 -#: views/campaigns/opened.hbs:10 views/campaigns/unsubscribed.hbs:8 -#: views/lists/subscription/add.hbs:6 views/lists/subscription/edit.hbs:7 +#: views/campaigns/blacklisted.hbs:8 views/campaigns/bounced.hbs:8 +#: views/campaigns/clicked.hbs:16 views/campaigns/complained.hbs:8 +#: views/campaigns/delivered.hbs:8 views/campaigns/opened.hbs:10 +#: views/campaigns/unsubscribed.hbs:8 views/lists/subscription/add.hbs:6 +#: views/lists/subscription/edit.hbs:7 #: views/lists/subscription/import-preview.hbs:7 views/lists/view.hbs:20 #: views/subscription/partials/subscription-custom-fields.hbs:3 #: views/triggers/triggered.hbs:7 msgid "First Name" msgstr "Vorname" -#: views/campaigns/bounced.hbs:9 views/campaigns/clicked.hbs:17 -#: views/campaigns/complained.hbs:9 views/campaigns/delivered.hbs:9 -#: views/campaigns/opened.hbs:11 views/campaigns/unsubscribed.hbs:9 -#: views/lists/subscription/add.hbs:7 views/lists/subscription/edit.hbs:8 +#: views/campaigns/blacklisted.hbs:9 views/campaigns/bounced.hbs:9 +#: views/campaigns/clicked.hbs:17 views/campaigns/complained.hbs:9 +#: views/campaigns/delivered.hbs:9 views/campaigns/opened.hbs:11 +#: views/campaigns/unsubscribed.hbs:9 views/lists/subscription/add.hbs:7 +#: views/lists/subscription/edit.hbs:8 #: views/lists/subscription/import-preview.hbs:8 views/lists/view.hbs:21 #: views/subscription/partials/subscription-custom-fields.hbs:4 #: views/triggers/triggered.hbs:8 msgid "Last Name" msgstr "Nachname" +#: views/campaigns/blacklisted.hbs:10 +msgid "Reason" +msgstr "Grund" + +#: views/campaigns/blacklisted.hbs:11 +msgid "Time" +msgstr "Zeit" + +#: views/campaigns/bounced.hbs:3 views/campaigns/bounced.hbs:4 +msgid "Bounced info" +msgstr "Bounced Info" + +#: views/campaigns/bounced.hbs:6 +msgid "Subscribers who bounced and were unsubscribed:" +msgstr "Abonnenten, die bounced und abgemeldet wurden:" + #: views/campaigns/bounced.hbs:10 views/campaigns/complained.hbs:10 #: views/campaigns/delivered.hbs:10 views/campaigns/unsubscribed.hbs:10 msgid "SMTP response" @@ -131,10 +174,15 @@ msgstr "Trigger-Kampagne" #: views/campaigns/campaigns.hbs:8 views/campaigns/create-rss.hbs:6 #: views/campaigns/create-triggered.hbs:5 views/campaigns/create.hbs:5 #: views/campaigns/edit-rss.hbs:8 views/campaigns/edit-triggered.hbs:9 -#: views/campaigns/edit.hbs:10 views/campaigns/view.hbs:71 +#: views/campaigns/edit.hbs:10 views/campaigns/view.hbs:73 #: views/lists/create.hbs:5 views/lists/edit.hbs:6 #: views/lists/fields/fields.hbs:6 views/lists/forms/forms.hbs:6 #: views/lists/lists.hbs:5 views/lists/segments/segments.hbs:6 +#: views/report-templates/partials/report-template-fields.hbs:1 +#: views/report-templates/report-templates.hbs:10 +#: views/reports/partials/report-fields.hbs:1 +#: views/reports/partials/report-fields.hbs:5 +#: views/reports/partials/report-fields.hbs:9 views/reports/reports.hbs:6 #: views/templates/templates.hbs:5 views/triggers/triggers.hbs:5 msgid "Name" msgstr "Name" @@ -142,25 +190,31 @@ msgstr "Name" #: views/campaigns/campaigns.hbs:9 views/campaigns/create-rss.hbs:8 #: views/campaigns/create-triggered.hbs:7 views/campaigns/create.hbs:7 #: views/campaigns/edit-rss.hbs:10 views/campaigns/edit-triggered.hbs:11 -#: views/campaigns/edit.hbs:12 views/campaigns/view.hbs:72 +#: views/campaigns/edit.hbs:12 views/campaigns/view.hbs:74 #: views/lists/create.hbs:7 views/lists/edit.hbs:10 #: views/lists/forms/edit.hbs:9 views/lists/forms/forms.hbs:7 #: views/lists/lists.hbs:8 views/mosaico/editor.hbs:3 -#: views/partials/merge-tag-reference.hbs:4 views/templates/create.hbs:9 -#: views/templates/edit.hbs:8 views/templates/templates.hbs:6 -#: views/triggers/create.hbs:7 views/triggers/edit.hbs:8 -#: views/triggers/triggers.hbs:7 +#: views/partials/merge-tag-reference.hbs:4 +#: views/report-templates/partials/report-template-fields.hbs:3 +#: views/report-templates/report-templates.hbs:11 +#: views/reports/partials/report-fields.hbs:3 +#: views/reports/partials/report-fields.hbs:6 views/reports/reports.hbs:8 +#: views/templates/create.hbs:9 views/templates/edit.hbs:8 +#: views/templates/templates.hbs:6 views/triggers/create.hbs:7 +#: views/triggers/edit.hbs:8 views/triggers/triggers.hbs:7 msgid "Description" msgstr "Beschreibung" -#: views/campaigns/campaigns.hbs:10 views/campaigns/view.hbs:73 +#: views/campaigns/campaigns.hbs:10 views/campaigns/view.hbs:75 #: views/lists/view.hbs:22 views/lists/view.hbs:30 #: views/triggers/triggers.hbs:6 msgid "Status" msgstr "Status" -#: views/campaigns/campaigns.hbs:11 views/campaigns/view.hbs:74 +#: views/campaigns/campaigns.hbs:11 views/campaigns/view.hbs:76 #: views/lists/view.hbs:23 views/lists/view.hbs:24 +#: views/report-templates/report-templates.hbs:12 +#: views/reports/partials/report-fields.hbs:7 views/reports/reports.hbs:9 msgid "Created" msgstr "Erstellt" @@ -168,23 +222,23 @@ msgstr "Erstellt" msgid "Link info" msgstr "Link Info" -#: views/campaigns/clicked.hbs:6 views/campaigns/view.hbs:61 +#: views/campaigns/clicked.hbs:6 views/campaigns/view.hbs:63 msgid "URL" msgstr "URL" -#: views/campaigns/clicked.hbs:7 views/campaigns/view.hbs:62 +#: views/campaigns/clicked.hbs:7 views/campaigns/view.hbs:64 msgid "Clicks" msgstr "Klicks" -#: views/campaigns/clicked.hbs:8 views/campaigns/view.hbs:63 +#: views/campaigns/clicked.hbs:8 views/campaigns/view.hbs:65 msgid "% of clicks" msgstr "% der Klicks" -#: views/campaigns/clicked.hbs:9 views/campaigns/view.hbs:64 +#: views/campaigns/clicked.hbs:9 views/campaigns/view.hbs:66 msgid "% of messages" msgstr "% der Nachrichten" -#: views/campaigns/clicked.hbs:10 views/campaigns/view.hbs:67 +#: views/campaigns/clicked.hbs:10 views/campaigns/view.hbs:69 msgid "Aggregated clicks" msgstr "Aggregierte Klicks" @@ -249,8 +303,10 @@ msgstr "Kampagnen Name" #: views/campaigns/create.hbs:8 views/campaigns/edit-rss.hbs:11 #: views/campaigns/edit-triggered.hbs:12 views/campaigns/edit.hbs:13 #: views/lists/create.hbs:8 views/lists/edit.hbs:11 -#: views/templates/create.hbs:11 views/templates/edit.hbs:10 -#: views/triggers/create.hbs:9 views/triggers/edit.hbs:10 +#: views/report-templates/partials/report-template-fields.hbs:4 +#: views/reports/partials/report-fields.hbs:4 views/templates/create.hbs:11 +#: views/templates/edit.hbs:10 views/triggers/create.hbs:9 +#: views/triggers/edit.hbs:10 msgid "HTML is allowed" msgstr "HTML ist erlaubt" @@ -272,6 +328,7 @@ msgstr "Liste" #: views/lists/segments/rule-create.hbs:7 views/lists/subscription/add.hbs:10 #: views/lists/subscription/add.hbs:12 views/lists/subscription/edit.hbs:11 #: views/lists/subscription/import-preview.hbs:5 +#: views/reports/partials/report-select-template.hbs:2 #: views/subscription/partials/subscription-custom-fields.hbs:9 #: views/templates/create.hbs:8 views/triggers/create-select.hbs:7 #: views/triggers/create.hbs:17 views/triggers/create.hbs:20 @@ -342,7 +399,7 @@ msgstr "Trigger-Kampagne erstellen" #: views/campaigns/create-triggered.hbs:12 views/campaigns/create.hbs:12 #: views/campaigns/edit-triggered.hbs:7 views/campaigns/edit.hbs:7 #: views/lists/fields/create.hbs:31 views/lists/fields/edit.hbs:33 -#: views/templates/create.hbs:13 +#: views/reports/reports.hbs:7 views/templates/create.hbs:13 msgid "Template" msgstr "Vorlage" @@ -439,12 +496,12 @@ msgid "Delete Campaign" msgstr "Kampagne löschen" #: views/campaigns/edit-rss.hbs:24 views/campaigns/edit-triggered.hbs:27 -#: views/campaigns/edit.hbs:35 views/lists/edit.hbs:16 +#: views/campaigns/edit.hbs:35 views/lists/edit.hbs:17 #: views/lists/fields/edit.hbs:39 views/lists/forms/edit.hbs:29 #: views/lists/forms/forms.hbs:12 views/lists/segments/edit.hbs:14 -#: views/lists/segments/rule-edit.hbs:38 views/lists/subscription/edit.hbs:17 -#: views/settings.hbs:99 views/templates/edit.hbs:12 views/triggers/edit.hbs:30 -#: views/users/account.hbs:18 +#: views/lists/segments/rule-edit.hbs:38 views/lists/subscription/edit.hbs:18 +#: views/reports/edit.hbs:6 views/settings.hbs:99 views/templates/edit.hbs:12 +#: views/triggers/edit.hbs:30 views/users/account.hbs:18 msgid "Update" msgstr "Aktualisieren" @@ -483,7 +540,7 @@ msgstr "Datei" msgid "Size" msgstr "Grösse" -#: views/campaigns/edit.hbs:32 views/campaigns/view.hbs:66 +#: views/campaigns/edit.hbs:32 views/campaigns/view.hbs:68 #: views/lists/fields/fields.hbs:12 views/lists/forms/forms.hbs:9 #: views/lists/view.hbs:33 msgid "No data available in table" @@ -517,8 +574,8 @@ msgstr "Abmeldungs-Info" msgid "Subscribers who unsubscribed:" msgstr "Abonnenten welche deabonnierten:" -#: views/campaigns/unsubscribed.hbs:11 views/campaigns/view.hbs:26 -#: views/lists/subscription/import.hbs:10 routes/lists.js:187 +#: views/campaigns/unsubscribed.hbs:11 views/campaigns/view.hbs:28 +#: views/lists/subscription/import.hbs:10 routes/lists.js:202 msgid "Unsubscribed" msgstr "Abbestellt" @@ -572,7 +629,7 @@ msgstr "Noch keine Testbenutzer vorhanden, erstellen Sie einen hier" msgid "Go" msgstr "Los" -#: views/campaigns/view.hbs:20 lib/models/triggers.js:25 +#: views/campaigns/view.hbs:20 lib/models/triggers.js:26 msgid "Delivered" msgstr "Zugestellt" @@ -580,111 +637,119 @@ msgstr "Zugestellt" msgid "List subscribers who received this message" msgstr "Abonnenten dieser Liste, die diese Nachricht erhalten haben" -#: views/campaigns/view.hbs:22 routes/lists.js:187 +#: views/campaigns/view.hbs:22 +msgid "Blacklisted" +msgstr "Blacklisted" + +#: views/campaigns/view.hbs:23 +msgid "List subscribers who blacklisted by global blacklist" +msgstr "" + +#: views/campaigns/view.hbs:24 routes/lists.js:202 msgid "Bounced" msgstr "Bounced" -#: views/campaigns/view.hbs:23 +#: views/campaigns/view.hbs:25 msgid "List subscribers who bounced" msgstr "Bounced Listen Abonnenten" -#: views/campaigns/view.hbs:24 +#: views/campaigns/view.hbs:26 msgid "Complaints" msgstr "Beschwerden" -#: views/campaigns/view.hbs:25 +#: views/campaigns/view.hbs:27 msgid "List subscribers who complained for this message" msgstr "Abonnenten, die sich über diese Nachricht beschwert haben" -#: views/campaigns/view.hbs:27 +#: views/campaigns/view.hbs:29 msgid "List subscribers who unsubscribed after this message" msgstr "Abonnenten, die sich nach dieser Nachricht abgemeldet haben" -#: views/campaigns/view.hbs:28 +#: views/campaigns/view.hbs:30 msgid "Opened" msgstr "Geöffnet" -#: views/campaigns/view.hbs:29 +#: views/campaigns/view.hbs:31 msgid "List subscribers who opened this message" msgstr "Abonnenten, die diese Nachricht geöffnet haben" -#: views/campaigns/view.hbs:30 +#: views/campaigns/view.hbs:32 msgid "Clicked" msgstr "Geklickt" -#: views/campaigns/view.hbs:31 views/campaigns/view.hbs:68 +#: views/campaigns/view.hbs:33 views/campaigns/view.hbs:70 msgid "List subscribers who clicked on a link" msgstr "Abonnenten, die auf einen Link geklickt haben" -#: views/campaigns/view.hbs:32 +#: views/campaigns/view.hbs:34 msgid "" "Are you sure? This action would start sending messages to the selected list" msgstr "" "Sind Sie sicher? Diese Aktion würde mit dem Senden von Nachrichten an die " "ausgewählte Liste beginnen" -#: views/campaigns/view.hbs:33 +#: views/campaigns/view.hbs:35 msgid "Delay sending" msgstr "Senden verzögern" -#: views/campaigns/view.hbs:34 +#: views/campaigns/view.hbs:36 msgid "hours" msgstr "Stunden" -#: views/campaigns/view.hbs:35 +#: views/campaigns/view.hbs:37 msgid "minutes" msgstr "Minuten" -#: views/campaigns/view.hbs:36 +#: views/campaigns/view.hbs:38 msgid "Send to subscribers:" msgstr "An Abonnenten senden:" -#: views/campaigns/view.hbs:37 +#: views/campaigns/view.hbs:39 msgid "Are you sure? This action would reset scheduling" msgstr "Sind Sie sicher? Diese Aktion würde die Terminierung zurücksetzen" -#: views/campaigns/view.hbs:38 +#: views/campaigns/view.hbs:40 msgid "Cancel" msgstr "Abbrechen" -#: views/campaigns/view.hbs:39 +#: views/campaigns/view.hbs:41 msgid "Sending scheduled" msgstr "Senden geplant" -#: views/campaigns/view.hbs:40 views/campaigns/view.hbs:52 +#: views/campaigns/view.hbs:42 views/campaigns/view.hbs:54 msgid "Pause" msgstr "Pause" -#: views/campaigns/view.hbs:41 routes/campaigns.js:264 +#: views/campaigns/view.hbs:43 routes/campaigns.js:253 msgid "Sending" msgstr "Am senden" -#: views/campaigns/view.hbs:42 views/campaigns/view.hbs:46 +#: views/campaigns/view.hbs:44 views/campaigns/view.hbs:48 msgid "" "Are you sure? This action would resume sending messages to the selected list" msgstr "" "Sind Sie sicher? Diese Aktion würde das Senden von E-Mails an die " "ausgewählte Liste fortsetzen" -#: views/campaigns/view.hbs:43 views/campaigns/view.hbs:47 +#: views/campaigns/view.hbs:45 views/campaigns/view.hbs:49 msgid "Are you sure? This action would reset all stats about current progress" msgstr "" "Sind Sie sicher? Diese Aktion würde alle Statistiken über den aktuellen " "Fortschritt zurücksetzen" -#: views/campaigns/view.hbs:44 +#: views/campaigns/view.hbs:46 msgid "Resume" msgstr "Fortsetzen" -#: views/campaigns/view.hbs:45 views/campaigns/view.hbs:49 +#: views/campaigns/view.hbs:47 views/campaigns/view.hbs:51 msgid "Reset" msgstr "Zurücksetzen" -#: views/campaigns/view.hbs:48 +#: views/campaigns/view.hbs:50 msgid "Continue" msgstr "Weiter" -#: views/campaigns/view.hbs:50 +#: views/campaigns/view.hbs:52 msgid "" "All messages sent! Hit \"Continue\" if you you want to send this campaign to " "new subscribers" @@ -692,7 +757,7 @@ msgstr "" "Alle E-Mails gesendet! Klicken Sie auf \"Weiter\", wenn Sie diese Kampagne " "an neue Abonnenten senden möchten" -#: views/campaigns/view.hbs:51 +#: views/campaigns/view.hbs:53 msgid "" "Are you sure? This action would pause sending new entries in RSS feed as " "email messages to the selected list" @@ -700,15 +765,15 @@ msgstr "" "Sind Sie sicher? Diese Aktion würde das Senden neuer Einträge des RSS-Feed " "als E-Mail-Nachrichten an die ausgewählte Liste pausieren" -#: views/campaigns/view.hbs:53 views/campaigns/view.hbs:57 +#: views/campaigns/view.hbs:55 views/campaigns/view.hbs:59 msgid "Campaign status:" msgstr "Kampagnen Status:" -#: views/campaigns/view.hbs:54 +#: views/campaigns/view.hbs:56 msgid "ACTIVE" msgstr "AKTIV" -#: views/campaigns/view.hbs:55 +#: views/campaigns/view.hbs:57 msgid "" "Are you sure? This action would start sending new entries in RSS feed as " "email messages to the selected list" @@ -716,15 +781,15 @@ msgstr "" "Sind Sie sicher? Diese Aktion würde neue RSS Feed Einträge als E-Mail-" "Nachrichten der ausgewählten Liste zustellen" -#: views/campaigns/view.hbs:56 +#: views/campaigns/view.hbs:58 msgid "Activate" msgstr "Aktivieren" -#: views/campaigns/view.hbs:58 +#: views/campaigns/view.hbs:60 msgid "INACTIVE" msgstr "INAKTIV" -#: views/campaigns/view.hbs:59 +#: views/campaigns/view.hbs:61 msgid "" "This is a triggered campaign. Messages are only sent to subscribers that hit " "some trigger that invokes this campaign" @@ -732,15 +797,15 @@ msgstr "" "Dies ist eine Trigger-Kampagne. Nachrichten werden nur an Abonnenten " "gesendet, die einen Trigger auslösen, der diese Kampagne aufruft" -#: views/campaigns/view.hbs:60 +#: views/campaigns/view.hbs:62 msgid "see more" msgstr "mehr anzeigen" -#: views/campaigns/view.hbs:65 +#: views/campaigns/view.hbs:67 msgid "List subscribers who clicked this link" msgstr "Abonnenten dieser Liste, die diesen Link geklickt haben" -#: views/campaigns/view.hbs:69 +#: views/campaigns/view.hbs:71 msgid "" "Clicks are counted as unique subscribers that clicked on a specific link or " "on any link (in aggregated view)" @@ -748,7 +813,7 @@ msgstr "" "Klicks werden als eindeutige Abonnenten gezählt, die auf einen bestimmten " "Link oder auf irgendeinen Link geklickt haben (in der aggregierter Ansicht)" -#: views/campaigns/view.hbs:70 +#: views/campaigns/view.hbs:72 msgid "" "If a new entry is found from campaign feed a new subcampaign is created of " "that entry and it will be listed here" @@ -787,12 +852,12 @@ msgid "Preferences" msgstr "Persönliche Einstellungen" #: views/emails/rss-html.hbs:2 views/emails/stationery-html.hbs:4 -#: views/emails/stationery-text.hbs:4 views/lists/subscription/edit.hbs:15 +#: views/emails/stationery-text.hbs:4 views/lists/subscription/edit.hbs:16 #: views/subscription/partials/subscription-unsubscribe-form.hbs:2 #: views/subscription/web-manage.mjml.hbs:3 #: views/subscription/web-unsubscribe.mjml.hbs:1 #: views/subscription/web-unsubscribe.mjml.hbs:3 routes/forms.js:213 -#: routes/lists.js:269 +#: routes/lists.js:284 msgid "Unsubscribe" msgstr "Newsletter abbestellen" @@ -936,7 +1001,7 @@ msgstr "" "Webhooks für SES, SparkPost, SendGrid und Mailgun unterstützt, auch für " "Postfix und ZoneMTA." -#: views/index.hbs:26 lib/tools.js:129 +#: views/index.hbs:26 lib/tools.js:130 msgid "Automation" msgstr "Automatisierung" @@ -1007,28 +1072,28 @@ msgstr "Account" msgid "Settings" msgstr "Einstellungen" -#: views/layout.hbs:7 views/users/api.hbs:2 views/users/api.hbs:3 +#: views/layout.hbs:8 views/users/api.hbs:2 views/users/api.hbs:3 msgid "API" msgstr "API" -#: views/layout.hbs:8 +#: views/layout.hbs:9 msgid "Log out" msgstr "Abmelden" -#: views/layout.hbs:9 views/users/forgot.hbs:2 views/users/login.hbs:2 +#: views/layout.hbs:10 views/users/forgot.hbs:2 views/users/login.hbs:2 #: views/users/login.hbs:3 views/users/login.hbs:9 views/users/reset.hbs:2 msgid "Sign in" msgstr "Anmelden" -#: views/layout.hbs:10 +#: views/layout.hbs:11 msgid "Self Hosted Newsletter App Built on Top of Nodemailer" msgstr "Selbst gehostete Newsletter-App basierend auf Nodemailer" -#: views/layout.hbs:11 views/layout.hbs:13 +#: views/layout.hbs:12 views/layout.hbs:14 msgid "Source on GitHub" msgstr "Quellcode auf Github" -#: views/layout.hbs:12 +#: views/layout.hbs:13 msgid "Subscribe to Our Newsletter" msgstr "Abonnieren Sie unseren Newsletter" @@ -1045,11 +1110,11 @@ msgstr "Abonnieren Sie unseren Newsletter" #: views/lists/subscription/import-failed.hbs:2 #: views/lists/subscription/import-preview.hbs:2 #: views/lists/subscription/import.hbs:2 views/lists/view.hbs:2 -#: lib/tools.js:117 +#: lib/tools.js:118 routes/lists.js:59 msgid "Lists" msgstr "Listen" -#: views/lists/create.hbs:3 views/lists/create.hbs:4 views/lists/create.hbs:9 +#: views/lists/create.hbs:3 views/lists/create.hbs:4 views/lists/create.hbs:10 #: views/lists/lists.hbs:3 msgid "Create List" msgstr "Liste erstellen" @@ -1058,6 +1123,10 @@ msgstr "Liste erstellen" msgid "List Name" msgstr "Linstennamen" +#: views/lists/create.hbs:9 views/lists/edit.hbs:15 +msgid "Allow public users to subscribe themselves" +msgstr "Allen erlauben, diese Liste selbst zu abonnieren" + #: views/lists/edit.hbs:3 views/lists/edit.hbs:4 views/lists/view.hbs:8 msgid "Edit List" msgstr "Liste bearbeiten" @@ -1090,7 +1159,7 @@ msgstr "" "Das Standard-Formular dieser Liste. Wenn Sie ein Formular erstellt möchten, " "klicken Sie hier." -#: views/lists/edit.hbs:15 +#: views/lists/edit.hbs:16 msgid "Delete List" msgstr "Liste löschen" @@ -1260,16 +1329,17 @@ msgid "Delete Field" msgstr "Feld löschen" #: views/lists/fields/fields.hbs:7 views/lists/view.hbs:26 +#: views/report-templates/partials/report-template-fields.hbs:5 msgid "Type" msgstr "Typ" #: views/lists/fields/fields.hbs:10 views/lists/fields/fields.hbs:11 #: views/lists/forms/edit.hbs:22 views/lists/forms/forms.hbs:8 -#: views/lists/lists.hbs:9 views/lists/segments/segments.hbs:8 -#: views/lists/segments/view.hbs:12 views/templates/templates.hbs:7 -#: views/triggers/triggers.hbs:14 routes/campaigns.js:287 -#: routes/campaigns.js:576 routes/campaigns.js:665 routes/lists.js:238 -#: routes/triggers.js:297 +#: views/lists/segments/segments.hbs:8 views/lists/segments/view.hbs:12 +#: views/triggers/triggers.hbs:14 routes/campaigns.js:276 +#: routes/campaigns.js:568 routes/campaigns.js:657 routes/campaigns.js:706 +#: routes/lists.js:166 routes/lists.js:253 routes/report-templates.js:51 +#: routes/templates.js:170 routes/triggers.js:297 msgid "Edit" msgstr "Bearbeiten" @@ -1328,7 +1398,7 @@ msgstr "" #: views/lists/forms/edit.hbs:13 views/lists/subscription/add.hbs:16 #: views/subscription/mail-unsubscribe-confirmed-html.mjml.hbs:4 #: views/subscription/mail-unsubscribe-confirmed-text.hbs:4 routes/forms.js:157 -#: routes/lists.js:269 +#: routes/lists.js:284 msgid "Subscribe" msgstr "Abonnieren" @@ -1356,9 +1426,11 @@ msgstr "Adresse Verwalten" msgid "Create a test user for additional options" msgstr "Erstellen Sie einen Testbenutzer für zusätzliche Optionen" -#: views/lists/forms/edit.hbs:20 views/templates/create.hbs:2 +#: views/lists/forms/edit.hbs:20 views/report-templates/create.hbs:3 +#: views/report-templates/edit.hbs:3 +#: views/report-templates/report-templates.hbs:3 views/templates/create.hbs:2 #: views/templates/edit.hbs:2 views/templates/templates.hbs:2 -#: views/templates/templates.hbs:4 lib/tools.js:121 +#: views/templates/templates.hbs:4 lib/tools.js:122 routes/templates.js:27 msgid "Templates" msgstr "Vorlagen" @@ -1398,7 +1470,7 @@ msgstr "Das Standard-Formular für diese Liste ist:" msgid "ID" msgstr "ID" -#: views/lists/lists.hbs:7 +#: views/lists/lists.hbs:7 views/reports/partials/report-fields.hbs:10 msgid "Subscribers" msgstr "Abonnenten " @@ -1556,7 +1628,9 @@ msgid "Add Rule" msgstr "Regel hinzufügen" #: views/lists/segments/rule-create.hbs:8 -#: views/lists/subscription/import.hbs:12 views/triggers/create-select.hbs:9 +#: views/lists/subscription/import.hbs:15 +#: views/reports/create-select-template.hbs:5 +#: views/triggers/create-select.hbs:9 msgid "Next" msgstr "Weiter" @@ -1645,11 +1719,11 @@ msgstr "Zurück zur Liste" #: views/lists/subscription/edit.hbs:6 #: views/lists/subscription/import-preview.hbs:6 #: views/subscription/partials/subscription-unsubscribe-form.hbs:1 -#: lib/helpers.js:40 lib/models/segments.js:11 +#: lib/helpers.js:41 lib/models/segments.js:11 msgid "Email address" msgstr "E-Mail-Adresse" -#: views/lists/subscription/edit.hbs:16 +#: views/lists/subscription/edit.hbs:17 msgid "Delete Subscription" msgstr "Abonnement löschen" @@ -1686,7 +1760,8 @@ msgid "Import subscribers" msgstr "Abonnenten importieren" #: views/lists/subscription/import-preview.hbs:10 views/users/api.hbs:27 -#: views/users/api.hbs:35 views/users/api.hbs:43 views/users/api.hbs:55 +#: views/users/api.hbs:35 views/users/api.hbs:43 views/users/api.hbs:54 +#: views/users/api.hbs:62 views/users/api.hbs:70 msgid "Example" msgstr "Beispiel" @@ -1706,7 +1781,7 @@ msgstr "CSV-Trennzeichen" msgid "Categorize the imported subscribers as" msgstr "Kategorisiere die importierten Abonnenten als" -#: views/lists/subscription/import.hbs:8 routes/lists.js:187 +#: views/lists/subscription/import.hbs:8 routes/lists.js:202 msgid "Subscribed" msgstr "Abonniert" @@ -1718,6 +1793,18 @@ msgstr "Normale Abonnenten-Adressen" msgid "Suppressed emails that will be unsubscribed from your list" msgstr "Unterdrückte E-Mail-Adressen, die von Ihrer Liste abgemeldet werden" +#: views/lists/subscription/import.hbs:12 +msgid "Check imported emails" +msgstr "Überprüfe die importierten E-Mail-Adressen" + +#: views/lists/subscription/import.hbs:13 views/triggers/triggers.hbs:12 +msgid "Enabled" +msgstr "Aktiviert" + +#: views/lists/subscription/import.hbs:14 views/triggers/triggers.hbs:13 +msgid "Disabled" +msgstr "Deaktiviert" + #: views/lists/view.hbs:3 msgid "Subscription Form" msgstr "Abonnement-Formular" @@ -1757,7 +1844,7 @@ msgstr "Abonnemente" msgid "Imports" msgstr "Importe" -#: views/lists/view.hbs:25 routes/campaigns.js:266 routes/lists.js:281 +#: views/lists/view.hbs:25 routes/campaigns.js:255 routes/lists.js:296 msgid "Finished" msgstr "Fertig" @@ -1881,6 +1968,116 @@ msgstr "Mosaico öffnen" msgid "Template content (plaintext)" msgstr "Vorlagen-Inhalt (Klartext) " +#: views/report-templates/create.hbs:2 views/report-templates/edit.hbs:2 +#: views/report-templates/report-templates.hbs:2 +#: views/reports/create-select-template.hbs:2 views/reports/create.hbs:2 +#: views/reports/edit.hbs:2 views/reports/output.hbs:2 +#: views/reports/reports.hbs:2 views/reports/reports.hbs:5 +#: views/reports/view.hbs:2 lib/tools.js:137 routes/reports.js:31 +msgid "Reports" +msgstr "Reporte" + +#: views/report-templates/create.hbs:4 views/report-templates/create.hbs:6 +#: views/report-templates/report-templates.hbs:4 views/templates/create.hbs:3 +#: views/templates/create.hbs:4 views/templates/create.hbs:12 +#: views/templates/templates.hbs:3 +msgid "Create Template" +msgstr "Vorlage erstellen" + +#: views/report-templates/create.hbs:5 routes/report-templates.js:231 +msgid "Create Report Template" +msgstr "Report-Vorlage erstellen" + +#: views/report-templates/edit.hbs:4 views/templates/edit.hbs:3 +#: views/templates/edit.hbs:4 +msgid "Edit Template" +msgstr "Vorlage bearbeiten" + +#: views/report-templates/edit.hbs:5 routes/report-templates.js:262 +msgid "Edit Report Template" +msgstr "Report-Vorlage bearbeiten" + +#: views/report-templates/edit.hbs:6 views/templates/edit.hbs:11 +msgid "Delete Template" +msgstr "Vorlage löschen" + +#: views/report-templates/edit.hbs:7 +msgid "Update and Stay" +msgstr "Aktualisieren und bleiben" + +#: views/report-templates/edit.hbs:8 +msgid "Update and Leave" +msgstr "Aktualisieren und verlassen" + +#: views/report-templates/partials/report-template-fields.hbs:2 +msgid "Template Name" +msgstr "Vorlagen-Name" + +#: views/report-templates/partials/report-template-fields.hbs:6 +msgid "User selectable fields" +msgstr "Vom Benutzer wählbare Felder" + +#: views/report-templates/partials/report-template-fields.hbs:7 +msgid "Data processing code" +msgstr "Datenverarbeitungs-Code" + +#: views/report-templates/partials/report-template-fields.hbs:8 +msgid "Rendering template" +msgstr "Render-Vorlage" + +#: views/report-templates/report-templates.hbs:5 +msgid "Blank" +msgstr "Leer" + +#: views/report-templates/report-templates.hbs:6 +msgid "All Subscribers" +msgstr "Alle Abonnenten" + +#: views/report-templates/report-templates.hbs:7 +msgid "Grouped Subscribers" +msgstr "Gruppierte Abonnenten" + +#: views/report-templates/report-templates.hbs:8 +msgid "Export List as CSV" +msgstr "Liste als CSV exportieren" + +#: views/report-templates/report-templates.hbs:9 views/reports/reports.hbs:4 +#: routes/report-templates.js:29 +msgid "Report Templates" +msgstr "Report-Vorlagen" + +#: views/reports/create-select-template.hbs:3 +#: views/reports/create-select-template.hbs:4 views/reports/create.hbs:3 +#: views/reports/create.hbs:4 views/reports/create.hbs:5 +#: views/reports/reports.hbs:3 routes/reports.js:81 +msgid "Create Report" +msgstr "Report erstellen" + +#: views/reports/edit.hbs:3 views/reports/edit.hbs:4 routes/reports.js:151 +msgid "Edit Report" +msgstr "Report bearbeiten" + +#: views/reports/edit.hbs:5 +msgid "Delete Report" +msgstr "Report löschen" + +#: views/reports/partials/report-fields.hbs:2 +msgid "Report Name" +msgstr "Report-Name" + +#: views/reports/partials/report-fields.hbs:8 +#: views/reports/partials/report-fields.hbs:11 +msgid "" +"Select a campaign in the table above by clicking on the respective row " +"number." +msgstr "" +"Wählen Sie eine Kampagne in der obigen Tabelle aus, indem Sie auf die " +"jeweilige Zeilennummer klicken." + +#: views/reports/partials/report-select-template.hbs:1 +msgid "Report Template" +msgstr "Report-Vorlage" + #: views/settings.hbs:5 msgid "Service Address (URL)" msgstr "Service Adresse (URL)" @@ -2337,7 +2534,7 @@ msgstr "" "werden Nachrichten nicht signiert." #: views/subscription/mail-confirm-html.mjml.hbs:1 -#: views/subscription/mail-confirm-text.hbs:1 +#: views/subscription/mail-confirm-text.hbs:1 routes/subscription.js:551 msgid "Please Confirm Subscription" msgstr "Bitte bestätigen Sie ihr Abonnement" @@ -2505,14 +2702,17 @@ msgstr "Einstellungen aktualisieren" #: views/subscription/partials/subscription-subscribe-form.hbs:1 #: views/subscription/web-subscribe.mjml.hbs:2 +#: views/subscription/widget-subscribe.hbs:1 msgid "Subscribe to list" msgstr "Newsletter abonnieren" #: views/subscription/web-confirm-notice.mjml.hbs:1 +#: views/subscription/widget-subscribe.hbs:4 msgid "Almost Finished" msgstr "Fast Fertig" #: views/subscription/web-confirm-notice.mjml.hbs:2 +#: views/subscription/widget-subscribe.hbs:5 msgid "" "We need to confirm your email address. To complete the subscription process, " "please click the link in the email we just sent you." @@ -2557,10 +2757,13 @@ msgstr "Einstellungen aktualisiert" msgid "Your profile information has been updated." msgstr "Ihre Profilinformationen wurden aktualisiert." -#: views/templates/create.hbs:3 views/templates/create.hbs:4 -#: views/templates/create.hbs:12 views/templates/templates.hbs:3 -msgid "Create Template" -msgstr "Vorlage erstellen" +#: views/subscription/widget-subscribe.hbs:2 +msgid "Sending ..." +msgstr "Am senden …" + +#: views/subscription/widget-subscribe.hbs:3 +msgid "It looks like you are already subscribed to this list." +msgstr "Es sieht so aus, als hätten Sie diese Liste bereits abonniert." #: views/templates/create.hbs:5 views/templates/edit.hbs:6 msgid "Template name" @@ -2578,18 +2781,10 @@ msgstr "HTML Editor" msgid "Optional comments about this template" msgstr "Optionale Kommentare zu dieser Vorlage" -#: views/templates/edit.hbs:3 views/templates/edit.hbs:4 -msgid "Edit Template" -msgstr "Vorlage bearbeiten" - #: views/templates/edit.hbs:5 msgid "Back to templates" msgstr "Zurück zu Vorlagen" -#: views/templates/edit.hbs:11 -msgid "Delete Template" -msgstr "Vorlage löschen" - #: views/triggers/create-select.hbs:2 views/triggers/create.hbs:2 #: views/triggers/edit.hbs:2 views/triggers/triggered.hbs:2 #: views/triggers/triggers.hbs:2 views/triggers/triggers.hbs:4 @@ -2691,14 +2886,6 @@ msgstr "Ziel-Kampagne" msgid "Triggered count" msgstr "Anzahl Auslösungen" -#: views/triggers/triggers.hbs:12 -msgid "Enabled" -msgstr "Aktiviert" - -#: views/triggers/triggers.hbs:13 -msgid "Disabled" -msgstr "Deaktiviert" - #: views/users/account.hbs:4 msgid "This account is managed through LDAP." msgstr "Dieses Konto wird über LDAP verwaltet." @@ -2817,12 +3004,13 @@ msgstr "" #: views/users/api.hbs:15 views/users/api.hbs:17 views/users/api.hbs:30 #: views/users/api.hbs:32 views/users/api.hbs:38 views/users/api.hbs:40 -#: views/users/api.hbs:46 views/users/api.hbs:48 +#: views/users/api.hbs:46 views/users/api.hbs:57 views/users/api.hbs:59 +#: views/users/api.hbs:65 views/users/api.hbs:67 msgid "arguments" msgstr "Argumente" #: views/users/api.hbs:16 views/users/api.hbs:31 views/users/api.hbs:39 -#: views/users/api.hbs:47 +#: views/users/api.hbs:47 views/users/api.hbs:58 views/users/api.hbs:66 msgid "your personal access token" msgstr "Ihr persönlicher Access Token" @@ -2831,7 +3019,7 @@ msgid "subscriber's email address" msgstr "E-Mail-Adresse des Abonnenten" #: views/users/api.hbs:19 views/users/api.hbs:34 views/users/api.hbs:42 -#: views/users/api.hbs:50 +#: views/users/api.hbs:61 views/users/api.hbs:69 msgid "required" msgstr "erforderlich" @@ -2899,44 +3087,52 @@ msgid "This API call deletes a subscription" msgstr "Dieser API-Aufruf löscht ein Abonnement" #: views/users/api.hbs:44 -msgid "Add new custom field" -msgstr "Neues benutzerdefiniertes Feld hinzufügen" +msgid "Get list of blacklisted emails" +msgstr "" #: views/users/api.hbs:45 -msgid "This API call creates a new custom field for a list." +msgid "This API call get list of blacklisted emails." msgstr "" -"Dieser API-Aufruf erstellt ein neues benutzerdefiniertes Feld für eine Liste." + +#: views/users/api.hbs:48 +msgid "Start position" +msgstr "Startposition" #: views/users/api.hbs:49 -msgid "field name" -msgstr "Feldname" +msgid "optional, default 0" +msgstr "optional, standard 0" + +#: views/users/api.hbs:50 +msgid "limit emails count in response" +msgstr "" #: views/users/api.hbs:51 -msgid "one of the following types:" -msgstr "Einer der folgenden Typen:" +msgid "optional, default 10000" +msgstr "optional, standard 10000" #: views/users/api.hbs:52 -msgid "" -"If the type is 'option' then you also need to specify the parent element ID" +msgid "filter by part of email" msgstr "" -"Wenn der Typ 'Option' ist, dann müssen Sie auch die übergeordnete Element-ID " -"angeben" #: views/users/api.hbs:53 -msgid "" -"Template for the group element. If not set, then values of the elements are " -"joined with commas" -msgstr "" -"Vorlage für das Gruppenelement. Wenn nicht gesetzt, dann werden die Werte " -"der Elemente mit Kommas verbunden" +msgid "optional, default ''" +msgstr "optional, standard ''" -#: views/users/api.hbs:54 -msgid "" -"if not visible then the subscriber can not view or modify this value at the " -"profile page" +#: views/users/api.hbs:56 +msgid "This API call either add emails to blacklist" +msgstr "" + +#: views/users/api.hbs:60 views/users/api.hbs:68 +msgid "email address" +msgstr "E-Mail-Adresse" + +#: views/users/api.hbs:63 +msgid "Delete email from blacklist" +msgstr "E-Mail aus der Blacklist löschen" + +#: views/users/api.hbs:64 +msgid "This API call either delete emails from blacklist" msgstr "" -"Wenn nicht sichtbar, kann der Abonnent diesen Wert auf der Profilseite weder " -"sehen noch bearbeiten" #: views/users/forgot.hbs:3 views/users/reset.hbs:3 msgid "Password Reset" @@ -2991,18 +3187,18 @@ msgstr "Wähle Sie Ihr neues Passwort" msgid "Please enter a new password." msgstr "Bitte geben Sie ein neues Passwort ein" -#: lib/editor-helpers.js:16 routes/templates.js:109 +#: lib/editor-helpers.js:17 routes/templates.js:95 msgid "Could not find template with specified ID" msgstr "Konnte keine Vorlage mit angegebener ID finden" -#: lib/editor-helpers.js:32 routes/archive.js:145 routes/campaigns.js:131 -#: routes/campaigns.js:295 routes/campaigns.js:390 routes/campaigns.js:435 -#: routes/campaigns.js:475 routes/campaigns.js:778 routes/campaigns.js:801 -#: routes/campaigns.js:820 routes/campaigns.js:842 routes/triggers.js:146 +#: lib/editor-helpers.js:33 routes/archive.js:145 routes/campaigns.js:131 +#: routes/campaigns.js:284 routes/campaigns.js:379 routes/campaigns.js:427 +#: routes/campaigns.js:467 routes/campaigns.js:844 routes/campaigns.js:867 +#: routes/campaigns.js:886 routes/campaigns.js:908 routes/triggers.js:146 msgid "Could not find campaign with specified ID" msgstr "Konnte keine Kampagne mit dieser ID finden" -#: lib/editor-helpers.js:46 routes/editorapi.js:308 +#: lib/editor-helpers.js:47 routes/editorapi.js:308 msgid "Invalid resource type" msgstr "Ungültiger Ressourcentyp" @@ -3010,71 +3206,95 @@ msgstr "Ungültiger Ressourcentyp" msgid "Bad status code %s" msgstr "Bad Statuscode %s" -#: lib/helpers.js:31 +#: lib/helpers.js:32 msgid "URL that points to the unsubscribe page" msgstr "URL, die auf die Abmeldungsseite verweist" -#: lib/helpers.js:34 +#: lib/helpers.js:35 msgid "URL that points to the preferences page of the subscriber" msgstr "" "URL, die auf die Persönliche-Einstellungs-Seite des Teilnehmers verweist" -#: lib/helpers.js:37 +#: lib/helpers.js:38 msgid "URL to preview the message in a browser" msgstr "URL zur Vorschau der E-Mail im Browser" -#: lib/helpers.js:43 lib/models/segments.js:31 +#: lib/helpers.js:44 lib/models/segments.js:31 msgid "First name" msgstr "Vorname" -#: lib/helpers.js:46 lib/models/segments.js:35 +#: lib/helpers.js:47 lib/models/segments.js:35 msgid "Last name" msgstr "Nachname" -#: lib/helpers.js:49 +#: lib/helpers.js:50 msgid "Full name (first and last name combined)" msgstr "Vollständiger Name (Vor- und Nachname kombiniert)" -#: lib/helpers.js:52 +#: lib/helpers.js:53 msgid "Unique ID that identifies the recipient" msgstr "Eindeutige ID, die den Empfänger identifiziert" -#: lib/helpers.js:55 +#: lib/helpers.js:56 msgid "Unique ID that identifies the list used for this campaign" msgstr "" "Eindeutige ID, welche die für diese Kampagne verwendete Liste identifiziert" -#: lib/helpers.js:58 +#: lib/helpers.js:59 msgid "Unique ID that identifies current campaign" msgstr "Eindeutige ID, welche die aktuelle Kampagne identifiziert" +#: lib/helpers.js:67 lib/helpers.js:79 +msgid "content from an RSS entry" +msgstr "Inhalt aus einem RSS-Eintrag" + +#: lib/helpers.js:70 +msgid "RSS entry title" +msgstr "" + +#: lib/helpers.js:73 +msgid "RSS entry date" +msgstr "" + +#: lib/helpers.js:76 +msgid "RSS entry link" +msgstr "" + +#: lib/helpers.js:82 +msgid "RSS entry summary" +msgstr "" + +#: lib/helpers.js:85 +msgid "RSS entry image URL" +msgstr "" + #: lib/mailer.js:245 msgid "Invalid mail transport" msgstr "Ungültiger Mail-Transport" -#: lib/models/campaigns.js:308 lib/models/campaigns.js:335 -#: lib/models/campaigns.js:408 lib/models/campaigns.js:531 -#: lib/models/campaigns.js:792 lib/models/campaigns.js:924 +#: lib/models/campaigns.js:105 lib/models/campaigns.js:132 +#: lib/models/campaigns.js:205 lib/models/campaigns.js:328 +#: lib/models/campaigns.js:589 lib/models/campaigns.js:721 msgid "Missing Campaign ID" msgstr "Kampagnen-ID fehlt" -#: lib/models/campaigns.js:444 +#: lib/models/campaigns.js:241 msgid "Emtpy or too large attahcment" msgstr "Leere oder zu große Anhangsdatei" -#: lib/models/campaigns.js:610 lib/models/campaigns.js:801 +#: lib/models/campaigns.js:407 lib/models/campaigns.js:598 msgid "Campaign Name must be set" msgstr "Kampagnenname ist erforderlich" -#: lib/models/campaigns.js:614 +#: lib/models/campaigns.js:411 msgid "RSS URL must be set and needs to be a valid URL" msgstr "RSS URL ist erforderlich und muss eine gültige URL sein" -#: lib/models/campaigns.js:770 +#: lib/models/campaigns.js:567 msgid "Selected template not found" msgstr "Ausgewählte Vorlage nicht gefunden" -#: lib/models/campaigns.js:1125 +#: lib/models/campaigns.js:922 msgid "Invalid or missing message ID" msgstr "Ungültige oder fehlende Nachrichten-ID" @@ -3095,12 +3315,12 @@ msgid "Option" msgstr "Option" #: lib/models/fields.js:53 lib/models/fields.js:98 lib/models/fields.js:123 -#: lib/models/forms.js:37 lib/models/lists.js:81 lib/models/lists.js:178 -#: lib/models/lists.js:218 lib/models/segments.js:43 lib/models/segments.js:176 -#: lib/models/subscriptions.js:89 lib/models/subscriptions.js:661 -#: lib/models/subscriptions.js:724 lib/models/subscriptions.js:910 -#: lib/models/subscriptions.js:1013 lib/models/subscriptions.js:1067 -#: lib/models/subscriptions.js:1130 lib/models/subscriptions.js:1173 +#: lib/models/forms.js:37 lib/models/lists.js:72 lib/models/lists.js:172 +#: lib/models/lists.js:212 lib/models/segments.js:43 lib/models/segments.js:176 +#: lib/models/subscriptions.js:74 lib/models/subscriptions.js:574 +#: lib/models/subscriptions.js:637 lib/models/subscriptions.js:823 +#: lib/models/subscriptions.js:926 lib/models/subscriptions.js:980 +#: lib/models/subscriptions.js:1043 lib/models/subscriptions.js:1086 msgid "Missing List ID" msgstr "Listen-ID fehlt" @@ -3150,12 +3370,12 @@ msgstr "Formular-Name ist erforderlich" msgid "Custom form not found" msgstr "Formular nicht gefunden" -#: lib/models/links.js:329 routes/campaigns.js:541 routes/campaigns.js:589 -#: routes/campaigns.js:629 services/sender.js:304 +#: lib/models/links.js:329 routes/campaigns.js:533 routes/campaigns.js:581 +#: routes/campaigns.js:621 routes/campaigns.js:671 services/sender.js:305 msgid "Campaign not found" msgstr "Kampagne nicht gefunden" -#: lib/models/links.js:337 routes/lists.js:162 services/sender.js:311 +#: lib/models/links.js:337 routes/lists.js:177 services/sender.js:312 msgid "List not found" msgstr "Liste nicht gefunden" @@ -3163,27 +3383,44 @@ msgstr "Liste nicht gefunden" msgid "Subscription not found" msgstr "Abonnement nicht gefunden" -#: lib/models/lists.js:117 lib/models/lists.js:182 +#: lib/models/lists.js:110 lib/models/lists.js:176 msgid "List Name must be set" msgstr "Listennamen ist erforderlich" -#: lib/models/lists.js:247 +#: lib/models/lists.js:241 msgid "Missing List CID" msgstr "Listen CID fehlt" +#: lib/models/report-templates.js:26 lib/models/report-templates.js:70 +#: lib/models/report-templates.js:142 +msgid "Missing report template ID" +msgstr "Report-Vorlagen-ID fehlt" + +#: lib/models/report-templates.js:77 +msgid "Report template name must be set" +msgstr "Report-Vorlagen-Name ist erforderlich" + +#: lib/models/reports.js:39 lib/models/reports.js:109 lib/models/reports.js:187 +msgid "Missing report ID" +msgstr "Report-ID fehlt" + +#: lib/models/reports.js:115 +msgid "Report name must be set" +msgstr "Report-Name ist erforderlich" + #: lib/models/segments.js:15 msgid "Signup country" msgstr "Anmeldungs-Land" -#: lib/models/segments.js:19 lib/models/triggers.js:11 +#: lib/models/segments.js:19 lib/models/triggers.js:12 msgid "Sign up date" msgstr "Anmeldungs-Datum" -#: lib/models/segments.js:23 lib/models/triggers.js:15 +#: lib/models/segments.js:23 lib/models/triggers.js:16 msgid "Latest open" msgstr "Letzte Öffnung" -#: lib/models/segments.js:27 lib/models/triggers.js:19 +#: lib/models/segments.js:27 lib/models/triggers.js:20 msgid "Latest click" msgstr "Letzter Klick" @@ -3242,107 +3479,107 @@ msgstr "Das Segment wurde nicht gefunden" msgid "Selected rule not found" msgstr "Ausgewählte Regel nicht gefunden" -#: lib/models/subscriptions.js:235 +#: lib/models/subscriptions.js:148 msgid "%s: Please Confirm Subscription" msgstr "%s: Bitte bestätigen Sie Ihr Abonnement" -#: lib/models/subscriptions.js:345 +#: lib/models/subscriptions.js:258 msgid "Could not save subscription" msgstr "Abonnement konnte nicht gespeichert werden" -#: lib/models/subscriptions.js:528 lib/models/subscriptions.js:558 +#: lib/models/subscriptions.js:441 lib/models/subscriptions.js:471 msgid "Missing Subbscription ID" msgstr "Abonnement-ID fehlt" -#: lib/models/subscriptions.js:586 +#: lib/models/subscriptions.js:499 msgid "Missing Subbscription email address" msgstr "Abonnement-E-Mail-Adresse fehlt" -#: lib/models/subscriptions.js:665 lib/models/subscriptions.js:914 -#: lib/models/subscriptions.js:1177 +#: lib/models/subscriptions.js:578 lib/models/subscriptions.js:827 +#: lib/models/subscriptions.js:1090 msgid "Missing subscription ID" msgstr "Abonnement-ID fehlt" -#: lib/models/subscriptions.js:728 +#: lib/models/subscriptions.js:641 msgid "Missing email address" msgstr "E-Mail-Adresse fehlt" -#: lib/models/subscriptions.js:1017 lib/models/subscriptions.js:1071 -#: lib/models/subscriptions.js:1107 +#: lib/models/subscriptions.js:930 lib/models/subscriptions.js:984 +#: lib/models/subscriptions.js:1020 msgid "Missing Import ID" msgstr "Import-ID fehlt" -#: lib/models/subscriptions.js:1199 +#: lib/models/subscriptions.js:1112 msgid "Unknown subscription ID" msgstr "Unbekannte Abonnement-ID" -#: lib/models/subscriptions.js:1204 +#: lib/models/subscriptions.js:1117 msgid "Nothing seems to be changed" msgstr "Nichts scheint sich geändert zu haben" -#: lib/models/subscriptions.js:1218 +#: lib/models/subscriptions.js:1131 msgid "This address is already registered by someone else" msgstr "Diese Adresse ist bereits von jemand anderem registriert" -#: lib/models/templates.js:51 lib/models/templates.js:125 -#: lib/models/templates.js:169 +#: lib/models/templates.js:26 lib/models/templates.js:100 +#: lib/models/templates.js:144 msgid "Missing Template ID" msgstr "Vorlagen ID fehlt" -#: lib/models/templates.js:80 lib/models/templates.js:129 +#: lib/models/templates.js:55 lib/models/templates.js:104 msgid "Template Name must be set" msgstr "Vorlagen-Name ist erforderlich" -#: lib/models/triggers.js:28 +#: lib/models/triggers.js:29 msgid "Has Opened" msgstr "Hat geöffnet" -#: lib/models/triggers.js:31 +#: lib/models/triggers.js:32 msgid "Has Clicked" msgstr "Hat geklickt" -#: lib/models/triggers.js:34 +#: lib/models/triggers.js:35 msgid "Not Opened" msgstr "Nicht geöffnet" -#: lib/models/triggers.js:37 +#: lib/models/triggers.js:38 msgid "Not Clicked" msgstr "Nicht geklickt" -#: lib/models/triggers.js:174 lib/models/triggers.js:211 +#: lib/models/triggers.js:175 lib/models/triggers.js:212 msgid "Missing or invalid list ID" msgstr "Fehlende oder ungültige Listen ID" -#: lib/models/triggers.js:178 lib/models/triggers.js:263 +#: lib/models/triggers.js:179 lib/models/triggers.js:264 msgid "Days in the past are not allowed" msgstr "Tage in der Vergangenheit sind nicht erlaubt" -#: lib/models/triggers.js:182 lib/models/triggers.js:203 -#: lib/models/triggers.js:267 lib/models/triggers.js:288 +#: lib/models/triggers.js:183 lib/models/triggers.js:204 +#: lib/models/triggers.js:268 lib/models/triggers.js:289 msgid "Missing or invalid trigger rule" msgstr "Fehlende oder ungültige Trigger-Regel" -#: lib/models/triggers.js:189 lib/models/triggers.js:274 +#: lib/models/triggers.js:190 lib/models/triggers.js:275 msgid "Invalid subscription configuration" msgstr "Ungültige Abonnement-Konfiguration" -#: lib/models/triggers.js:196 lib/models/triggers.js:281 +#: lib/models/triggers.js:197 lib/models/triggers.js:282 msgid "Invalid campaign configuration" msgstr "Kampagnen Konfiguration ungültig" -#: lib/models/triggers.js:199 lib/models/triggers.js:284 +#: lib/models/triggers.js:200 lib/models/triggers.js:285 msgid "A campaing can not be a target for itself" msgstr "Eine Kampagne kann kein Ziel für sich selbst sein" -#: lib/models/triggers.js:232 +#: lib/models/triggers.js:233 msgid "Could not store trigger row" msgstr "Trigger-Zeile konnte nicht gespeichert werden" -#: lib/models/triggers.js:249 +#: lib/models/triggers.js:250 msgid "Missing or invalid Trigger ID" msgstr "Trigger-ID fehlt oder ist ungültig" -#: lib/models/triggers.js:316 +#: lib/models/triggers.js:317 msgid "Missing Trigger ID" msgstr "Trigger-ID fehlt" @@ -3413,42 +3650,43 @@ msgstr "Eingeloggt als %s" msgid "Incorrect username or password" msgstr "Falscher Benutzername oder Passwort" -#: lib/tools.js:139 +#: lib/tools.js:148 msgid "Blocked email address \"%s\"" msgstr "Gesperrte E-Mail-Adresse \"%s\"" -#: lib/tools.js:148 +#: lib/tools.js:157 msgid "Invalid email address \"%s\"." msgstr "Ungültige E-Mail-Adresse \"%s\"." -#: lib/tools.js:151 +#: lib/tools.js:160 msgid "MX record not found for domain" msgstr "MX-Record für die Domäne nicht gefunden" -#: lib/tools.js:154 +#: lib/tools.js:163 msgid "Address domain not found" msgstr "Address-Domain nicht gefunden" -#: lib/tools.js:157 +#: lib/tools.js:166 msgid "Address domain name is required" msgstr "Address-Domain-Name ist erforderlich" -#: routes/archive.js:31 routes/archive.js:43 routes/archive.js:55 app.js:225 +#: routes/archive.js:31 routes/archive.js:43 routes/archive.js:55 app.js:224 msgid "Not Found" msgstr "Nicht gefunden" -#: routes/archive.js:121 services/sender.js:447 +#: routes/archive.js:121 services/sender.js:449 msgid "Received status code %s from %s" msgstr "Empfangener Statuscode %s von %s" -#: routes/archive.js:153 routes/campaigns.js:828 +#: routes/archive.js:153 routes/campaigns.js:894 msgid "Attachment not found" msgstr "Anhangs-Datei nicht gefunden" -#: routes/campaigns.js:26 routes/editorapi.js:35 routes/fields.js:13 -#: routes/forms.js:16 routes/grapejs.js:13 routes/lists.js:50 -#: routes/mosaico.js:14 routes/segments.js:13 routes/settings.js:23 -#: routes/templates.js:17 routes/triggers.js:18 routes/users.js:75 +#: routes/blacklist.js:13 routes/campaigns.js:26 routes/editorapi.js:35 +#: routes/fields.js:13 routes/forms.js:16 routes/grapejs.js:13 +#: routes/lists.js:50 routes/mosaico.js:14 routes/report-templates.js:20 +#: routes/reports.js:22 routes/segments.js:13 routes/settings.js:23 +#: routes/templates.js:18 routes/triggers.js:18 routes/users.js:75 #: routes/users.js:120 msgid "Need to be logged in to access restricted content" msgstr "Sie müssen angemeldet sein, um auf geschützte Inhalte zuzugreifen." @@ -3461,107 +3699,103 @@ msgstr "Kampagne konnte nicht erstellt werden" msgid "Campaign “%s” created" msgstr "Die Kampagne “%s” wurde erstellt" -#: routes/campaigns.js:204 -msgid "content from an RSS entry" -msgstr "Inhalt aus einem RSS-Eintrag" - -#: routes/campaigns.js:220 +#: routes/campaigns.js:209 msgid "Campaign settings updated" msgstr "Kampagnen-Einstellungen aktualisiert" -#: routes/campaigns.js:222 +#: routes/campaigns.js:211 msgid "Campaign settings not updated" msgstr "Kampagnen-Einstellungen nicht aktualisiert" -#: routes/campaigns.js:238 routes/campaigns.js:678 +#: routes/campaigns.js:227 routes/campaigns.js:744 msgid "Campaign deleted" msgstr "Kampagne gelöscht" -#: routes/campaigns.js:240 routes/campaigns.js:680 +#: routes/campaigns.js:229 routes/campaigns.js:746 msgid "Could not delete specified campaign" msgstr "Die Kampagne konnte nicht gelöscht werden" -#: routes/campaigns.js:259 +#: routes/campaigns.js:248 msgid "Idling" msgstr "Ruhend" -#: routes/campaigns.js:262 +#: routes/campaigns.js:251 msgid "Scheduled" msgstr "Geplant" -#: routes/campaigns.js:268 +#: routes/campaigns.js:257 msgid "Paused" msgstr "Pausiert" -#: routes/campaigns.js:270 +#: routes/campaigns.js:259 msgid "Inactive" msgstr "Inaktiv" -#: routes/campaigns.js:272 +#: routes/campaigns.js:261 msgid "Active" msgstr "Aktiv" -#: routes/campaigns.js:274 +#: routes/campaigns.js:263 msgid "Other" msgstr "Andere" -#: routes/campaigns.js:429 +#: routes/campaigns.js:421 msgid "Unknown status selector" msgstr "Unbekannter Status-Selektor" -#: routes/campaigns.js:696 +#: routes/campaigns.js:762 msgid "Scheduled sending" msgstr "Senden geplant" -#: routes/campaigns.js:698 +#: routes/campaigns.js:764 msgid "Could not schedule sending" msgstr "Versand konnte nicht geplant werden" -#: routes/campaigns.js:710 +#: routes/campaigns.js:776 msgid "Sending resumed" msgstr "Versand wieder aufgenommen" -#: routes/campaigns.js:712 +#: routes/campaigns.js:778 msgid "Could not resume sending" msgstr "Versand konnte nicht fortgesetzt werden" -#: routes/campaigns.js:724 +#: routes/campaigns.js:790 msgid "Sending reset" msgstr "Versand zurückgesetzt" -#: routes/campaigns.js:726 +#: routes/campaigns.js:792 msgid "Could not reset sending" msgstr "Versand konnte nicht zurückgesetzt werden" -#: routes/campaigns.js:738 routes/campaigns.js:766 +#: routes/campaigns.js:804 routes/campaigns.js:832 msgid "Sending paused" msgstr "Versand pausiert" -#: routes/campaigns.js:740 routes/campaigns.js:768 +#: routes/campaigns.js:806 routes/campaigns.js:834 msgid "Could not pause sending" msgstr "Versand konnte nicht pausiert werden" -#: routes/campaigns.js:752 +#: routes/campaigns.js:818 msgid "Sending activated" msgstr "Versand aktiviert" -#: routes/campaigns.js:754 +#: routes/campaigns.js:820 msgid "Could not activate sending" msgstr "Versand konnte nicht aktiviert werden" -#: routes/campaigns.js:789 +#: routes/campaigns.js:855 msgid "Attachment uploaded" msgstr "Anhang hochgeladen" -#: routes/campaigns.js:791 +#: routes/campaigns.js:857 msgid "Could not store attachment" msgstr "Anhang konnte nicht gespeichert werden" -#: routes/campaigns.js:808 +#: routes/campaigns.js:874 msgid "Attachment deleted" msgstr "Anhang gelöscht" -#: routes/campaigns.js:810 +#: routes/campaigns.js:876 msgid "Could not delete attachment" msgstr "Anhang konnte nicht gelöscht werden" @@ -3726,138 +3960,207 @@ msgid "Oops, we couldn't find a link for the URL you clicked" msgstr "" "Ups, wir konnten keinen Link für die URL finden, die Sie geklickt haben" -#: routes/lists.js:91 +#: routes/lists.js:80 msgid "Could not create list" msgstr "Die Liste konnte nicht erstellt werden" -#: routes/lists.js:94 +#: routes/lists.js:83 msgid "List created" msgstr "Liste erstellt" -#: routes/lists.js:102 routes/lists.js:252 routes/lists.js:317 -#: routes/lists.js:356 routes/lists.js:425 routes/lists.js:450 -#: routes/lists.js:495 routes/lists.js:517 routes/lists.js:546 -#: routes/lists.js:625 routes/lists.js:682 routes/lists.js:709 +#: routes/lists.js:91 routes/lists.js:267 routes/lists.js:332 +#: routes/lists.js:371 routes/lists.js:440 routes/lists.js:465 +#: routes/lists.js:510 routes/lists.js:532 routes/lists.js:561 +#: routes/lists.js:640 routes/lists.js:697 routes/lists.js:724 msgid "Could not find list with specified ID" msgstr "Die Liste mit angegebener ID konnte nicht gefunden werden" -#: routes/lists.js:129 +#: routes/lists.js:118 msgid "List settings updated" msgstr "Listeneinstellungen aktualisiert" -#: routes/lists.js:131 +#: routes/lists.js:120 msgid "List settings not updated" msgstr "Listeneinstellungen nicht aktualisiert" -#: routes/lists.js:149 +#: routes/lists.js:138 msgid "List deleted" msgstr "Liste gelöscht" -#: routes/lists.js:151 +#: routes/lists.js:140 msgid "Could not delete specified list" msgstr "Die Liste konnte nicht gelöscht werden" -#: routes/lists.js:187 +#: routes/lists.js:202 msgid "Unknown" msgstr "Unbekannt" -#: routes/lists.js:187 +#: routes/lists.js:202 msgid "Complained" msgstr "Beschwert" -#: routes/lists.js:218 +#: routes/lists.js:233 msgid "Invalid key" msgstr "Ungültiger Key" -#: routes/lists.js:220 +#: routes/lists.js:235 msgid "Expired key" msgstr "Abgelaufener Key" -#: routes/lists.js:222 +#: routes/lists.js:237 msgid "Revoked key" msgstr "Widerrufener Key" -#: routes/lists.js:272 +#: routes/lists.js:287 msgid "Initializing" msgstr "Initialisierung" -#: routes/lists.js:275 +#: routes/lists.js:290 msgid "Initialized" msgstr "Initialisiert" -#: routes/lists.js:278 +#: routes/lists.js:293 msgid "Importing" msgstr "Importieren" -#: routes/lists.js:284 +#: routes/lists.js:299 msgid "Errored" msgstr "Fehlerhaft" -#: routes/lists.js:362 routes/lists.js:431 routes/lists.js:456 +#: routes/lists.js:377 routes/lists.js:446 routes/lists.js:471 msgid "Could not find subscriber with specified ID" msgstr "Der Abonnent mit angegebenen ID konnte nicht gefunden werden" -#: routes/lists.js:408 +#: routes/lists.js:423 msgid "Could not add subscription" msgstr "Das Abonnement konnte nicht hinzugefügt werden" -#: routes/lists.js:413 +#: routes/lists.js:428 msgid "%s was successfully added to your list" msgstr "%s wurde Ihrer Liste erfolgreich hinzugefügt" -#: routes/lists.js:415 +#: routes/lists.js:430 msgid "%s was not added to your list" msgstr "%s wurde nicht zu Ihrer Liste hinzugefügt" -#: routes/lists.js:437 +#: routes/lists.js:452 msgid "Could not unsubscribe user" msgstr "Der Benutzer konnte nicht deabonniert werden" -#: routes/lists.js:440 +#: routes/lists.js:455 msgid "%s was successfully unsubscribed from your list" msgstr "%s wurde erfolgreich von Ihrer Liste entfernt" -#: routes/lists.js:460 +#: routes/lists.js:475 msgid "%s was successfully removed from your list" msgstr "%s wurde erfolgreich aus Ihrer Liste entfernt" -#: routes/lists.js:472 +#: routes/lists.js:487 msgid "Another subscriber with email address %s already exists" msgstr "Ein anderer Abonnent mit der E-Mail-Adresse %s existiert bereits" -#: routes/lists.js:479 +#: routes/lists.js:494 msgid "Subscription settings updated" msgstr "Abonnementeinstellungen aktualisiert" -#: routes/lists.js:481 +#: routes/lists.js:496 msgid "Subscription settings not updated" msgstr "Abonnementeinstellungen nicht aktualisiert" -#: routes/lists.js:523 routes/lists.js:631 routes/lists.js:667 -#: routes/lists.js:695 routes/lists.js:715 +#: routes/lists.js:538 routes/lists.js:646 routes/lists.js:682 +#: routes/lists.js:710 routes/lists.js:730 msgid "Could not find import data with specified ID" msgstr "Keine Importdaten für diese ID gefunden" -#: routes/lists.js:554 +#: routes/lists.js:569 msgid "Could not process CSV" msgstr "CSV-Datei konnte nicht verarbeitet werden" -#: routes/lists.js:563 +#: routes/lists.js:578 msgid "Could not create importer" msgstr "Importer konnte nicht erstellt werden" -#: routes/lists.js:614 +#: routes/lists.js:629 msgid "Empty file" msgstr "Leere Datei" -#: routes/lists.js:671 +#: routes/lists.js:686 msgid "Import started" msgstr "Import gestartet" -#: routes/lists.js:699 +#: routes/lists.js:714 msgid "Import restarted" msgstr "Import neu gestartet" +#: routes/report-templates.js:246 +msgid "Could not create report template" +msgstr "Report-Vorlage konnte nicht erstellt werden" + +#: routes/report-templates.js:249 +msgid "Report template “%s” created" +msgstr "Die Report-Vorlage “%s” wurde erstellt" + +#: routes/report-templates.js:257 +msgid "Could not find report template with specified ID" +msgstr "Report-Vorlage mit angegebener ID konnte nicht gefunden werden" + +#: routes/report-templates.js:280 +msgid "Report template updated" +msgstr "Report-Vorlage aktualisiert" + +#: routes/report-templates.js:282 +msgid "Report template not updated" +msgstr "Report-Vorlage wurde nicht aktualisiert" + +#: routes/report-templates.js:298 +msgid "Report template deleted" +msgstr "Report-Vorlage gelöscht" + +#: routes/report-templates.js:300 +msgid "Could not delete specified report template" +msgstr "Die Report-Vorlage konnte nicht gelöscht werden" + +#: routes/reports.js:124 routes/reports.js:130 +msgid "Could not create report" +msgstr "Der Report konnte nicht erstellt werden" + +#: routes/reports.js:135 +msgid "Report “%s” created" +msgstr "Report “%s” erstellt" + +#: routes/reports.js:146 routes/reports.js:224 routes/reports.js:239 +#: routes/reports.js:265 routes/reports.js:275 +msgid "Could not find report with specified ID" +msgstr "Der Report mit dieser ID konnte nicht gefunden werden" + +#: routes/reports.js:188 routes/reports.js:194 +msgid "Could not update report" +msgstr "Der Report konnte nicht aktualisiert werden" + +#: routes/reports.js:197 +msgid "Report updated" +msgstr "Report aktualisiert" + +#: routes/reports.js:199 +msgid "Report not updated" +msgstr "Report nicht aktualisiert" + +#: routes/reports.js:212 +msgid "Report deleted" +msgstr "Report gelöscht" + +#: routes/reports.js:214 +msgid "Could not delete specified report" +msgstr "Der Report konnte nicht gelöscht werden" + +#: routes/reports.js:230 +msgid "Could not find report template" +msgstr "Report-Vorlage konnte nicht gefunden werden" + +#: routes/reports.js:260 +msgid "Unknown type of template" +msgstr "Unbekannter Mime-Type des Template" + #: routes/segments.js:86 msgid "Could not create segment" msgstr "Segment konnte nicht erstellt werden" @@ -3993,71 +4296,80 @@ msgstr "Server antwortete mit: \"%s\"" msgid "Mailer settings verified, ready to send some mail!" msgstr "Mailer-Einstellungen überprüft, bereit zum Senden!" -#: routes/subscription.js:25 +#: routes/subscription.js:32 +msgid "Not allowed by CORS" +msgstr "Nicht erlaubt von CORS" + +#: routes/subscription.js:50 msgid "Selected subscription not found" msgstr "Ausgewähltes Abonnement nicht gefunden" -#: routes/subscription.js:35 routes/subscription.js:155 -#: routes/subscription.js:222 routes/subscription.js:275 -#: routes/subscription.js:327 routes/subscription.js:396 -#: routes/subscription.js:434 routes/subscription.js:510 -#: routes/subscription.js:532 routes/subscription.js:592 -#: routes/subscription.js:616 routes/subscription.js:681 +#: routes/subscription.js:60 routes/subscription.js:181 +#: routes/subscription.js:266 routes/subscription.js:324 +#: routes/subscription.js:377 routes/subscription.js:429 +#: routes/subscription.js:511 routes/subscription.js:562 +#: routes/subscription.js:638 routes/subscription.js:660 +#: routes/subscription.js:720 routes/subscription.js:744 +#: routes/subscription.js:809 msgid "Selected list not found" msgstr "Ausgewählte Liste nicht gefunden" -#: routes/subscription.js:109 +#: routes/subscription.js:134 msgid "%s: Subscription Confirmed" msgstr "%s: Abonnement bestätigt" -#: routes/subscription.js:381 +#: routes/subscription.js:184 routes/subscription.js:514 +msgid "The list does not allow public subscriptions." +msgstr "Die Liste erlaubt keine öffentlichen Abonnements." + +#: routes/subscription.js:493 routes/subscription.js:495 msgid "Email address not set" msgstr "E-Mail-Adresse nicht gesetzt" -#: routes/subscription.js:419 +#: routes/subscription.js:538 msgid "Could not store confirmation data" msgstr "Die Bestätigungsdaten konnten nicht gespeichert werden" -#: routes/subscription.js:448 routes/subscription.js:547 -#: routes/subscription.js:631 +#: routes/subscription.js:576 routes/subscription.js:675 +#: routes/subscription.js:759 msgid "Subscription not found from this list" msgstr "Abonnement konnte in dieser Liste nicht gefunden werden" -#: routes/subscription.js:607 +#: routes/subscription.js:735 msgid "Email address updated, check your mailbox for verification instructions" msgstr "" "Die E-Mail-Adresse wurde aktualisiert. Bitte überprüfen Sie Ihre Mailbox zur " "Bestätigung" -#: routes/subscription.js:730 +#: routes/subscription.js:858 msgid "%s: Unsubscribe Confirmed" msgstr "%s: Abmeldungen Bestätigt" -#: routes/subscription.js:777 routes/subscription.js:793 +#: routes/subscription.js:905 routes/subscription.js:921 msgid "Public key is not set" msgstr "Public-Key ist nicht gesetzt" -#: routes/templates.js:98 +#: routes/templates.js:84 msgid "Could not create template" msgstr "Vorlage konnte nicht erstellt werden" -#: routes/templates.js:101 +#: routes/templates.js:87 msgid "Template created" msgstr "Vorlage erstellt" -#: routes/templates.js:140 +#: routes/templates.js:126 msgid "Template settings updated" msgstr "Vorlageneinstellungen aktualisiert" -#: routes/templates.js:142 +#: routes/templates.js:128 msgid "Template settings not updated" msgstr "Template Einstellungen wurden nicht aktualisiert" -#: routes/templates.js:158 +#: routes/templates.js:144 msgid "Template deleted" msgstr "Vorlage gelöscht" -#: routes/templates.js:160 +#: routes/templates.js:146 msgid "Could not delete specified template" msgstr "Die Vorlage konnte nicht gelöscht werden" @@ -4145,11 +4457,11 @@ msgstr "Fand %s neue Kampagnen-Nachrichten im Feed" msgid "Found nothing new from the feed" msgstr "Im Feed wurde nichts neues gefunden" -#: services/feedcheck.js:143 +#: services/feedcheck.js:146 msgid "RSS entry %s" msgstr "RSS Eintrag %s" -#: services/importer.js:243 +#: services/importer.js:249 msgid "Could not access import file" msgstr "Auf die Importdatei konnte nicht zugegriffen werden" @@ -4157,6 +4469,36 @@ msgstr "Auf die Importdatei konnte nicht zugegriffen werden" msgid "Unknown trigger type %s" msgstr "Unbekannter Trigger-Typ %s" +#~ msgid "Add new custom field" +#~ msgstr "Neues benutzerdefiniertes Feld hinzufügen" + +#~ msgid "field name" +#~ msgstr "Feldname" + +#~ msgid "one of the following types:" +#~ msgstr "Einer der folgenden Typen:" + +#~ msgid "" +#~ "If the type is 'option' then you also need to specify the parent element " +#~ "ID" +#~ msgstr "" +#~ "Wenn der Typ 'Option' ist, dann müssen Sie auch die übergeordnete Element-" +#~ "ID angeben" + +#~ msgid "" +#~ "Template for the group element. If not set, then values of the elements " +#~ "are joined with commas" +#~ msgstr "" +#~ "Vorlage für das Gruppenelement. Wenn nicht gesetzt, dann werden die Werte " +#~ "der Elemente mit Kommas verbunden" + +#~ msgid "" +#~ "if not visible then the subscriber can not view or modify this value at " +#~ "the profile page" +#~ msgstr "" +#~ "Wenn nicht sichtbar, kann der Abonnent diesen Wert auf der Profilseite " +#~ "weder sehen noch bearbeiten" + #~ msgid "You are now unsubscribed" #~ msgstr "Sie sind jetzt aus dieser Liste ausgetragen" From 6c35046ab2ba3052c1c621b5b88e0ce2eeb967a4 Mon Sep 17 00:00:00 2001 From: witzig Date: Wed, 10 May 2017 01:40:02 +0200 Subject: [PATCH 22/30] e2e tests (draft) --- .gitignore | 2 + Gruntfile.js | 2 +- app.js | 5 + config/default.toml | 24 +- lib/dbcheck.js | 7 +- package.json | 17 +- services/test-server.js | 75 +- setup/sql/drop.js | 16 +- setup/sql/init.js | 17 +- setup/sql/mailtrain-test.sql | 1023 +++++++++++++++++++++++++ test/e2e/.eslintrc | 11 + test/e2e/README.md | 6 + test/e2e/bin/README.md | 8 + test/e2e/helpers/config.js | 31 + test/e2e/helpers/driver.js | 15 + test/e2e/helpers/exit-unless-test.js | 16 + test/e2e/index.js | 36 + test/e2e/page-objects/flash.js | 25 + test/e2e/page-objects/home.js | 12 + test/e2e/page-objects/page.js | 61 ++ test/e2e/page-objects/subscription.js | 84 ++ test/e2e/page-objects/users.js | 35 + test/e2e/tests/login.js | 57 ++ test/e2e/tests/subscription.js | 101 +++ test/{ => nodeunit}/frontmail-test.js | 0 views/layout.hbs | 2 +- 26 files changed, 1659 insertions(+), 29 deletions(-) create mode 100644 setup/sql/mailtrain-test.sql create mode 100644 test/e2e/.eslintrc create mode 100644 test/e2e/README.md create mode 100644 test/e2e/bin/README.md create mode 100644 test/e2e/helpers/config.js create mode 100644 test/e2e/helpers/driver.js create mode 100644 test/e2e/helpers/exit-unless-test.js create mode 100644 test/e2e/index.js create mode 100644 test/e2e/page-objects/flash.js create mode 100644 test/e2e/page-objects/home.js create mode 100644 test/e2e/page-objects/page.js create mode 100644 test/e2e/page-objects/subscription.js create mode 100644 test/e2e/page-objects/users.js create mode 100644 test/e2e/tests/login.js create mode 100644 test/e2e/tests/subscription.js rename test/{ => nodeunit}/frontmail-test.js (100%) diff --git a/.gitignore b/.gitignore index 710ec373..c7687df8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,10 @@ npm-debug.log .DS_Store config/development.* config/production.* +config/test.* workers/reports/config/development.* workers/reports/config/production.* +workers/reports/config/test.* dump.rdb # generate POT file every time you want to update your PO file diff --git a/Gruntfile.js b/Gruntfile.js index 6ac983cc..daf89982 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -9,7 +9,7 @@ module.exports = function (grunt) { }, nodeunit: { - all: ['test/**/*-test.js'] + all: ['test/nodeunit/**/*-test.js'] }, jsxgettext: { diff --git a/app.js b/app.js index f8bad1bf..1143df71 100644 --- a/app.js +++ b/app.js @@ -183,6 +183,11 @@ app.use((req, res, next) => { res.locals.customStyles = config.customstyles || []; res.locals.customScripts = config.customscripts || []; + let bodyClasses = []; + app.get('env') === 'test' && bodyClasses.push('page--' + (req.path.substring(1).replace(/\//g, '--') || 'home')); + req.user && bodyClasses.push('logged-in user-' + req.user.username); + res.locals.bodyClass = bodyClasses.join(' '); + settingsModel.list(['ua_code', 'shoutout'], (err, configItems) => { if (err) { return next(err); diff --git a/config/default.toml b/config/default.toml index 64d45fbe..af72e6dd 100644 --- a/config/default.toml +++ b/config/default.toml @@ -110,16 +110,6 @@ host="0.0.0.0" # VERP hostname is in the same domain as the From address. # disablesenderheader=true -[testserver] -# Starts a vanity server that redirects all mail to /dev/null -# Mostly needed for local development -enabled=false -port=5587 -host="0.0.0.0" -username="testuser" -password="testpass" -logger=false - [ldap] # enable to use ldap user backend enabled=false @@ -177,3 +167,17 @@ templates=[["demo", "Demo Template"]] # The bottom line is that if people who are creating report templates or have write access to the DB cannot be trusted, # then it's safer to switch off the reporting functionality below. enabled=false + +[testserver] +# Starts a vanity server that redirects all mail to /dev/null +# Mostly needed for local development +enabled=false +port=5587 +mailboxserverport=3001 +host="0.0.0.0" +username="testuser" +password="testpass" +logger=false + +[seleniumwebdriver] +browser="phantomjs" diff --git a/lib/dbcheck.js b/lib/dbcheck.js index 001723a1..93264800 100644 --- a/lib/dbcheck.js +++ b/lib/dbcheck.js @@ -107,13 +107,14 @@ function getSql(path, data, callback) { if (err) { return callback(err); } - let renderer = Handlebars.compile(source); - return callback(null, renderer(data || {})); + const rendered = data ? Handlebars.compile(source)(data) : source; + return callback(null, rendered); }); } function runInitial(callback) { - let fname = process.env.DB_FROM_START ? 'base.sql' : 'mailtrain.sql'; + let dump = process.env.NODE_ENV === 'test' ? 'mailtrain-test.sql' : 'mailtrain.sql'; + let fname = process.env.DB_FROM_START ? 'base.sql' : dump; let path = pathlib.join(__dirname, '..', 'setup', 'sql', fname); log.info('sql', 'Loading tables from %s', fname); applyUpdate({ diff --git a/package.json b/package.json index 7d82f7cd..ad306cd2 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,17 @@ "test": "grunt", "start": "node index.js", "sqlinit": "node setup/sql/init.js", - "sqldump": "node setup/sql/dump.js | sed -e '/^\\/\\*.*\\*\\/;$/d' -e 's/.[0-9]\\{4\\}-[0-9][0-9]-[0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9]./NOW()/g' > setup/sql/mailtrain.sql", + "sqldump": "node setup/sql/dump.js | sed -e '/^\\/\\*.*\\*\\/;$/d' -e 's/.[0-9]\\{4\\}-[0-9][0-9]-[0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9]./NOW()/g' > setup/sql/mailtrain${DUMP_NAME_SUFFIX}.sql", "sqldrop": "node setup/sql/drop.js", "sqlgen": "npm run sqldrop && DB_FROM_START=Y npm run sqlinit && npm run sqldump", "langs:hbs": "jsxgettext -L handlebars -k translate -o langs/hbs.pot views/layout.hbs views/index.hbs", "langs:js": "jsxgettext -o languages/js.pot routes/index.js", - "langs": "npm run langs:hbs && npm run langs:js" + "langs": "npm run langs:hbs && npm run langs:js", + "sqldumptest": "NODE_ENV=test DUMP_NAME_SUFFIX=-test npm run sqldump", + "sqlresettest": "NODE_ENV=test npm run sqldrop && NODE_ENV=test npm run sqlinit", + "starttest": "NODE_ENV=test node index.js", + "_e2e": "PATH=$PATH:./node_modules/phantomjs/lib/phantom/bin:./test/e2e/bin NODE_ENV=test ./node_modules/.bin/mocha test/e2e/index.js", + "e2e": "npm run sqlresettest && npm run _e2e" }, "repository": { "type": "git", @@ -26,12 +31,18 @@ "node": ">=5.0.0" }, "devDependencies": { + "babel-eslint": "^7.2.3", + "chai": "^3.5.0", "eslint-config-nodemailer": "^1.0.0", "grunt": "^1.0.1", "grunt-cli": "^1.2.0", "grunt-contrib-nodeunit": "^1.0.0", "grunt-eslint": "^19.0.0", - "jsxgettext-andris": "^0.9.0-patch.1" + "jsxgettext-andris": "^0.9.0-patch.1", + "mailparser": "^2.0.5", + "mocha": "^3.3.0", + "phantomjs": "^2.1.7", + "selenium-webdriver": "^3.4.0" }, "optionalDependencies": { "posix": "^4.1.1" diff --git a/services/test-server.js b/services/test-server.js index 025a16df..1f4777b8 100644 --- a/services/test-server.js +++ b/services/test-server.js @@ -4,12 +4,37 @@ let log = require('npmlog'); let config = require('config'); let crypto = require('crypto'); let humanize = require('humanize'); +let http = require('http'); let SMTPServer = require('smtp-server').SMTPServer; +let simpleParser = require('mailparser').simpleParser; let totalMessages = 0; let received = 0; +let mailstore = { + accounts: {}, + saveMessage(address, message) { + if (!this.accounts[address]) { + this.accounts[address] = []; + } + this.accounts[address].push(message); + }, + getMail(address, callback) { + if (!this.accounts[address] || this.accounts[address].length === 0) { + let err = new Error('No mail for ' + address); + err.status = 404; + return callback(err); + } + simpleParser(this.accounts[address].shift(), (err, mail) => { + if (err) { + return callback(err.message || err); + } + callback(null, mail); + }); + } +}; + // Setup server let server = new SMTPServer({ @@ -74,8 +99,12 @@ let server = new SMTPServer({ // Handle message stream onData: (stream, session, callback) => { let hash = crypto.createHash('md5'); + let message = ''; stream.on('data', chunk => { hash.update(chunk); + if (/^keep/i.test(session.envelope.rcptTo[0].address)) { + message += chunk; + } }); stream.on('end', () => { let err; @@ -84,6 +113,12 @@ let server = new SMTPServer({ err.responseCode = 552; return callback(err); } + + // Store message for e2e tests + if (/^keep/i.test(session.envelope.rcptTo[0].address)) { + mailstore.saveMessage(session.envelope.rcptTo[0].address, message); + } + received++; callback(null, 'Message queued as ' + hash.digest('hex')); // accept the message once the stream is ended }); @@ -94,6 +129,41 @@ server.on('error', err => { log.error('Test SMTP', err.stack); }); +let mailBoxServer = http.createServer((req, res) => { + let renderer = data => ( + '' + data.title + '' + data.body + '' + ); + + let address = req.url.substring(1); + mailstore.getMail(address, (err, mail) => { + if (err) { + let html = renderer({ + title: 'error', + body: err.message || err + }); + res.writeHead(err.status || 500, { 'Content-Type': 'text/html' }); + return res.end(html); + } + + let html = mail.html || renderer({ + title: 'error', + body: 'This mail has no HTML part' + }); + + // https://nodemailer.com/extras/mailparser/#mail-object + delete mail.html; + delete mail.textAsHtml; + delete mail.attachments; + + let script = ''; + html = html.replace(/<\/body\b/i, match => script + match); + html = html.replace(/target="_blank"/g, 'target="_self"'); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(html); + }); +}); + module.exports = callback => { if (config.testserver.enabled) { server.listen(config.testserver.port, config.testserver.host, () => { @@ -112,7 +182,10 @@ module.exports = callback => { } }, 60 * 1000); - setImmediate(callback); + mailBoxServer.listen(config.testserver.mailboxserverport, config.testserver.host, () => { + log.info('Test SMTP', 'Mail Box Server listening on port %s', config.testserver.mailboxserverport); + setImmediate(callback); + }); }); } else { setImmediate(callback); diff --git a/setup/sql/drop.js b/setup/sql/drop.js index ba0291d2..3e972fb3 100644 --- a/setup/sql/drop.js +++ b/setup/sql/drop.js @@ -1,17 +1,23 @@ 'use strict'; -if (process.env.NODE_ENV === 'production') { - console.log('This script does not run in production'); // eslint-disable-line no-console - process.exit(1); -} - let config = require('config'); let spawn = require('child_process').spawn; let log = require('npmlog'); let path = require('path'); +let fs = require('fs'); log.level = 'verbose'; +if (process.env.NODE_ENV === 'production') { + log.error('sqldrop', 'This script does not run in production'); + process.exit(1); +} + +if (process.env.NODE_ENV === 'test' && !fs.existsSync(path.join(__dirname, '..', '..', 'config', 'test.toml'))) { + log.error('sqldrop', 'This script only runs in test if config/test.toml (i.e. a dedicated test database) is present'); + process.exit(1); +} + function createDump(callback) { let cmd = spawn(path.join(__dirname, 'drop.sh'), [], { env: { diff --git a/setup/sql/init.js b/setup/sql/init.js index c3a638e3..c654b451 100644 --- a/setup/sql/init.js +++ b/setup/sql/init.js @@ -1,15 +1,22 @@ 'use strict'; -if (process.env.NODE_ENV === 'production') { - console.log('This script does not run in production'); // eslint-disable-line no-console - process.exit(1); -} - let dbcheck = require('../../lib/dbcheck'); let log = require('npmlog'); +let path = require('path'); +let fs = require('fs'); log.level = 'verbose'; +if (process.env.NODE_ENV === 'production') { + log.error('sqlinit', 'This script does not run in production'); + process.exit(1); +} + +if (process.env.NODE_ENV === 'test' && !fs.existsSync(path.join(__dirname, '..', '..', 'config', 'test.toml'))) { + log.error('sqlinit', 'This script only runs in test if config/test.toml (i.e. a dedicated test database) is present'); + process.exit(1); +} + dbcheck(err => { if (err) { log.error('DB', err); diff --git a/setup/sql/mailtrain-test.sql b/setup/sql/mailtrain-test.sql new file mode 100644 index 00000000..8ae97db9 --- /dev/null +++ b/setup/sql/mailtrain-test.sql @@ -0,0 +1,1023 @@ +SET UNIQUE_CHECKS=0; +SET FOREIGN_KEY_CHECKS=0; + +CREATE TABLE `attachments` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `campaign` int(11) unsigned NOT NULL, + `filename` varchar(255) CHARACTER SET utf8mb4 NOT NULL DEFAULT '', + `content_type` varchar(100) CHARACTER SET ascii NOT NULL DEFAULT '', + `content` longblob, + `size` int(11) NOT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `campaign` (`campaign`), + CONSTRAINT `attachments_ibfk_1` FOREIGN KEY (`campaign`) REFERENCES `campaigns` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE `blacklist` ( + `email` varchar(191) NOT NULL, + PRIMARY KEY (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `campaign` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `list` int(11) unsigned NOT NULL, + `segment` int(11) unsigned NOT NULL, + `subscription` int(11) unsigned NOT NULL, + `status` tinyint(4) unsigned NOT NULL DEFAULT '0', + `response` varchar(255) DEFAULT NULL, + `response_id` varchar(255) CHARACTER SET ascii DEFAULT NULL, + `updated` timestamp NULL DEFAULT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `list` (`list`,`segment`,`subscription`), + KEY `created` (`created`), + KEY `response_id` (`response_id`), + KEY `status_index` (`status`), + KEY `subscription_index` (`subscription`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `campaign_tracker` ( + `list` int(11) unsigned NOT NULL, + `subscriber` int(11) unsigned NOT NULL, + `link` int(11) NOT NULL, + `ip` varchar(100) CHARACTER SET ascii DEFAULT NULL, + `device_type` varchar(50) DEFAULT NULL, + `country` varchar(2) CHARACTER SET ascii DEFAULT NULL, + `count` int(11) unsigned NOT NULL DEFAULT '1', + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`list`,`subscriber`,`link`), + KEY `created_index` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `campaigns` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `cid` varchar(255) CHARACTER SET ascii NOT NULL, + `type` tinyint(4) unsigned NOT NULL DEFAULT '1', + `parent` int(11) unsigned DEFAULT NULL, + `name` varchar(255) NOT NULL DEFAULT '', + `description` text, + `list` int(11) unsigned NOT NULL, + `segment` int(11) unsigned DEFAULT NULL, + `template` int(11) unsigned NOT NULL, + `source_url` varchar(255) CHARACTER SET ascii DEFAULT NULL, + `editor_name` varchar(50) DEFAULT '', + `editor_data` longtext, + `last_check` timestamp NULL DEFAULT NULL, + `check_status` varchar(255) DEFAULT NULL, + `from` varchar(255) DEFAULT '', + `address` varchar(255) DEFAULT '', + `reply_to` varchar(255) DEFAULT '', + `subject` varchar(255) DEFAULT '', + `html` longtext, + `html_prepared` longtext, + `text` longtext, + `status` tinyint(4) unsigned NOT NULL DEFAULT '1', + `tracking_disabled` tinyint(4) unsigned NOT NULL DEFAULT '0', + `scheduled` timestamp NULL DEFAULT NULL, + `status_change` timestamp NULL DEFAULT NULL, + `delivered` int(11) unsigned NOT NULL DEFAULT '0', + `blacklisted` int(11) unsigned NOT NULL DEFAULT '0', + `opened` int(11) unsigned NOT NULL DEFAULT '0', + `clicks` int(11) unsigned NOT NULL DEFAULT '0', + `unsubscribed` int(11) unsigned NOT NULL DEFAULT '0', + `bounced` int(1) unsigned NOT NULL DEFAULT '0', + `complained` int(1) unsigned NOT NULL DEFAULT '0', + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `cid` (`cid`), + KEY `name` (`name`(191)), + KEY `status` (`status`), + KEY `schedule_index` (`scheduled`), + KEY `type_index` (`type`), + KEY `parent_index` (`parent`), + KEY `check_index` (`last_check`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `confirmations` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `cid` varchar(255) CHARACTER SET ascii NOT NULL, + `list` int(11) unsigned NOT NULL, + `email` varchar(255) NOT NULL, + `opt_in_ip` varchar(100) DEFAULT NULL, + `data` text NOT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `cid` (`cid`), + KEY `list` (`list`), + CONSTRAINT `confirmations_ibfk_1` FOREIGN KEY (`list`) REFERENCES `lists` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `custom_fields` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `list` int(11) unsigned NOT NULL, + `name` varchar(255) DEFAULT '', + `key` varchar(100) CHARACTER SET ascii NOT NULL, + `default_value` varchar(255) DEFAULT NULL, + `type` varchar(255) CHARACTER SET ascii NOT NULL DEFAULT '', + `group` int(11) unsigned DEFAULT NULL, + `group_template` text, + `column` varchar(255) CHARACTER SET ascii DEFAULT NULL, + `visible` tinyint(4) unsigned NOT NULL DEFAULT '1', + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `list` (`list`,`column`), + KEY `list_2` (`list`), + CONSTRAINT `custom_fields_ibfk_1` FOREIGN KEY (`list`) REFERENCES `lists` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `custom_forms` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `list` int(11) unsigned NOT NULL, + `name` varchar(255) DEFAULT '', + `description` text, + `fields_shown_on_subscribe` varchar(255) DEFAULT '', + `fields_shown_on_manage` varchar(255) DEFAULT '', + `layout` longtext, + `form_input_style` longtext, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `list` (`list`), + CONSTRAINT `custom_forms_ibfk_1` FOREIGN KEY (`list`) REFERENCES `lists` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `custom_forms_data` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `form` int(11) unsigned NOT NULL, + `data_key` varchar(255) DEFAULT '', + `data_value` longtext, + PRIMARY KEY (`id`), + KEY `form` (`form`), + CONSTRAINT `custom_forms_data_ibfk_1` FOREIGN KEY (`form`) REFERENCES `custom_forms` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `import_failed` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `import` int(11) unsigned NOT NULL, + `email` varchar(255) NOT NULL DEFAULT '', + `reason` varchar(255) DEFAULT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `import` (`import`), + CONSTRAINT `import_failed_ibfk_1` FOREIGN KEY (`import`) REFERENCES `importer` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `importer` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `list` int(11) unsigned NOT NULL, + `type` tinyint(4) unsigned NOT NULL DEFAULT '1', + `path` varchar(255) NOT NULL DEFAULT '', + `size` int(11) unsigned NOT NULL DEFAULT '0', + `delimiter` varchar(1) CHARACTER SET ascii NOT NULL DEFAULT ',', + `emailcheck` tinyint(4) unsigned NOT NULL DEFAULT '1', + `status` tinyint(4) unsigned NOT NULL DEFAULT '0', + `error` varchar(255) DEFAULT NULL, + `processed` int(11) unsigned NOT NULL DEFAULT '0', + `new` int(11) unsigned NOT NULL DEFAULT '0', + `failed` int(11) unsigned NOT NULL DEFAULT '0', + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `mapping` text NOT NULL, + `finished` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `list` (`list`), + CONSTRAINT `importer_ibfk_1` FOREIGN KEY (`list`) REFERENCES `lists` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `links` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `cid` varchar(255) CHARACTER SET ascii NOT NULL DEFAULT '', + `campaign` int(11) unsigned NOT NULL, + `url` varchar(255) CHARACTER SET ascii NOT NULL DEFAULT '', + `clicks` int(11) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `cid` (`cid`), + UNIQUE KEY `campaign_2` (`campaign`,`url`), + KEY `campaign` (`campaign`), + CONSTRAINT `links_ibfk_1` FOREIGN KEY (`campaign`) REFERENCES `campaigns` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `lists` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `cid` varchar(255) CHARACTER SET ascii NOT NULL, + `default_form` int(11) unsigned DEFAULT NULL, + `name` varchar(255) NOT NULL DEFAULT '', + `description` text, + `subscribers` int(11) unsigned DEFAULT '0', + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `public_subscribe` tinyint(1) unsigned NOT NULL DEFAULT '1', + PRIMARY KEY (`id`), + UNIQUE KEY `cid` (`cid`), + KEY `name` (`name`(191)) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4; +INSERT INTO `lists` (`id`, `cid`, `default_form`, `name`, `description`, `subscribers`, `created`, `public_subscribe`) VALUES (1,'Hkj1vCoJb',NULL,'01 Testlist - Public Subscribe','',0,NOW(),1); +CREATE TABLE `queued` ( + `campaign` int(11) unsigned NOT NULL, + `list` int(11) unsigned NOT NULL, + `subscriber` int(11) unsigned NOT NULL, + `source` varchar(255) DEFAULT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`campaign`,`list`,`subscriber`), + KEY `created` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE `report_templates` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) DEFAULT '', + `mime_type` varchar(255) NOT NULL DEFAULT 'text/html', + `description` text, + `user_fields` longtext, + `js` longtext, + `hbs` longtext, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `reports` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) DEFAULT '', + `description` text, + `report_template` int(11) unsigned NOT NULL, + `params` longtext, + `state` int(11) unsigned NOT NULL DEFAULT '0', + `last_run` datetime DEFAULT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `report_template` (`report_template`), + CONSTRAINT `report_template_ibfk_1` FOREIGN KEY (`report_template`) REFERENCES `report_templates` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `rss` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `parent` int(11) unsigned NOT NULL, + `guid` varchar(255) NOT NULL DEFAULT '', + `pubdate` timestamp NULL DEFAULT NULL, + `campaign` int(11) unsigned DEFAULT NULL, + `found` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `parent_2` (`parent`,`guid`), + KEY `parent` (`parent`), + CONSTRAINT `rss_ibfk_1` FOREIGN KEY (`parent`) REFERENCES `campaigns` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE `segment_rules` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `segment` int(11) unsigned NOT NULL, + `column` varchar(255) CHARACTER SET ascii NOT NULL DEFAULT '', + `value` varchar(255) NOT NULL DEFAULT '', + PRIMARY KEY (`id`), + KEY `segment` (`segment`), + CONSTRAINT `segment_rules_ibfk_1` FOREIGN KEY (`segment`) REFERENCES `segments` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `segments` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `list` int(11) unsigned NOT NULL, + `name` varchar(255) NOT NULL DEFAULT '', + `type` tinyint(4) unsigned NOT NULL, + `created` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `list` (`list`), + KEY `name` (`name`(191)), + CONSTRAINT `segments_ibfk_1` FOREIGN KEY (`list`) REFERENCES `lists` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `settings` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `key` varchar(255) CHARACTER SET ascii NOT NULL DEFAULT '', + `value` text NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `key` (`key`) +) ENGINE=InnoDB AUTO_INCREMENT=112 DEFAULT CHARSET=utf8mb4; +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (1,'smtp_hostname','localhost'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (2,'smtp_port','5587'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (3,'smtp_encryption','NONE'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (4,'smtp_user','testuser'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (5,'smtp_pass','testpass'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (6,'service_url','http://localhost:3000/'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (7,'admin_email','admin@example.com'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (8,'smtp_max_connections','5'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (9,'smtp_max_messages','100'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (10,'smtp_log',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (11,'default_sender','My Awesome Company'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (12,'default_postaddress','1234 Main Street'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (13,'default_from','My Awesome Company'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (14,'default_address','admin@example.com'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (15,'default_subject','Test message'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (16,'default_homepage','https://mailtrain.org'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (17,'db_schema_version','27'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (46,'ua_code',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (47,'shoutout',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (54,'mail_transport','smtp'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (60,'ses_key',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (61,'ses_secret',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (62,'ses_region','us-east-1'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (65,'smtp_throttling',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (66,'pgp_passphrase',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (67,'pgp_private_key',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (68,'dkim_api_key',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (69,'dkim_domain',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (70,'dkim_selector',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (71,'dkim_private_key',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (73,'smtp_self_signed',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (74,'smtp_disable_auth',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (75,'verp_use',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (76,'disable_wysiwyg',''); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (77,'disable_confirmations',''); +CREATE TABLE `subscription` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `cid` varchar(255) CHARACTER SET ascii NOT NULL, + `email` varchar(255) CHARACTER SET utf8 NOT NULL DEFAULT '', + `opt_in_ip` varchar(100) DEFAULT NULL, + `opt_in_country` varchar(2) DEFAULT NULL, + `tz` varchar(100) CHARACTER SET ascii DEFAULT NULL, + `imported` int(11) unsigned DEFAULT NULL, + `status` tinyint(4) unsigned NOT NULL DEFAULT '1', + `is_test` tinyint(4) unsigned NOT NULL DEFAULT '0', + `status_change` timestamp NULL DEFAULT NULL, + `latest_open` timestamp NULL DEFAULT NULL, + `latest_click` timestamp NULL DEFAULT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `first_name` varchar(255) DEFAULT NULL, + `last_name` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`), + UNIQUE KEY `cid` (`cid`), + KEY `status` (`status`), + KEY `first_name` (`first_name`(191)), + KEY `last_name` (`last_name`(191)), + KEY `subscriber_tz` (`tz`), + KEY `is_test` (`is_test`), + KEY `latest_open` (`latest_open`), + KEY `latest_click` (`latest_click`), + KEY `created` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `subscription__1` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `cid` varchar(255) CHARACTER SET ascii NOT NULL, + `email` varchar(255) CHARACTER SET utf8 NOT NULL DEFAULT '', + `opt_in_ip` varchar(100) DEFAULT NULL, + `opt_in_country` varchar(2) DEFAULT NULL, + `tz` varchar(100) CHARACTER SET ascii DEFAULT NULL, + `imported` int(11) unsigned DEFAULT NULL, + `status` tinyint(4) unsigned NOT NULL DEFAULT '1', + `is_test` tinyint(4) unsigned NOT NULL DEFAULT '0', + `status_change` timestamp NULL DEFAULT NULL, + `latest_open` timestamp NULL DEFAULT NULL, + `latest_click` timestamp NULL DEFAULT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `first_name` varchar(255) DEFAULT NULL, + `last_name` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`), + UNIQUE KEY `cid` (`cid`), + KEY `status` (`status`), + KEY `first_name` (`first_name`(191)), + KEY `last_name` (`last_name`(191)), + KEY `subscriber_tz` (`tz`), + KEY `is_test` (`is_test`), + KEY `latest_open` (`latest_open`), + KEY `latest_click` (`latest_click`), + KEY `created` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `templates` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL DEFAULT '', + `description` text, + `editor_name` varchar(50) DEFAULT '', + `editor_data` longtext, + `html` longtext, + `text` longtext, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `name` (`name`(191)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `trigger` ( + `list` int(11) unsigned NOT NULL, + `subscription` int(11) unsigned NOT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`list`,`subscription`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE `triggers` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL DEFAULT '', + `description` text, + `enabled` tinyint(4) unsigned NOT NULL DEFAULT '1', + `list` int(11) unsigned NOT NULL, + `source_campaign` int(11) unsigned DEFAULT NULL, + `rule` varchar(255) CHARACTER SET ascii NOT NULL DEFAULT 'column', + `column` varchar(255) CHARACTER SET ascii DEFAULT NULL, + `seconds` int(11) NOT NULL DEFAULT '0', + `dest_campaign` int(11) unsigned DEFAULT NULL, + `count` int(11) unsigned NOT NULL DEFAULT '0', + `last_check` timestamp NULL DEFAULT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `name` (`name`(191)), + KEY `source_campaign` (`source_campaign`), + KEY `dest_campaign` (`dest_campaign`), + KEY `list` (`list`), + KEY `column` (`column`), + KEY `active` (`enabled`), + KEY `last_check` (`last_check`), + CONSTRAINT `triggers_ibfk_1` FOREIGN KEY (`list`) REFERENCES `lists` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `tzoffset` ( + `tz` varchar(100) NOT NULL DEFAULT '', + `offset` int(11) NOT NULL DEFAULT '0', + PRIMARY KEY (`tz`) +) ENGINE=InnoDB DEFAULT CHARSET=ascii; +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/abidjan',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/accra',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/addis_ababa',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/algiers',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/asmara',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/asmera',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/bamako',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/bangui',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/banjul',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/bissau',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/blantyre',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/brazzaville',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/bujumbura',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/cairo',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/casablanca',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/ceuta',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/conakry',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/dakar',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/dar_es_salaam',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/djibouti',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/douala',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/el_aaiun',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/freetown',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/gaborone',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/harare',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/johannesburg',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/juba',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/kampala',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/khartoum',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/kigali',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/kinshasa',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/lagos',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/libreville',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/lome',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/luanda',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/lubumbashi',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/lusaka',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/malabo',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/maputo',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/maseru',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/mbabane',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/mogadishu',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/monrovia',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/nairobi',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/ndjamena',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/niamey',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/nouakchott',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/ouagadougou',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/porto-novo',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/sao_tome',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/timbuktu',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/tripoli',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/tunis',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/windhoek',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/adak',-540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/anchorage',-480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/anguilla',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/antigua',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/araguaina',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/buenos_aires',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/catamarca',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/comodrivadavia',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/cordoba',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/jujuy',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/la_rioja',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/mendoza',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/rio_gallegos',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/salta',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/san_juan',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/san_luis',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/tucuman',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/argentina/ushuaia',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/aruba',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/asuncion',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/atikokan',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/atka',-540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/bahia',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/bahia_banderas',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/barbados',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/belem',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/belize',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/blanc-sablon',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/boa_vista',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/bogota',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/boise',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/buenos_aires',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/cambridge_bay',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/campo_grande',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/cancun',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/caracas',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/catamarca',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/cayenne',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/cayman',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/chicago',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/chihuahua',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/coral_harbour',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/cordoba',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/costa_rica',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/creston',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/cuiaba',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/curacao',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/danmarkshavn',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/dawson',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/dawson_creek',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/denver',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/detroit',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/dominica',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/edmonton',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/eirunepe',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/el_salvador',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/ensenada',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/fortaleza',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/fort_nelson',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/fort_wayne',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/glace_bay',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/godthab',-120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/goose_bay',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/grand_turk',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/grenada',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/guadeloupe',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/guatemala',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/guayaquil',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/guyana',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/halifax',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/havana',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/hermosillo',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/indiana/indianapolis',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/indiana/knox',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/indiana/marengo',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/indiana/petersburg',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/indiana/tell_city',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/indiana/vevay',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/indiana/vincennes',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/indiana/winamac',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/indianapolis',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/inuvik',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/iqaluit',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/jamaica',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/jujuy',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/juneau',-480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/kentucky/louisville',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/kentucky/monticello',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/knox_in',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/kralendijk',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/la_paz',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/lima',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/los_angeles',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/louisville',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/lower_princes',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/maceio',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/managua',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/manaus',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/marigot',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/martinique',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/matamoros',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/mazatlan',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/mendoza',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/menominee',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/merida',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/metlakatla',-480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/mexico_city',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/miquelon',-120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/moncton',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/monterrey',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/montevideo',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/montreal',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/montserrat',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/nassau',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/new_york',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/nipigon',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/nome',-480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/noronha',-120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/north_dakota/beulah',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/north_dakota/center',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/north_dakota/new_salem',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/ojinaga',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/panama',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/pangnirtung',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/paramaribo',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/phoenix',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/port-au-prince',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/porto_acre',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/porto_velho',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/port_of_spain',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/puerto_rico',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/punta_arenas',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/rainy_river',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/rankin_inlet',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/recife',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/regina',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/resolute',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/rio_branco',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/rosario',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/santarem',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/santa_isabel',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/santiago',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/santo_domingo',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/sao_paulo',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/scoresbysund',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/shiprock',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/sitka',-480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/st_barthelemy',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/st_johns',-150); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/st_kitts',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/st_lucia',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/st_thomas',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/st_vincent',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/swift_current',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/tegucigalpa',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/thule',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/thunder_bay',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/tijuana',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/toronto',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/tortola',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/vancouver',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/virgin',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/whitehorse',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/winnipeg',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/yakutat',-480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/yellowknife',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/casey',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/davis',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/dumontdurville',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/macquarie',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/mawson',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/mcmurdo',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/palmer',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/rothera',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/south_pole',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/syowa',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/troll',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('antarctica/vostok',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('arctic/longyearbyen',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/aden',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/almaty',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/amman',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/anadyr',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/aqtau',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/aqtobe',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/ashgabat',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/ashkhabad',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/atyrau',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/baghdad',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/bahrain',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/baku',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/bangkok',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/barnaul',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/beirut',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/bishkek',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/brunei',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/calcutta',330); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/chita',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/choibalsan',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/chongqing',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/chungking',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/colombo',330); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/dacca',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/damascus',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/dhaka',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/dili',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/dubai',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/dushanbe',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/famagusta',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/gaza',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/harbin',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/hebron',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/hong_kong',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/hovd',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/ho_chi_minh',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/irkutsk',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/istanbul',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/jakarta',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/jayapura',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/jerusalem',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/kabul',270); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/kamchatka',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/karachi',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/kashgar',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/kathmandu',345); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/katmandu',345); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/khandyga',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/kolkata',330); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/krasnoyarsk',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/kuala_lumpur',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/kuching',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/kuwait',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/macao',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/macau',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/magadan',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/makassar',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/manila',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/muscat',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/nicosia',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/novokuznetsk',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/novosibirsk',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/omsk',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/oral',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/phnom_penh',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/pontianak',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/pyongyang',510); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/qatar',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/qyzylorda',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/rangoon',390); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/riyadh',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/saigon',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/sakhalin',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/samarkand',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/seoul',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/shanghai',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/singapore',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/srednekolymsk',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/taipei',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/tashkent',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/tbilisi',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/tehran',270); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/tel_aviv',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/thimbu',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/thimphu',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/tokyo',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/tomsk',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/ujung_pandang',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/ulaanbaatar',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/ulan_bator',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/urumqi',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/ust-nera',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/vientiane',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/vladivostok',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/yakutsk',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/yangon',390); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/yekaterinburg',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('asia/yerevan',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/azores',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/bermuda',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/canary',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/cape_verde',-60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/faeroe',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/faroe',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/jan_mayen',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/madeira',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/reykjavik',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/south_georgia',-120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/stanley',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('atlantic/st_helena',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/act',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/adelaide',570); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/brisbane',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/broken_hill',570); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/canberra',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/currie',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/darwin',570); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/eucla',525); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/hobart',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/lhi',630); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/lindeman',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/lord_howe',630); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/melbourne',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/north',570); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/nsw',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/perth',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/queensland',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/south',570); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/sydney',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/tasmania',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/victoria',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/west',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('australia/yancowinna',570); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('brazil/acre',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('brazil/denoronha',-120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('brazil/east',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('brazil/west',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/atlantic',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/central',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/east-saskatchewan',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/eastern',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/mountain',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/newfoundland',-150); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/pacific',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/saskatchewan',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/yukon',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('cet',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('chile/continental',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('chile/easterisland',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('cst6cdt',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('cuba',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('eet',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('egypt',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('eire',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('est',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('est5edt',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+0',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+1',-60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+10',-600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+11',-660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+12',-720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+2',-120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+3',-180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+4',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+5',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+6',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+7',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+8',-480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt+9',-540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-0',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-1',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-10',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-11',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-12',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-13',780); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-14',840); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-2',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-3',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-4',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-5',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-6',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-7',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-8',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt-9',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/gmt0',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/greenwich',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/uct',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/universal',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/utc',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('etc/zulu',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/amsterdam',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/andorra',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/astrakhan',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/athens',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/belfast',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/belgrade',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/berlin',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/bratislava',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/brussels',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/bucharest',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/budapest',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/busingen',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/chisinau',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/copenhagen',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/dublin',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/gibraltar',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/guernsey',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/helsinki',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/isle_of_man',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/istanbul',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/jersey',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/kaliningrad',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/kiev',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/kirov',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/lisbon',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/ljubljana',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/london',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/luxembourg',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/madrid',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/malta',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/mariehamn',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/minsk',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/monaco',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/moscow',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/nicosia',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/oslo',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/paris',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/podgorica',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/prague',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/riga',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/rome',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/samara',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/san_marino',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/sarajevo',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/saratov',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/simferopol',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/skopje',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/sofia',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/stockholm',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/tallinn',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/tirane',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/tiraspol',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/ulyanovsk',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/uzhgorod',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/vaduz',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/vatican',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/vienna',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/vilnius',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/volgograd',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/warsaw',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/zagreb',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/zaporozhye',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('europe/zurich',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('gb',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('gb-eire',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('gmt',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('gmt+0',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('gmt-0',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('gmt0',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('greenwich',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('hongkong',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('hst',-600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('iceland',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/antananarivo',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/chagos',360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/christmas',420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/cocos',390); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/comoro',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/kerguelen',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/mahe',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/maldives',300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/mauritius',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/mayotte',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('indian/reunion',240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('iran',270); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('israel',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('jamaica',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('japan',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('kwajalein',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('libya',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('met',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('mexico/bajanorte',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('mexico/bajasur',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('mexico/general',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('mst',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('mst7mdt',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('navajo',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('nz',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('nz-chat',765); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/apia',780); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/auckland',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/bougainville',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/chatham',765); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/chuuk',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/easter',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/efate',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/enderbury',780); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/fakaofo',780); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/fiji',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/funafuti',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/galapagos',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/gambier',-540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/guadalcanal',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/guam',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/honolulu',-600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/johnston',-600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/kiritimati',840); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/kosrae',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/kwajalein',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/majuro',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/marquesas',-570); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/midway',-660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/nauru',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/niue',-660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/norfolk',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/noumea',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/pago_pago',-660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/palau',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/pitcairn',-480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/pohnpei',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/ponape',660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/port_moresby',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/rarotonga',-600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/saipan',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/samoa',-660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/tahiti',-600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/tarawa',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/tongatapu',780); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/truk',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/wake',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/wallis',720); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/yap',600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('poland',120); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('portugal',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('prc',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pst8pdt',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('roc',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('rok',540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('singapore',480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('turkey',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('uct',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('universal',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/alaska',-480); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/aleutian',-540); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/arizona',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/central',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/east-indiana',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/eastern',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/hawaii',-600); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/indiana-starke',-300); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/michigan',-240); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/mountain',-360); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/pacific',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/pacific-new',-420); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('us/samoa',-660); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('utc',0); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('w-su',180); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('wet',60); +INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('zulu',0); +CREATE TABLE `users` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `username` varchar(255) NOT NULL DEFAULT '', + `password` varchar(255) NOT NULL DEFAULT '', + `email` varchar(255) CHARACTER SET utf8 DEFAULT NULL, + `access_token` varchar(40) DEFAULT NULL, + `reset_token` varchar(255) CHARACTER SET ascii DEFAULT NULL, + `reset_expire` timestamp NULL DEFAULT NULL, + `created` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`), + KEY `username` (`username`(191)), + KEY `reset` (`reset_token`), + KEY `check_reset` (`username`(191),`reset_token`,`reset_expire`), + KEY `token_index` (`access_token`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4; +INSERT INTO `users` (`id`, `username`, `password`, `email`, `access_token`, `reset_token`, `reset_expire`, `created`) VALUES (1,'admin','$2a$10$mzKU71G62evnGB2PvQA4k..Wf9jASk.c7a8zRMHh6qQVjYJ2r/g/K','admin@example.com',NULL,NULL,NULL,NOW()); + +SET UNIQUE_CHECKS=1; +SET FOREIGN_KEY_CHECKS=1; diff --git a/test/e2e/.eslintrc b/test/e2e/.eslintrc new file mode 100644 index 00000000..836bac9a --- /dev/null +++ b/test/e2e/.eslintrc @@ -0,0 +1,11 @@ +{ + "parser": "babel-eslint", + "rules": { + "strict": 0, + "no-invalid-this": 0, + "no-unused-expressions": 0 + }, + "env": { + "mocha": true + } +} diff --git a/test/e2e/README.md b/test/e2e/README.md new file mode 100644 index 00000000..f1147d08 --- /dev/null +++ b/test/e2e/README.md @@ -0,0 +1,6 @@ +Running e2e tests requires Node 7.6 or later and a dedicated test database. + +1. Start Mailtrain with `npm run startest` +2. Start e2e tests with `npm run e2e` + +By default the tests run with `phantomjs`. To use different browsers see `test/e2e/bin/README.md`. diff --git a/test/e2e/bin/README.md b/test/e2e/bin/README.md new file mode 100644 index 00000000..5edbe684 --- /dev/null +++ b/test/e2e/bin/README.md @@ -0,0 +1,8 @@ +This directory serves for custom browser drivers. + +1. https://seleniumhq.github.io/selenium/docs/api/javascript/ +2. Download a driver of your choice and put it into this directory +3. chmod +x driver +4. Edit config/test.toml + +Current Firefox issue (and patch): https://github.com/mozilla/geckodriver/issues/683 diff --git a/test/e2e/helpers/config.js b/test/e2e/helpers/config.js new file mode 100644 index 00000000..71726ff7 --- /dev/null +++ b/test/e2e/helpers/config.js @@ -0,0 +1,31 @@ +'use strict'; + +const config = require('config'); + +module.exports = { + app: config, + baseUrl: 'http://localhost:' + config.www.port, + users: { + admin: { + username: 'admin', + password: 'test' + } + }, + lists: { + one: { + id: 1, + cid: 'Hkj1vCoJb', + publicSubscribe: 1, + unsubscriptionMode: 0 + } + }, + settings: { + 'service-url' : 'http://localhost:' + config.www.port + '/', + 'default-homepage': 'https://mailtrain.org', + 'smtp-hostname': config.testserver.host, + 'smtp-port': config.testserver.port, + 'smtp-encryption': 'NONE', + 'smtp-user': config.testserver.username, + 'smtp-pass': config.testserver.password + } +}; diff --git a/test/e2e/helpers/driver.js b/test/e2e/helpers/driver.js new file mode 100644 index 00000000..a9b8444b --- /dev/null +++ b/test/e2e/helpers/driver.js @@ -0,0 +1,15 @@ +'use strict'; + +const config = require('./config'); +const webdriver = require('selenium-webdriver'); + +const driver = new webdriver.Builder() + .forBrowser(config.app.seleniumwebdriver.browser || 'phantomjs') + .build(); + +if (global.USE_SHARED_DRIVER === true) { + driver.originalQuit = driver.quit; + driver.quit = () => {}; +} + +module.exports = driver; diff --git a/test/e2e/helpers/exit-unless-test.js b/test/e2e/helpers/exit-unless-test.js new file mode 100644 index 00000000..e53b2eed --- /dev/null +++ b/test/e2e/helpers/exit-unless-test.js @@ -0,0 +1,16 @@ +'use strict'; + +const config = require('./config'); +const log = require('npmlog'); +const path = require('path'); +const fs = require('fs'); + +if (process.env.NODE_ENV !== 'test' || !fs.existsSync(path.join(__dirname, '..', '..', '..', 'config', 'test.toml'))) { + log.error('e2e', 'This script only runs in test and config/test.toml (i.e. a dedicated test database) is present'); + process.exit(1); +} + +if (config.app.testserver.enabled !== true) { + log.error('e2e', 'This script only runs if the testserver is enabled. Check config/test.toml'); + process.exit(1); +} diff --git a/test/e2e/index.js b/test/e2e/index.js new file mode 100644 index 00000000..d78f5819 --- /dev/null +++ b/test/e2e/index.js @@ -0,0 +1,36 @@ +'use strict'; + +require('./helpers/exit-unless-test'); + +global.USE_SHARED_DRIVER = true; + +const driver = require('./helpers/driver'); +const only = 'only'; +const skip = 'skip'; + + + +let tests = [ + ['tests/login'], + ['tests/subscription'] +]; + + + +tests = tests.filter(t => t[1] !== skip); + +if (tests.some(t => t[1] === only)) { + tests = tests.filter(t => t[1] === only); +} + +describe('e2e', function() { + this.timeout(10000); + + tests.forEach(t => { + describe(t[0], () => { + require('./' + t[0]); // eslint-disable-line global-require + }); + }); + + after(() => driver.originalQuit()); +}); diff --git a/test/e2e/page-objects/flash.js b/test/e2e/page-objects/flash.js new file mode 100644 index 00000000..2fd7d8da --- /dev/null +++ b/test/e2e/page-objects/flash.js @@ -0,0 +1,25 @@ +'use strict'; + +const Page = require('./page'); +let flash; + +class Flash extends Page { + getText() { + return this.element('alert').getText(); + } + clear() { + return this.driver.executeScript(` + var elements = document.getElementsByClassName('alert'); + while(elements.length > 0){ + elements[0].parentNode.removeChild(elements[0]); + } + `); + } +} + +module.exports = driver => flash || new Flash(driver, { + elementToWaitFor: 'alert', + elements: { + alert: 'div.alert:not(.js-warning)' + } +}); diff --git a/test/e2e/page-objects/home.js b/test/e2e/page-objects/home.js new file mode 100644 index 00000000..72ad84a7 --- /dev/null +++ b/test/e2e/page-objects/home.js @@ -0,0 +1,12 @@ +'use strict'; + +const Page = require('./page'); +let home; + +module.exports = driver => home || new Page(driver, { + url: '/', + elementToWaitFor: 'body', + elements: { + body: 'body.page--home' + } +}); diff --git a/test/e2e/page-objects/page.js b/test/e2e/page-objects/page.js new file mode 100644 index 00000000..16c2a6cf --- /dev/null +++ b/test/e2e/page-objects/page.js @@ -0,0 +1,61 @@ +'use strict'; + +const config = require('../helpers/config'); +const webdriver = require('selenium-webdriver'); +const By = webdriver.By; +const until = webdriver.until; + +class Page { + constructor(driver, props) { + this.driver = driver; + this.props = props || { + elements: {} + }; + } + + element(key) { + return this.driver.findElement(By.css(this.props.elements[key] || key)); + } + + navigate() { + this.driver.navigate().to(config.baseUrl + this.props.url); + return this.waitUntilVisible(); + } + + waitUntilVisible() { + let selector = this.props.elements[this.props.elementToWaitFor]; + if (!selector && this.props.url) { + selector = 'body.page--' + (this.props.url.substring(1).replace(/\//g, '--') || 'home'); + } + return selector ? this.driver.wait(until.elementLocated(By.css(selector))) : this.driver.sleep(1000); + } + + submit() { + return this.element('submitButton').click(); + } + + click(key) { + return this.element(key).click(); + } + + getText(key) { + return this.element(key).getText(); + } + + getValue(key) { + return this.element(key).getAttribute('value'); + } + + setValue(key, value) { + return this.element(key).sendKeys(value); + } + + containsText(str) { + // let text = await driver.findElement({ css: 'body' }).getText(); + return this.driver.executeScript(` + return (document.documentElement.textContent || document.documentElement.innerText).indexOf('${str}') > -1; + `); + } +} + +module.exports = Page; diff --git a/test/e2e/page-objects/subscription.js b/test/e2e/page-objects/subscription.js new file mode 100644 index 00000000..858dfdf7 --- /dev/null +++ b/test/e2e/page-objects/subscription.js @@ -0,0 +1,84 @@ +'use strict'; + +const config = require('../helpers/config'); +const Page = require('./page'); + +class Web extends Page { + enterEmail(value) { + this.element('emailInput').clear(); + return this.element('emailInput').sendKeys(value); + } +} + +class Mail extends Page { + navigate(address) { + this.driver.sleep(100); + this.driver.navigate().to(`http://localhost:${config.app.testserver.mailboxserverport}/${address}`); + return this.waitUntilVisible(); + } +} + +module.exports = (driver, list) => ({ + + webSubscribe: new Web(driver, { + url: `/subscription/${list.cid}`, + elementToWaitFor: 'form', + elements: { + form: `form[action="/subscription/${list.cid}/subscribe"]`, + emailInput: '#main-form input[name="email"]', + submitButton: 'a[href="#submit"]' + } + }), + + webConfirmSubscriptionNotice: new Web(driver, { + url: `/subscription/${list.cid}/confirm-notice`, + elementToWaitFor: 'homepageButton', + elements: { + homepageButton: `a[href="${config.settings['default-homepage']}"]` + } + }), + + mailConfirmSubscription: new Mail(driver, { + elementToWaitFor: 'confirmLink', + elements: { + confirmLink: `a[href^="${config.settings['service-url']}subscription/subscribe/"]` + } + }), + + webSubscribedNotice: new Web(driver, { + elementToWaitFor: 'homepageButton', + elements: { + homepageButton: 'a[href^="https://mailtrain.org"]' + } + }), + + mailSubscriptionConfirmed: new Mail(driver, { + elementToWaitFor: 'unsubscribeLink', + elements: { + unsubscribeLink: 'a[href*="/unsubscribe/"]', + manageLink: 'a[href*="/manage/"]' + } + }), + + webUnsubscribe: new Web(driver, { + elementToWaitFor: 'submitButton', + elements: { + submitButton: 'a[href="#submit"]' + } + }), + + webUnsubscribedNotice: new Web(driver, { + elementToWaitFor: 'homepageButton', + elements: { + homepageButton: 'a[href^="https://mailtrain.org"]' + } + }), + + mailUnsubscriptionConfirmed: new Mail(driver, { + elementToWaitFor: 'resubscribeLink', + elements: { + resubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}"]` + } + }) + +}); diff --git a/test/e2e/page-objects/users.js b/test/e2e/page-objects/users.js new file mode 100644 index 00000000..b2c17b58 --- /dev/null +++ b/test/e2e/page-objects/users.js @@ -0,0 +1,35 @@ +'use strict'; + +const Page = require('./page'); + +class Login extends Page { + enterUsername(value) { + // this.element('usernameInput').clear(); + return this.element('usernameInput').sendKeys(value); + } + enterPassword(value) { + return this.element('passwordInput').sendKeys(value); + } +} + +module.exports = driver => ({ + + login: new Login(driver, { + url: '/users/login', + elementToWaitFor: 'submitButton', + elements: { + usernameInput: 'form[action="/users/login"] input[name="username"]', + passwordInput: 'form[action="/users/login"] input[name="password"]', + submitButton: 'form[action="/users/login"] [type=submit]' + } + }), + + account: new Page(driver, { + url: '/users/account', + elementToWaitFor: 'emailInput', + elements: { + emailInput: 'form[action="/users/account"] input[name="email"]' + } + }) + +}); diff --git a/test/e2e/tests/login.js b/test/e2e/tests/login.js new file mode 100644 index 00000000..238b5def --- /dev/null +++ b/test/e2e/tests/login.js @@ -0,0 +1,57 @@ +'use strict'; + +const config = require('../helpers/config'); +const expect = require('chai').expect; +const driver = require('../helpers/driver'); +const home = require('../page-objects/home')(driver); +const flash = require('../page-objects/flash')(driver); +const { + login, + account +} = require('../page-objects/users')(driver); + +describe('login', function() { + this.timeout(10000); + + before(() => driver.manage().deleteAllCookies()); + + it('can access home page', async () => { + await home.navigate(); + }); + + it('can not access restricted content', async () => { + driver.navigate().to(config.baseUrl + '/settings'); + flash.waitUntilVisible(); + expect(await flash.getText()).to.contain('Need to be logged in to access restricted content'); + await flash.clear(); + }); + + it('can not login with false credentials', async () => { + login.enterUsername(config.users.admin.username); + login.enterPassword('invalid'); + login.submit(); + flash.waitUntilVisible(); + expect(await flash.getText()).to.contain('Incorrect username or password'); + await flash.clear(); + }); + + it('can login as admin', async () => { + login.enterUsername(config.users.admin.username); + login.enterPassword(config.users.admin.password); + login.submit(); + flash.waitUntilVisible(); + expect(await flash.getText()).to.contain('Logged in as admin'); + }); + + it('can access account page as admin', async () => { + await account.navigate(); + }); + + it('can logout', async () => { + driver.navigate().to(config.baseUrl + '/users/logout'); + flash.waitUntilVisible(); + expect(await flash.getText()).to.contain('logged out'); + }); + + after(() => driver.quit()); +}); diff --git a/test/e2e/tests/subscription.js b/test/e2e/tests/subscription.js new file mode 100644 index 00000000..3360c738 --- /dev/null +++ b/test/e2e/tests/subscription.js @@ -0,0 +1,101 @@ +'use strict'; + +const config = require('../helpers/config'); +const shortid = require('shortid'); +const expect = require('chai').expect; +const driver = require('../helpers/driver'); +const Page = require('../page-objects/page'); + +const page = new Page(driver); +const flash = require('../page-objects/flash')(driver); +const { + webSubscribe, + webConfirmSubscriptionNotice, + mailConfirmSubscription, + webSubscribedNotice, + mailSubscriptionConfirmed, + webUnsubscribe, + webUnsubscribedNotice, + mailUnsubscriptionConfirmed +} = require('../page-objects/subscription')(driver, config.lists.one); + +const testuser = { + email: 'keep.' + shortid.generate() + '@mailtrain.org' +}; + +// console.log(testuser.email); + +describe('subscribe (list one)', function() { + this.timeout(10000); + + before(() => driver.manage().deleteAllCookies()); + + it('visits web-subscribe', async () => { + await webSubscribe.navigate(); + }); + + it('submits invalid email (error)', async () => { + webSubscribe.enterEmail('foo@bar.nope'); + webSubscribe.submit(); + flash.waitUntilVisible(); + expect(await flash.getText()).to.contain('Invalid email address'); + }); + + it('submits valid email', async () => { + webSubscribe.enterEmail(testuser.email); + await webSubscribe.submit(); + }); + + it('sees web-confirm-subscription-notice', async () => { + webConfirmSubscriptionNotice.waitUntilVisible(); + expect(await page.containsText('Almost Finished')).to.be.true; + }); + + it('receives mail-confirm-subscription', async () => { + mailConfirmSubscription.navigate(testuser.email); + expect(await page.containsText('Please Confirm Subscription')).to.be.true; + }); + + it('clicks confirm subscription', async () => { + await mailConfirmSubscription.click('confirmLink'); + }); + + it('sees web-subscribed-notice', async () => { + webSubscribedNotice.waitUntilVisible(); + expect(await page.containsText('Subscription Confirmed')).to.be.true; + }); + + it('receives mail-subscription-confirmed', async () => { + mailSubscriptionConfirmed.navigate(testuser.email); + expect(await page.containsText('Subscription Confirmed')).to.be.true; + }); +}); + +describe('unsubscribe (list one)', function() { + this.timeout(10000); + + it('clicks unsubscribe', async () => { + await mailSubscriptionConfirmed.click('unsubscribeLink'); + }); + + it('sees web-unsubscribe', async () => { + webUnsubscribe.waitUntilVisible(); + expect(await page.containsText('Unsubscribe')).to.be.true; + }); + + it('clicks confirm unsubscription', async () => { + await webUnsubscribe.submit(); + }); + + it('sees web-unsubscribed-notice', async () => { + webUnsubscribedNotice.waitUntilVisible(); + expect(await page.containsText('Unsubscribe Successful')).to.be.true; + }); + + it('receives mail-unsubscription-confirmed', async () => { + mailUnsubscriptionConfirmed.navigate(testuser.email); + expect(await page.containsText('You Are Now Unsubscribed')).to.be.true; + }); + + after(() => driver.quit()); +}); diff --git a/test/frontmail-test.js b/test/nodeunit/frontmail-test.js similarity index 100% rename from test/frontmail-test.js rename to test/nodeunit/frontmail-test.js diff --git a/views/layout.hbs b/views/layout.hbs index 339e8006..73eff28a 100644 --- a/views/layout.hbs +++ b/views/layout.hbs @@ -35,7 +35,7 @@ - +