I need to commit more often on these big rewrites

- Reworked server code
- Reworked poll creator
- Moved a couple of files around
- Cleaned up a lot of code
This commit is contained in:
Schamper 2015-12-20 22:07:48 +01:00
parent f19d411628
commit fdab27dbf0
31 changed files with 1462 additions and 1255 deletions

8
.editorconfig Normal file
View file

@ -0,0 +1,8 @@
root = true

[{*.js, *.css, *.tpl, *.json}]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = false

View file

@ -6,7 +6,6 @@
"reset": "Reset",
"save": "Save",
"toggles": "Toggles",
"allow_guests": "Allow guests to view poll results",
"limits": "Limits",
"max_options": "Maximum number of options per poll",
"defaults": "Defaults",
@ -17,7 +16,6 @@
"vote": "Vote",
"results": "Results",
"vote_count": "users voted for this option",
"results": "Results",
"votes": "votes",
"poll_title": "Poll Title",
"enter_poll_title": "Enter poll title",

View file

View file

View file

View file

@ -1,5 +0,0 @@
var Admin = {

};

module.exports = Admin;

View file

@ -1,275 +0,0 @@
var NodeBB = require('./nodebb'),
db = NodeBB.db,

async = require('async');

//To whoever reads this
//Please help improve
var Backend = {
addPoll: function(pollData, callback) {
db.incrObjectField('global', 'nextPollid', function(err, pollid) {
if (err) {
return callback(err, -1);
}

//These are separately saved, so we need to remove them from the main poll data
var pollOptions = pollData.options,
pollSettings = pollData.settings;

pollData.options = undefined;
pollData.settings = undefined;
pollData.pollid = pollid;

//Build new pollData without the options and settings keys
var poll = {};
for (var p in pollData) {
if (pollData.hasOwnProperty(p) && pollData[p] !== undefined) {
poll[p] = pollData[p];
}
}

//Save all the options to the database
for(var i = 0, l = pollOptions.length; i < l; i++) {
db.setObject('poll:' + pollid + ':options:' + i, pollOptions[i]);
db.setAdd('poll:' + pollid + ':options', i);
}

//Save the poll and settings to the database
db.setObject('poll:' + pollid, poll);
db.setObject('poll:' + pollid + ':settings', pollSettings);
db.listAppend('polls', pollid, function(){});

//Register poll with a topic and post
db.setObjectField('topic:' + poll.tid, 'poll:id', pollid);
db.setObjectField('post:' + poll.pid, 'poll:id', pollid);

//Check if this poll is scheduled to end
if (parseInt(pollSettings.end, 10) > 0) {
Backend.schedulePoll(pollid);
}

return callback(null, pollid);
});
},
getPoll: function(data, callback) {
var pollid = data.pollid,
uid = data.uid || false,
withVotes = (data.anon ? false : !!data.withVotes);

async.parallel({
info: function(next) {
Backend.getPollInfo(pollid, next);
},
options: function(next) {
Backend.getPollOptions(pollid, withVotes, next);
},
settings: function(next) {
Backend.getPollSettings(pollid, next);
},
hasvoted: function(next) {
if (uid) {
Backend.hasUidVoted(uid, pollid, next);
} else {
next(null, false);
}
}
}, callback);
},
getPollIdByTid: function(tid, callback) {
db.getObjectField('topic:' + tid, 'poll:id', callback);
},
getPollIdByPid: function(pid, callback) {
db.getObjectField('post:' + pid, 'poll:id', callback);
},
getPollInfo: function(pollid, callback) {
db.getObject('poll:' + pollid, callback);
},
getPollOptions: function(pollid, withVotes, callback) {
if (typeof withVotes === 'function') {
callback = withVotes;
withVotes = false;
}

db.getSetMembers('poll:' + pollid + ':options', function(err, options) {
async.map(options, function(option, next) {
Backend.getPollOption(pollid, option, withVotes, next);
}, callback);
});
},
getPollOption: function(pollid, option, withVotes, callback) {
async.parallel({
option: function(next) {
db.getObject('poll:' + pollid + ':options:' + option, next);
},
votes: function(next) {
if (withVotes) {
db.getSetMembers('poll:' + pollid + ':options:' + option + ':votes', next);
} else {
next();
}
}
}, function(err, results) {
if (err) {
return callback(err);
}
results.option = results.option || {title: 'option ' + option};
if (results.votes) {
results.option.votes = results.votes;
}
results.option.votecount = results.option.votecount || 0;
callback(null, results.option);
});
},
getPollSettings: function(pollid, callback) {
db.getObject('poll:' + pollid + ':settings', callback);
},
pollHasOption: function(pollid, option, callback) {
db.isSetMember('poll:' + pollid + ':options', option, callback);
},
pollHasOptions: function(pollid, options, callback) {
db.isSetMembers('poll:' + pollid + ':options', options, callback);
},
hasPollEnded: function(pollid, callback) {
Backend.getPollField(pollid, 'ended', function(err, result) {
callback(err, parseInt(result, 10) === 1);
});
},
endPoll: function(pollid) {
db.setRemove('polls:scheduled', pollid);
Backend.setPollField(pollid, 'ended', 1);
},
isPollDeleted: function(pollid, callback) {
Backend.getPollField(pollid, 'deleted', function(err, result) {
callback(err, parseInt(result, 10) === 1);
});
},
deletePoll: function(pollid) {
Backend.setPollField(pollid, 'deleted', 1);
},
restorePoll: function(pollid) {
Backend.setPollField(pollid, 'edited', 0);
Backend.setPollField(pollid, 'deleted', 0);
},
schedulePoll: function(pollid) {
db.setAdd('polls:scheduled', pollid);
require('./utils').scheduler.add(pollid);
},
getScheduledPolls: function(callback) {
db.getSetMembers('polls:scheduled', callback);
},
changePid: function(pollid, pid, callback) {
async.parallel([function(next) {
Backend.setPollField(pollid, 'pid', pid, next);
}, function(next) {
db.setObjectField('post:' + pid, 'poll:id', pollid, next);
}], callback);
},
changeTid: function(pollid, tid, callback) {
async.parallel([function(next) {
Backend.setPollField(pollid, 'tid', tid, next);
}, function(next) {
db.setObjectField('topic:' + tid, 'poll:id', pollid, next);
}], callback);
},
setPollField: function(pollid, field, value, callback) {
db.setObjectField('poll:' + pollid, field, value, callback);
},
setPollFields: function(pollid, fields, values, callback) {
db.setObjectFields('poll:' + pollid, fields, values, callback);
},
getPollField: function(pollid, field, callback) {
db.getObjectField('poll:' + pollid, field, callback);
},
getPollFields: function(pollid, fields, callback) {
db.getObjectFields('poll:' + pollid, fields, callback);
},
/***************************
* Vote methods start here *
***************************/
addVote: function(voteData, callback) {
var pollid = voteData.pollid,
options = voteData.options,
uid = voteData.uid;

async.parallel({
options: function(next) {
async.each(options, function(option, next) {
//Increase option vote count
//next is called here because the option votecount has been updated, it doesn't matter when the uid is added
db.incrObjectField('poll:' + pollid + ':options:' + option, 'votecount', next);
//Add uid to list of votes
db.setAdd('poll:' + pollid + ':options:' + option + ':votes', uid);
}, function(err) {
//Get poll options for callback
Backend.getPollOptions(pollid, next);
});
},
info: function(next) {
//Add uid to poll voters
db.setAdd('poll:' + pollid + ':voters', uid);
//Increase poll vote count
db.incrObjectFieldBy('poll:' + pollid, 'votecount', options.length, function(err, result){
next(err, {
votecount: result
})
});
}
}, callback);
},
removeVote: function(voteData, callback) {
var pollid = voteData.pollid,
options = voteData.options,
uid = voteData.uid;

async.parallel({
options: function(next) {
async.each(options, function(option, next) {
//Decrease option vote count
//next is called here because the option votecount has been updated, it doesn't matter when the uid is added
db.decrObjectField('poll:' + pollid + ':options:' + option, 'votecount', next);
//Remove uid from list of votes
db.setRemove('poll:' + pollid + ':options:' + option + ':votes', uid);
}, function(err) {
//Get poll options for callback
Backend.getPollOptions(pollid, next);
});
},
info: function(next) {
//Remove uid from poll voters
db.setRemove('poll:' + pollid + ':voters', uid);
//Decrease poll vote count
db.decrObjectFieldBy('poll:' + pollid, 'votecount', options.length, function(err, result){
next(err, {
votecount: result
})
});
}
}, callback);
},
canVote: function(voteData, callback) {
async.parallel([
function(next) {
//hasended
Backend.hasPollEnded(voteData.pollid, next);
},
function(next) {
//isdeleted
Backend.isPollDeleted(voteData.pollid, next);
},
function(next) {
//hasvoted
Backend.hasUidVoted(voteData.uid, voteData.pollid, next);
}
], function(err, result) {
callback(err, result.indexOf(true) === -1);
});
},
hasUidVoted: function(uid, pollid, callback) {
db.isSetMember('poll:' + pollid + ':voters', uid, callback);
},
hasUidVotedOnOption: function(uid, pollid, option, callback) {
db.isSetMember('poll:' + pollid + ':options:' + option + ':votes', uid, callback);
}
};

module.exports = Backend;

41
lib/config.js Normal file → Executable file
View file

@ -1,17 +1,19 @@
var NodeBB = require('./nodebb'),
Settings = NodeBB.settings,
"use strict";

pjson = require('../package.json'),
var NodeBB = require('./nodebb'),

packageInfo = require('../package.json'),
pluginInfo = require('../plugin.json'),
pluginId = pluginInfo.id.replace('nodebb-plugin-', ''),

Config = {};

Config.plugin = {
name: 'Poll',
id: 'poll',
version: pjson.version,
description: pjson.description,
icon: 'fa-bar-chart-o',
route: '/poll'
name: pluginInfo.name,
id: pluginId,
version: packageInfo.version,
description: packageInfo.description,
icon: 'fa-bar-chart-o'
};

Config.defaults = {
@ -25,12 +27,27 @@ Config.defaults = {
title: 'Poll',
maxvotes: 1,
end: 0
}
},
version: ''
};

Config.settings = new Settings(Config.plugin.id, Config.plugin.version, Config.defaults);
Config.settings = {};

Config.settingSockets = {
Config.init = function(callback) {
Config.settings = new NodeBB.Settings(Config.plugin.id, Config.plugin.version, Config.defaults, function() {
var oldVersion = Config.settings.get('version');

if (oldVersion < Config.settings.version) {
Config.settings.set('version', Config.plugin.version);
Config.settings.persist();
callback();
} else {
callback();
}
});
};

Config.adminSockets = {
sync: function() {
Config.settings.sync();
},

142
lib/hooks.js Normal file → Executable file
View file

@ -2,79 +2,81 @@ var XRegExp = require('xregexp').XRegExp,

NodeBB = require('./nodebb'),

Backend = require('./backend'),
Poll = require('./poll'),
Serializer = require('./serializer'),
Utils = require('./utils');

//Todo: add hooks for post / topic changes like delete
var Hooks = {
filter: {
postSave: function(postData, callback) {
//Is this the first post?
Utils.isFirstPost(postData.pid, postData.tid, function(err, isFirstPost) {
if (isFirstPost) {
Utils.parsePoll(postData.content, function(err, pollData) {
//Check if there's poll markup
if (pollData && pollData.options.length > 0) {
var poll = Utils.preparePoll(postData, pollData);
(function(Hooks) {

Backend.addPoll(poll, function(err, pollid) {
postData.content = Utils.removeMarkup(postData.content);
//Done
callback(null, postData);
});
} else {
return callback(null, postData);
}
});
} else {
Hooks.filter = {};
Hooks.action = {};

Hooks.filter.parseRaw = function(raw, callback) {
callback(null, Serializer.removeMarkup(raw, '[Poll]'));
};

Hooks.filter.postSave = function(postData, callback) {
// Is this the first post?
Utils.isFirstPost(postData.pid, postData.tid, function(err, isFirstPost) {
if (!isFirstPost) {
return callback(null, postData);
}

Serializer.serialize(postData.content, function(err, pollData) {
//Check if there's poll markup
if (!pollData || !pollData.options.length) {
return callback(null, postData);
}
});
},
getPosts: function(data, callback) {
if (Array.isArray(data.posts) && data.posts.length && data.posts[0] && data.posts[0]['poll:id']) {
//Render the client notification and add to post
Utils.app.render('poll/notify', { pollid: data.posts[0]['poll:id'] }, function(err, html) {
NodeBB.translator.translate(html, function(html) {
data.posts[0].content += html;
return callback(null, data);
});
});
} else {
return callback(null, data);
}
}
},
action: {
postDelete: function(pid) {
Backend.getPollIdByPid(pid, function(err, pollid) {
if (pollid) {
Backend.deletePoll(pollid);
}
});
},
postRestore: function(postData) {
Backend.getPollIdByPid(postData.pid, function(err, pollid) {
if (pollid) {
Backend.restorePoll(pollid);
}
});
},
topicDelete: function(tid) {
Backend.getPollIdByTid(tid, function(err, pollid) {
if (pollid) {
Backend.deletePoll(pollid);
}
});
},
topicRestore: function(tid) {
Backend.getPollIdByTid(tid, function(err, pollid) {
if (pollid) {
Backend.restorePoll(pollid);
}
});
}
}
};

module.exports = Hooks;
Poll.add(pollData, postData, function(err, pollId) {
postData.content = Serializer.removeMarkup(postData.content);
callback(null, postData);
});
});
});
};

Hooks.filter.getPosts = function(data, callback) {
if (!Array.isArray(data.posts) || !data.posts.length || !data.posts[0] || !data.posts[0]['poll:id']) {
return callback(null, data);
}

// Render the client notification and add to post
Utils.app.render('poll/notify', { pollId: data.posts[0]['poll:id'] }, function(err, html) {
data.posts[0].content += html;
return callback(null, data);
});
};

Hooks.action.postDelete = function(pid) {
Poll.getPollIdByPid(pid, function(err, pollId) {
if (pollId) {
Poll.delete(pollId);
}
});
};

Hooks.action.postRestore = function(postData) {
Poll.getPollIdByPid(postData.pid, function(err, pollId) {
if (pollId) {
Poll.restore(pollId);
}
});
};

Hooks.action.topicDelete = function(tid) {
Poll.getPollIdByTid(tid, function(err, pollId) {
if (pollId) {
Poll.delete(pollId);
}
});
};

Hooks.action.topicRestore = function(tid) {
Poll.getPollIdByTid(tid, function(err, pollId) {
if (pollId) {
Poll.restore(pollId);
}
});
};
})(exports);

16
lib/nodebb.js Normal file → Executable file
View file

@ -3,13 +3,13 @@ var NodeBB = {};
(function(parent) {
NodeBB = module.exports = {
db: parent.require('./database'),
settings: parent.require('./settings'),
meta: parent.require('./meta'),
user: parent.require('./user'),
topics: parent.require('./topics'),
pluginSockets: parent.require('./socket.io/plugins'),
adminSockets: parent.require('./socket.io/admin').plugins,
socketIndex: parent.require('./socket.io/index'),
translator: parent.require('../public/src/modules/translator')
Settings: parent.require('./settings'),
Meta: parent.require('./meta'),
User: parent.require('./user'),
Topics: parent.require('./topics'),
PluginSockets: parent.require('./socket.io/plugins'),
AdminSockets: parent.require('./socket.io/admin').plugins,
SocketIndex: parent.require('./socket.io/index'),
Translator: parent.require('../public/src/modules/translator')
};
}(module.parent.parent));

217
lib/poll.js Executable file
View file

@ -0,0 +1,217 @@
"use strict";

var async = require('async'),

NodeBB = require('./nodebb'),

Vote = require('./vote'),
Scheduler = require('./scheduler');

(function(Poll) {

Poll.add = function(pollData, postData, callback) {
NodeBB.db.incrObjectField('global', 'nextPollId', function(err, pollId) {
if (err) {
return callback(err);
}

var poll = {
title: pollData.title,
pollId: pollId,
uid: postData.uid,
tid: postData.tid,
pid: postData.pid,
deleted: 0,
ended: 0,
timestamp: postData.timestamp
};

pollData.options = pollData.options.map(function(val, i) {
return {
id: i,
title: val
};
});

// async this bitch up
async.parallel([
async.apply(async.each, pollData.options, function(option, next) {
async.parallel([
async.apply(NodeBB.db.setObject, 'poll:' + pollId + ':options:' + option.id, option),
async.apply(NodeBB.db.setAdd, 'poll:' + pollId + ':options', option.id)
], next);
}),
async.apply(NodeBB.db.setObject, 'poll:' + pollId, poll),
async.apply(NodeBB.db.setObject, 'poll:' + pollId + ':settings', pollData.settings),
async.apply(NodeBB.db.listAppend, 'polls', pollId),
async.apply(NodeBB.db.setObjectField, 'topic:' + poll.tid, 'pollId', pollId),
async.apply(NodeBB.db.setObjectField, 'post:' + poll.pid, 'pollId', pollId)
], function(err) {
if (err) {
return callback(err);
}

// Check if this poll is scheduled to end
if (parseInt(pollData.settings.end, 10) > 0) {
Poll.schedulePoll(pollId);
}

return callback(null, pollId);
});
});
};

Poll.get = function(data, callback) {
var pollId = data.pollId,
uid = data.uid || false,
withVotes = (data.anon ? false : !!data.withVotes);

async.parallel({
info: function(next) {
Poll.getInfo(pollId, next);
},
options: function(next) {
Poll.getOptions(pollId, withVotes, next);
},
settings: function(next) {
Poll.getSettings(pollId, next);
},
hasVoted: function(next) {
if (uid) {
Vote.hasUidVoted(uid, pollId, next);
} else {
next(null, false);
}
}
}, callback);
};

Poll.getPollIdByTid = function(tid, callback) {
NodeBB.db.getObjectField('topic:' + tid, 'pollId', callback);
};

Poll.getPollIdByPid = function(pid, callback) {
NodeBB.db.getObjectField('post:' + pid, 'pollId', callback);
};

Poll.getInfo = function(pollId, callback) {
NodeBB.db.getObject('poll:' + pollId, callback);
};

Poll.getOptions = function(pollId, withVotes, callback) {
if (typeof withVotes === 'function') {
callback = withVotes;
withVotes = false;
}

NodeBB.db.getSetMembers('poll:' + pollId + ':options', function(err, options) {
async.map(options, function(option, next) {
Poll.getOption(pollId, option, withVotes, next);
}, callback);
});
};

Poll.getOption = function(pollId, option, withVotes, callback) {
async.parallel({
option: async.apply(NodeBB.db.getObject, 'poll:' + pollId + ':options:' + option),
votes: function(next) {
if (withVotes) {
NodeBB.db.getSetMembers('poll:' + pollId + ':options:' + option + ':votes', next);
} else {
next();
}
}
}, function(err, results) {
if (err) {
return callback(err);
}

if (results.votes) {
results.option.votes = results.votes;
}
results.option.voteCount = results.option.voteCount || 0;

callback(null, results.option);
});
};

Poll.hasOption = function(pollId, option, callback) {
NodeBB.db.isSetMember('poll:' + pollId + ':options', option, callback);
};

Poll.hasOptions = function(pollId, options, callback) {
NodeBB.db.isSetMembers('poll:' + pollId + ':options', options, callback);
};

Poll.getSettings = function(pollId, callback) {
NodeBB.db.getObject('poll:' + pollId + ':settings', callback);
};

Poll.isDeleted = function(pollId, callback) {
Poll.getField(pollId, 'deleted', function(err, result) {
callback(err, parseInt(result, 10) === 1);
});
};

Poll.delete = function(pollId) {
Poll.setField(pollId, 'deleted', 1);
};

Poll.restore = function(pollId) {
Poll.setPollField(pollId, 'edited', 0);
Poll.setPollField(pollId, 'deleted', 0);
};

Poll.schedule = function(pollId) {
NodeBB.db.setAdd('polls:scheduled', pollId);
Scheduler.add(pollId);
};

Poll.getScheduled = function(callback) {
NodeBB.db.getSetMembers('polls:scheduled', callback);
};

Poll.hasEnded = function(pollId, callback) {
Poll.getField(pollId, 'ended', function(err, result) {
callback(err, parseInt(result, 10) === 1);
});
};

Poll.end = function(pollId) {
NodeBB.db.setRemove('polls:scheduled', pollId);
Poll.setField(pollId, 'ended', 1);
};

Poll.changePid = function(pollId, pid, callback) {
async.parallel([function(next) {
Poll.setField(pollId, 'pid', pid, next);
}, function(next) {
NodeBB.db.setObjectField('post:' + pid, 'pollId', pollId, next);
}], callback);
};

Poll.changeTid = function(pollId, tid, callback) {
async.parallel([function(next) {
Poll.setField(pollId, 'tid', tid, next);
}, function(next) {
NodeBB.db.setObjectField('topic:' + tid, 'polIid', pollId, next);
}], callback);
};

Poll.setField = function(pollId, field, value, callback) {
NodeBB.db.setObjectField('poll:' + pollId, field, value, callback);
};

Poll.setFields = function(pollId, fields, values, callback) {
NodeBB.db.setObjectFields('poll:' + pollId, fields, values, callback);
};

Poll.getField = function(pollId, field, callback) {
NodeBB.db.getObjectField('poll:' + pollId, field, callback);
};

Poll.getFields = function(pollId, fields, callback) {
NodeBB.db.getObjectFields('poll:' + pollId, fields, callback);
};

})(exports);

54
lib/scheduler.js Executable file
View file

@ -0,0 +1,54 @@
"use strict";

var Poll = require('./poll'),

jobs = {},

Scheduler = {};

Scheduler.start = function() {
Poll.getScheduled(function(err, pollIds) {
pollIds.forEach(function(pollId) {
Scheduler.add(pollId);
});
});
};

Scheduler.add = function(pollId) {
if (Object.keys(jobs).indexOf(pollId.toString()) !== -1) {
return;
}

Poll.getSettings(pollId, function(err, settings) {
if (err) {
return console.log(err.stack);
}
if (!settings) {
return console.log('Poll ID ' + pollId + ' has no settings!');
}

var now = Date.now(),
end = parseInt(settings.end, 10);

if (end < now) {
Scheduler.end(pollId);
} else {
jobs[pollId] = new cron(new Date(end), function() {
Scheduler.end(pollId);
}, null, true);
}
});
};

Scheduler.end = function(pollId) {
var index = Object.keys(jobs).indexOf(pollId.toString());

if (index !== -1 && jobs[pollId] !== undefined) {
jobs[pollId].stop();
delete jobs[pollId];
}

Poll.end(pollId);
};

module.exports = Scheduler;

3
lib/serializer.js Executable file
View file

@ -0,0 +1,3 @@
"use strict";

module.exports = require('../public/js/poll/serializer');

169
lib/sockets.js Normal file → Executable file
View file

@ -1,85 +1,100 @@
"use strict";

var async = require('async'),

Backend = require('./backend'),
Config = require('./config'),

NodeBB = require('./nodebb'),
SocketIndex = NodeBB.socketIndex,
User = NodeBB.user;

var Sockets = {
load: function(socket, data, callback) {
Config = require('./config'),
Poll = require('./poll'),
Vote = require('./vote');

(function(Sockets) {

Sockets.load = function(socket, data, callback) {
var allowAnon = Config.settings.get('toggles.allowAnon');
if (socket.uid || allowAnon) {
if (data && data.pollid) {
data.uid = socket.uid;
data.anon = (!socket.uid && allowAnon);
Backend.getPoll(data, function(err, result) {
callback(err, result);
});
} else {
callback(new Error('Invalid poll request'));

if (!socket.uid && !allowAnon) {
return callback(new Error('Not logged in'));
}

if (!data || !data.pollid) {
return callback(new Error('Invalid poll request'));
}

data.uid = socket.uid;
data.anon = (!socket.uid && allowAnon);

Poll.get(data, function(err, result) {
callback(err, result);
});
};

Sockets.vote = function(socket, data, callback) {
if (!socket.uid || !data || isNaN(parseInt(data.pollId, 10)) || !data.options || !data.options.length) {
return callback(new Error('Invalid vote'));
}

data.uid = socket.uid;

async.parallel({
canVote: function(next) {
Vote.canVote(data.uid, data.pollId, next);
},
optionsFilter: function(next) {
Poll.hasOptions(data.pollid, data.options, next);
},
settings: function(next) {
Poll.getSettings(data.pollid, next);
}
} else {
callback(new Error('Not logged in'));
}
},
vote: function(socket, data, callback) {
if (socket.uid && data && !isNaN(parseInt(data.pollid, 10)) && data.options && data.options.length > 0) {
data.uid = socket.uid;
async.parallel({
canvote: function(next) {
Backend.canVote(data, next);
},
optionsFilter: function(next) {
Backend.pollHasOptions(data.pollid, data.options, next);
},
settings: function(next) {
Backend.getPollSettings(data.pollid, next);
}
}, function(err, result) {
//Filter the options on their existence, then slice out the max allowed votes
data.options = data.options.filter(function(el, index) {
return result.optionsFilter[index];
})/*.slice(0, result.settings.maxvotes);*/

//Instead of slicing we'll give them an error
if (data.options.length > parseInt(result.settings.maxvotes, 10)) {
callback(new Error('You can only vote for ' + result.settings.maxvotes + ' options on this poll!'));
} else {
if (!err && (result.canvote && data.options.length > 0)) {
Backend.addVote(data, function(err, result) {
result.pollid = data.pollid;
SocketIndex.server.sockets.emit('event:poll.votechange', result);
callback();
});
} else {
callback(new Error('Already voted or invalid option'));
}
}
}, function(err, result) {
// Filter the options on their existence
data.options = data.options.filter(function(el, index) {
return result.optionsFilter[index];
});
} else {
callback(new Error('Invalid vote'));
}
},
optionDetails: function(socket, data, callback) {
if (socket.uid && data && !isNaN(parseInt(data.pollid, 10)) && !isNaN(parseInt(data.option, 10))) {
Backend.getPollOption(data.pollid, data.option, true, function(err, result) {
if (err) {
callback(new Error('Something went wrong!'));
} else if (result.votes && result.votes.length > 0) {
User.getMultipleUserFields(result.votes, ['username', 'userslug', 'picture'], function(err, userData) {
result.votes = userData;
callback(null, result);
})
} else {
callback(null, result);
}
});
} else {
callback(new Error('Invalid request'));
}
}
};

module.exports = Sockets;
// Give an error if there are too many votes
if (data.options.length > parseInt(result.settings.maxvotes, 10)) {
return callback(new Error('You can only vote for ' + result.settings.maxvotes + ' options on this poll.'));
}

if (err || !result.canVote || !data.options.length) {
return callback(new Error('Already voted or invalid option'));

}

Vote.add(data, function(err, result) {
result.pollId = data.pollId;

NodeBB.SocketIndex.server.sockets.emit('event:poll.voteChange', result);

callback();
});
});
};

Sockets.getOptionDetails = function(socket, data, callback) {
if (!socket.uid || !data || isNaN(parseInt(data.pollId, 10)) || isNaN(parseInt(data.option, 10))) {
return callback(new Error('Invalid request'));
}

Poll.getOption(data.pollid, data.option, true, function(err, result) {
if (err) {
return callback(new Error('Something went wrong!'));
}

if (!result.votes || !result.votes.length) {
return callback(null, result);
}

NodeBB.User.getMultipleUserFields(result.votes, ['username', 'userslug', 'picture'], function(err, userData) {
result.votes = userData;
callback(null, result);
})
});
};

Sockets.getConfig = function(socket, data, callback) {
callback(null, Config.settings.get());
};

})(exports);

196
lib/utils.js Normal file → Executable file
View file

@ -1,198 +1,20 @@
var S = require('string'),
XRegExp = require('xregexp').XRegExp,
async = require('async'),
cron = require('cron').CronJob,
"use strict";

NodeBB = require('./nodebb'),
db = NodeBB.db,
fs = module.parent.parent.require('fs'),
path = module.parent.parent.require('path'),
var NodeBB = require('./nodebb');

Config = require('./config'),
Backend = require('./backend'),
(function(Utils) {

pollRegex = XRegExp('(?:(?:\\[poll(?<settings>.*?)\\])\n(?<content>(?:-.+?\n)+)(?:\\[\/poll\\]))', 'g'),
pollSettingsRegex = XRegExp('(?<key>.+?)="(?<value>.+?)"', 'g'),
translations,
Utils.app = null;

pollSettingsMap = {
max: {
key: 'maxvotes',
test: function(value) {
return !isNaN(value);
}
},
title: {
key: 'title',
test: function(value) {
return value.length > 0;
}
},
end: {
key: 'end',
test: function(value) {
return (!isNaN(value) && parseInt(value, 10) > Date.now());
}
}
};

var Utils = {
app: null,
hasPoll: function(post) {
return XRegExp.exec(post, pollRegex) !== null;
},
parsePoll: function(post, callback) {
var match = XRegExp.exec(post, pollRegex);
if (match !== null) {
async.parallel({
options: function(next) {
Utils.parseOptions(match.content, next);
},
settings: function(next) {
Utils.parseSettings(match.settings, next);
}
}, callback);
} else {
callback(null, null);
}
},
parseOptions: function(raw, callback) {
var maxOptions = Config.settings.get('limits.maxOptions'),
pollOptions = [],
rawOptions = S(raw).stripTags().s.split('\n');

for (var i = 0, l = rawOptions.length; i < l; i++) {
if (rawOptions[i].length > 0) {
var option = S(rawOptions[i].split('-')[1]).trim().s;
if (option.length > 0) {
pollOptions.push(option);
}
}
}

if (pollOptions.length > maxOptions) {
pollOptions = pollOptions.slice(0, maxOptions - 1);
}

callback(null, pollOptions);
},
parseSettings: function(raw, callback) {
var pollSettings = Config.settings.get('defaults');

raw = S(raw).stripTags().s;

callback(null, XRegExp.forEach(raw, pollSettingsRegex, function(match) {
var key = S(match.key).trim().s,
value = S(match.value).trim().s;

if (key.length > 0 && value.length > 0 && pollSettingsMap.hasOwnProperty(key)) {
if (pollSettingsMap[key].test(value)) {
this[pollSettingsMap[key].key] = value;
}
}
}, pollSettings));
},
preparePoll: function(postData, pollData) {
return {
title: pollData.title,
uid: postData.uid,
tid: postData.tid,
pid: postData.pid,
deleted: 0,
ended: 0,
timestamp: postData.timestamp,
settings: pollData.settings,
options: pollData.options.map(function(val, i) {
return {
id: i,
title: val
};
})
};
},
isFirstPost: function(pid, tid, callback) {
//Check if topic is empty or if post is first post
db.getSortedSetRange('tid:' + tid + ':posts', 0, 0, function(err, pids) {
Utils.isFirstPost = function(pid, tid, callback) {
// Check if topic is empty or if post is first post
NodeBB.db.getSortedSetRange('tid:' + tid + ':posts', 0, 0, function(err, pids) {
if(err) {
return callback(err);
}

callback(null, pids.length === 0 || parseInt(pids[0], 10) === parseInt(pid, 10));
});
},
removeMarkup: function(content) {
return XRegExp.replace(content, pollRegex, '');
},
scheduler: {
init: function() {
Backend.getScheduledPolls(function(err, pollids) {
if (pollids.length > 0) {
for (var i = 0, l = pollids.length; i < l; i++) {
Utils.scheduler.add(pollids[i]);
}
}
});
},
add: function(pollid) {
if (Object.keys(Utils.scheduler.jobs).indexOf(pollid.toString()) !== -1) {
return;
}
};

Backend.getPollSettings(pollid, function(err, settings) {
if (err) {
return console.log(err.stack);
}
if (!settings) {
return console.log('Poll ID - ' + pollid + ' has no settings!');
}
var now = Date.now(),
end = parseInt(settings.end, 10);
if (end < now) {
Utils.scheduler.end(pollid);
} else {
Utils.scheduler.jobs[pollid] = new cron(new Date(end), function() {
Utils.scheduler.end(pollid);
}, null, true);
}
});
},
end: function(pollid) {
var index = Object.keys(Utils.scheduler.jobs).indexOf(pollid.toString());
if (index !== -1 && Utils.scheduler.jobs[pollid] !== undefined) {
Utils.scheduler.jobs[pollid].stop();
Utils.scheduler.jobs[pollid] = undefined;
}
Backend.endPoll(pollid);
},
jobs: {}
},
loadTranslations: function(){
Utils.translations = { };
var languagesPath = path.resolve(__dirname, '../public/language');

var langs = fs.readdirSync(languagesPath);
for (var l in langs) {
var lang = langs[l],
langPath = languagesPath + '/' + lang;

if (fs.lstatSync(langPath).isDirectory()) {
var files = fs.readdirSync(langPath);
for (var f in files) {
var file = files[f],
filePath = langPath + '/' + file;

if (!fs.lstatSync(filePath).isDirectory() && file.slice(-5) === '.json') {
try {
Utils.translations[langs[l]] = JSON.parse(fs.readFileSync(filePath), 'utf8');
NodeBB.translator.addTranslation(lang, file.slice(0, -5), Utils.translations[langs[l]]);
}catch (e){
console.log("Poll: Error reading " + filePath + ": " + e);
}
}
}
}
}
}
};

module.exports = Utils;
})(exports);

106
lib/vote.js Executable file
View file

@ -0,0 +1,106 @@
"use strict";

var async = require('async'),

NodeBB = require('./nodebb'),

Poll = require('./poll');

(function(Vote) {

Vote.add = function(voteData, callback) {
var pollId = voteData.pollId,
options = voteData.options,
uid = voteData.uid;

async.parallel({
options: function(next) {
async.each(options, function(option, next) {
async.parallel([
// Increase option vote count
async.apply(NodeBB.db.incrObjectField, 'poll:' + pollId + ':options:' + option),
// Add uid to list of votes
async.apply(NodeBB.db.setAdd, 'poll:' + pollId + ':options:' + option + ':votes', uid)
], next);
}, function(err) {
// Get poll options for callback
Poll.getOptions(pollId, next);
});
},
info: function(next) {
async.parallel([
// Add uid to poll voters
async.apply(NodeBB.db.setAdd, 'poll:' + pollId + ':voters', uid),
// Increase poll vote count
async.apply(NodeBB.db.incrObjectFieldBy, 'poll:' + pollId, 'votecount', options.length)
], function(err, result) {
next(err, {
voteCount: result[1]
})
});
}
}, callback);
};

Vote.remove = function(voteData, callback) {
var pollId = voteData.pollId,
options = voteData.options,
uid = voteData.uid;

async.parallel({
options: function(next) {
async.each(options, function(option, next) {
async.parallel([
// Decrease option vote count
async.apply(NodeBB.db.decrObjectField, 'poll:' + pollId + ':options:' + option, 'votecount'),
// Remove uid from list of votes
async.apply(NodeBB.db.setRemove, 'poll:' + pollId + ':options:' + option + ':votes', uid)
], next);
}, function(err) {
//Get poll options for callback
Poll.getOptions(pollId, next);
});
},
info: function(next) {
async.parallel([
// Remove uid from poll voters
async.apply(NodeBB.db.setRemove, 'poll:' + pollId + ':voters', uid),
// Decrease poll vote count
async.apply(NodeBB.db.decrObjectFieldBy, 'poll:' + pollId, 'votecount', options.length)
], function(err, result) {
next(err, {
voteCount: result[1]
})
});
}
}, callback);
};

Vote.canVote = function(uid, pollId, callback) {
async.parallel([
function(next) {
// Ended?
Poll.hasEnded(pollId, next);
},
function(next) {
// Deleted?
Poll.isDeleted(pollId, next);
},
function(next) {
// Already voted?
Vote.hasUidVoted(uid, pollId, next);
}
], function(err, result) {
callback(err, result.indexOf(true) === -1);
});
};

Vote.hasUidVoted = function(uid, pollId, callback) {
NodeBB.db.isSetMember('poll:' + pollId + ':voters', uid, callback);
};

Vote.hasUidVotedOnOption = function(uid, pollId, option, callback) {
NodeBB.db.isSetMember('poll:' + pollId + ':options:' + option + ':votes', uid, callback);
};

})(exports);

64
library.js Normal file → Executable file
View file

@ -1,47 +1,41 @@
"use strict";

var NodeBB = require('./lib/nodebb'),
Config = require('./lib/config'),
Sockets = require('./lib/sockets'),
Hooks = require('./lib/hooks'),
Utils = require('./lib/utils'),
Admin = require('./lib/admin'),
Scheduler = require('./lib/scheduler'),
Utils = require('./lib/utils');

PluginSockets = NodeBB.pluginSockets,
AdminSockets = NodeBB.adminSockets,
(function(Plugin) {

app;
Plugin.hooks = Hooks;

var Poll = {};

Poll.init = {
load: function(data, callback) {
app = data.app;
Plugin.load = function(params, callback) {
function renderAdmin(req, res, next) {
//Config.api(function(data) {
res.render('poll/admin', {});
//});
res.render('admin/plugins/' + Config.plugin.id, {});
}
Utils.loadTranslations();
data.router.get('/admin/poll', data.middleware.admin.buildHeader, renderAdmin);
data.router.get('/api/admin/poll', renderAdmin);
PluginSockets.poll = Sockets;
AdminSockets.poll = Config.settingSockets;
Utils.app = data.router;
Utils.scheduler.init();
callback();
},
admin: {
addNavigation: function(custom_header, callback) {
custom_header.plugins.push({
route: Config.plugin.route,
icon: Config.plugin.icon,
name: Config.plugin.name
});

callback(null, custom_header);
}
}
};
params.router.get('/admin/plugins/' + Config.plugin.id, params.middleware.admin.buildHeader, renderAdmin);
params.router.get('/api/admin/plugins/' + Config.plugin.id, renderAdmin);

Poll.hooks = Hooks;
NodeBB.PluginSockets[Config.plugin.id] = Sockets;
NodeBB.AdminSockets[Config.plugin.id] = Config.adminSockets;

module.exports = Poll;
Utils.app = params.app;
Scheduler.start();

Config.init(callback);
};

Plugin.addAdminNavigation = function(adminHeader, callback) {
adminHeader.plugins.push({
route: '/plugins/' + Config.plugin.id,
icon: Config.plugin.icon,
name: Config.plugin.name
});

callback(null, adminHeader);
};

})(exports);

8
package.json Normal file → Executable file
View file

@ -21,10 +21,10 @@
"url": "https://github.com/Schamper/nodebb-plugin-poll/issues"
},
"dependencies": {
"async": "~0.2.9",
"string": "~1.8.0",
"xregexp": "~2.0.0",
"cron": "~1.0.4"
"async": "~1.5.0",
"string": "~3.3.1",
"xregexp": "~3.0.0",
"cron": "~1.0.9"
},
"nbbpm": {
"compatibility": "^0.7.0 || ^0.8.0 || ^0.9.0"

20
plugin.json Normal file → Executable file
View file

@ -3,10 +3,11 @@
"name": "Poll",
"description": "NodeBB Poll Plugin",
"url": "https://github.com/Schamper/nodebb-plugin-poll",
"library": "./library.js",
"library": "library.js",
"hooks": [
{ "hook": "static:app.load", "method": "init.load" },
{ "hook": "filter:admin.header.build", "method": "init.admin.addNavigation" },
{ "hook": "static:app.load", "method": "load" },
{ "hook": "filter:admin.header.build", "method": "addAdminNavigation" },
{ "hook": "filter:parse.raw", "method": "hooks.filter.parseRaw", "priority": 1 },
{ "hook": "filter:post.save", "method": "hooks.filter.postSave" },
{ "hook": "filter:post.getPosts", "method": "hooks.filter.getPosts" },
{ "hook": "action:post.delete", "method": "hooks.action.postDelete" },
@ -15,20 +16,23 @@
{ "hook": "action:topic.restore", "method": "hooks.action.topicRestore" }
],
"staticDirs": {
"public": "./public"
"public": "public"
},
"less": [
"public/less/style.less",
"public/less/vendor/bootstrap-datetimepicker.less"
],
"scripts": [
"public/js/admin.js",
"public/js/poll/main.js",
"public/js/poll/serializer.js",
"public/js/poll/sockets.js",
"public/js/poll/view.js",
"public/js/poll/creator.js",
"public/js/vendor/moment.min.js",
"public/js/vendor/bootstrap-datetimepicker.min.js"
"public/js/vendor/bootstrap-datetimepicker.min.js",
"public/js/vendor/jquery.deserialize.min.js",
"public/js/vendor/jquery.serialize-object.min.js"
],
"templates": "./templates",
"languages": "public/language"
"templates": "templates",
"languages": "languages"
}

42
public/js/admin.js Executable file
View file

@ -0,0 +1,42 @@
'use strict';
/* globals $, app, socket, define, bootbox */

define('admin/plugins/poll', ['settings'], function(Settings) {
var wrapper;

var ACP = {};

ACP.init = function() {
wrapper = $('.poll-settings');

Settings.sync('poll', wrapper);

$('#save').on('click', function() {
save();
});

$('#reset').click(function() {
reset();
});
};

function save() {
Settings.persist('poll', wrapper, function() {
socket.emit('admin.plugins.poll.sync');
});
}

function reset() {
bootbox.confirm('Are you sure you wish to reset the settings?', function(sure) {
if (sure) {
socket.emit('admin.plugins.poll.getDefaults', null, function (err, data) {
Settings.set('poll', data, wrapper, function(){
socket.emit('admin.plugins.poll.sync');
});
});
}
});
}

return ACP;
});

284
public/js/poll/creator.js Normal file → Executable file
View file

@ -1,139 +1,185 @@
"use strict";
/* globals $, app, bootbox, define */

(function(Poll) {
var S,
settings = {
max: {
test: function(value) {
return !isNaN(value);
}
},
title: {
test: function(value) {
return value.length > 0;
}
},
end: {
test: function(value) {
return moment(value).isValid();
},
parse: function(value) {
return moment(value).valueOf();
}
}
};

//Todo: load settings (like option limit) from server
var Creator = {};
var config;

function initialise() {
require(['composer', 'string'], function(composer, String) {
S = String;
composer.addButton('fa fa-bar-chart-o', Poll.creator.show);
function init() {
require(['composer'], function(composer) {
composer.addButton('fa fa-bar-chart-o', Creator.show);
});
}

initialise();
Creator.show = function(textarea) {
Poll.sockets.getConfig(null, function(err, c) {
config = c;

Poll.creator = {
show: function(textarea) {
window.templates.parse('poll/creator', {}, function(html) {
require(['translator'], function(translator) {
translator.translate(html, config.userLang, function(html) {
bootbox.dialog({
title: 'Create a poll',
message: html,
className: 'poll-creator',
buttons: {
cancel: {
label: 'Cancel',
className: 'btn-default',
callback: function(e) {
return Poll.creator.cancel(e, textarea);
}
},
save: {
label: 'Done',
className: 'btn-primary',
callback: function(e) {
return Poll.creator.save(e, textarea);
}
}
}
}).find('#pollInputEnd').datetimepicker({
useSeconds: false,
useCurrent: false,
minDate: new Date(),
icons: {
time: "fa fa-clock-o",
date: "fa fa-calendar",
up: "fa fa-arrow-up",
down: "fa fa-arrow-down"
}
});
});
});
});
},
cancel: function(e, textarea) {
return true;
},
save: function(e, textarea) {
var modal = $(e.currentTarget).parents('.bootbox'),
errorBox = modal.find('#pollErrorBox');
var poll = {};

errorBox.addClass('hidden').html('');
// If there's already a poll in the post, serialize it for editing
if (Poll.serializer.canSerialize(textarea.value)) {
poll = Poll.serializer.serialize(textarea.value, config);

var result = Creator.parse(modal);
if (result.err) {
return Poll.creator.error(errorBox, result.err);
} else {
if (textarea.value.charAt(textarea.value.length - 1) !== '\n') {
result.markup = '\n' + result.markup;
if (poll.settings.end === 0) {
delete poll.settings.end;
} else {
poll.settings.end = parseInt(poll.settings.end, 10);
}
textarea.value += result.markup;
return true;
}
},
error: function(errorBox, message) {
errorBox.removeClass('hidden');
errorBox.append(message + '<br>');
return false;
}

showModal(poll, config, textarea);
});
};

var Creator = {
parse: function(modal) {
var options = S(modal.find('#pollInputOptions').val()).stripTags().s.split('\n').filter(function(o) {
return o.length == 0 ? false : o;
}),
settingMarkup = '',
result = {
err: null,
markup: null
};

if (options.length == 0) {
result.err = 'Create at least one option!';
return result;
}

for (var s in settings) {
if (settings.hasOwnProperty(s)) {
var value = S(modal.find('[data-poll-setting="' + s + '"]').val()).stripTags().trim().s;
if (value.length > 0 && settings[s].test(value)) {
if (typeof settings[s].parse === 'function') {
value = settings[s].parse(value);
function showModal(poll, config, textarea) {
app.parseAndTranslate('poll/creator', { poll: poll, config: config }, function(html) {
// Initialise modal
var modal = bootbox.dialog({
title: 'Create a poll',
message: html,
className: 'poll-creator',
buttons: {
cancel: {
label: 'Cancel',
className: 'btn-default',
callback: function() {
return true
}
},
save: {
label: 'Done',
className: 'btn-primary',
callback: function(e) {
return save(e, textarea);
}
settingMarkup += ' ' + s + '="' + value + '"';
}
}
}
});

result.markup = '[poll' + settingMarkup + ']\n';
for (var i = 0, l = options.length; i < l; i++) {
result.markup += '- ' + options[i] + '\n';
}
result.markup += '[/poll]\n';
// Add option adder
modal.find('#pollAddOption')
.off('click')
.on('click', function(e) {
var el = $(e.currentTarget);
var prevOption = el.prev();

return result;
if (config.limits.maxOptions <= el.prevAll('input').length) {
clearErrors();
return error('You can only create ' + config.limits.maxOptions + ' options.');
}

if (prevOption.val().length != 0) {
prevOption.clone().val('').insertBefore(el).focus();
}
});

var datetimepicker = modal.find('#pollInputEnd')
.datetimepicker({
sideBySide: true,
showClear: true,
useCurrent: true,
ignoreReadonly: true,
allowInputToggle: true,
toolbarPlacement: 'top',
minDate: moment().add(5, 'minutes'),
icons: {
time: "fa fa-clock-o",
date: "fa fa-calendar",
up: "fa fa-chevron-up",
down: "fa fa-chevron-down",
previous: 'fa fa-chevron-left',
next: 'fa fa-chevron-right',
today: 'fa fa-calendar',
clear: 'fa fa-trash-o',
close: 'fa fa-times'
}
}).data('DateTimePicker');

if (poll.settings.end) {
datetimepicker.date(moment(poll.settings.end));
} else {
datetimepicker.clear();
}
});
}

function save(e, textarea) {
clearErrors();

var form = $(e.currentTarget).parents('.bootbox').find('#pollCreator');
var obj = form.serializeObject();

// Let's be nice and at least show an error if there are no options
obj.options.filter(function(obj) {
return obj.length;
});

if (obj.options.length == 0) {
return error('Create at least one option.');
}
};

if (obj.settings.end && !moment(obj.settings.end).isValid()) {
return error('Please enter a valid date.');
} else if (obj.settings.end) {
obj.settings.end = moment(obj.settings.end).valueOf();
}

// Anything invalid will be discarded by the serializer
var markup = Poll.serializer.deserialize(obj, config);

// Remove any existing poll markup
textarea.value = Poll.serializer.removeMarkup(textarea.value);

// Insert the poll markup at the bottom
if (textarea.value.charAt(textarea.value.length - 1) !== '\n') {
markup = '\n' + markup;
}

textarea.value += markup;

return true;
}

function error(message) {
var errorBox = $('#pollErrorBox');

errorBox.removeClass('hidden');
errorBox.append(message + '<br>');

return false;
}

function clearErrors() {
$('#pollErrorBox').addClass('hidden').html('');
}

function dumbifyObject(obj) {
var result = {};

for (var key in obj) {
if (obj.hasOwnProperty(key)) {
var val = obj[key];

if (jQuery.isPlainObject(val)) {
var obj1 = dumbifyObject(val);
for (var k1 in obj1) {
if (obj1.hasOwnProperty(k1)) {
result[key + '.' + k1] = obj1[k1];
}
}
} else {
result[key] = val;
}
}
}

return result;
}

Poll.creator = Creator;

init();

})(window.Poll);

42
public/js/poll/main.js Normal file → Executable file
View file

@ -1,20 +1,26 @@
"use strict";

var Poll = {};

(function() {
window.Poll = {
load: function(pollid) {
Poll.sockets.emit.load(pollid, function(err, poll) {
if (!err) {
Poll.view.init(poll, function(pollView) {
if (parseInt(poll.info.deleted, 10) === 1 || parseInt(poll.info.ended, 10) === 1) {
Poll.view.showMessage({
title: 'Voting unavailable',
content: 'This poll has ended or has been marked as deleted. You can still view the results.'
}, pollView);
}
});
} else if (err.message != 'Not logged in') {
app.alertError('Something went wrong while getting the poll!');
}
});
}

Poll.load = function(pollId) {
console.log(pollId);

//Poll.sockets.emit.load(pollid, function(err, poll) {
// if (!err) {
// Poll.view.init(poll, function(pollView) {
// if (parseInt(poll.info.deleted, 10) === 1 || parseInt(poll.info.ended, 10) === 1) {
// Poll.view.showMessage({
// title: 'Voting unavailable',
// content: 'This poll has ended or has been marked as deleted. You can still view the results.'
// }, pollView);
// }
// });
// } else if (err.message != 'Not logged in') {
// app.alertError('Something went wrong while getting the poll!');
// }
//});
};
})();

})();

View file

@ -0,0 +1,150 @@
"use strict";
/* globals require */

(function(module) {

var XRegExp, S;
var Serializer = {};

if ('undefined' === typeof window) {
XRegExp = require('xregexp').XRegExp;
} else {
XRegExp = window.XRegExp;
require(['string'], function(string){ S = string });
}

var pollRegex = XRegExp('(?:(?:\\[poll(?<settings>.*?)\\])\n(?<content>(?:-.+?\n)+)(?:\\[\/poll\\]))', 'g');
var settingsRegex = XRegExp('(?<key>.+?)="(?<value>.+?)"', 'g');
var settingsValidators = {
title: {
test: function (value) {
return value.length > 0;
},
parse: function(value) {
return S(value).stripTags().trim().s;
}
},
maxvotes: {
test: function (value) {
return !isNaN(value);
},
parse: function(value) {
return parseInt(value, 10);
}
},
end: {
test: function (value) {
return (!isNaN(value) && parseInt(value, 10) > Date.now());
},
parse: function(value) {
return parseInt(value, 10);
}
}
};

Serializer.canSerialize = function(post) {
return XRegExp.exec(post, pollRegex) !== null;
};

Serializer.removeMarkup = function(content, replace) {
return XRegExp.replace(content, pollRegex, replace || '');
};

Serializer.serialize = function(post, config) {
var match = XRegExp.exec(post, pollRegex);

return {
options: serializeOptions(match.content, config),
settings: serializeSettings(match.settings, config)
};
};

Serializer.deserialize = function(poll, config) {
var options = deserializeOptions(poll.options, config);
var settings = deserializeSettings(poll.settings, config);

return '[poll' + settings +']\n' + options + '\n[/poll]';
};

function serializeOptions(raw, config) {
var pollOptions = [];
var rawOptions = S(raw).stripTags().s.split('\n');
var maxOptions = parseInt(config.limits.maxOptions, 10);

rawOptions.forEach(function(option) {
if (option.length) {
option = S(option.split('-')[1]).trim().s;

if (option.length) {
pollOptions.push(option);
}
}
});

if (pollOptions.length > maxOptions) {
pollOptions = pollOptions.slice(0, maxOptions - 1);
}

return pollOptions;
}

function deserializeOptions(options, config) {
var maxOptions = config.limits.maxOptions;

options = options.map(function (option) {
return S(option).stripTags().trim().s;
}).filter(function (option) {
return option.length;
});

if (options.length > maxOptions) {
options = options.slice(0, maxOptions - 1);
}

return options.length ? '- ' + options.join('\n- ') : '';
}

function serializeSettings(raw, config) {
return XRegExp.forEach(S(raw).stripTags().s, settingsRegex, function(match) {
var key = S(match.key).trim().s;
var value = S(match.value).trim().s;

if (key.length && value.length && settingsValidators.hasOwnProperty(key)) {
if (settingsValidators[key].test(value)) {
this[key] = settingsValidators[key].parse(value);
}
}
}, config.defaults);
}

function deserializeSettings(settings, config) {
var pollSettings = config.defaults;
var deserialized = '';

for (var k in settings) {
if (settings.hasOwnProperty(k)) {
var key = S(k).stripTags().trim().s;
var value = S(settings[k]).stripTags().trim().s;

if (key.length && value.length && settingsValidators.hasOwnProperty(key)) {
if (settingsValidators[key].test(value)) {
deserialized += ' ' + key + '="' + value + '"';
}
}
}
}

return deserialized;
}

module.exports = Serializer;

if ('undefined' !== typeof window) {
Poll.serializer = module.exports;
}

})('undefined' === typeof module ? {
module: {
exports: {}
}
} : module);

68
public/js/poll/sockets.js Normal file → Executable file
View file

@ -1,47 +1,39 @@
"use strict";
/* globals socket */

(function(Poll) {
var Sockets = {
events: {
load: 'plugins.poll.load',
vote: 'plugins.poll.vote',
details: 'plugins.poll.optionDetails',
onvotechange: 'event:poll.votechange'
},
on: {
votechange: {
register: function() {
if (socket.listeners(Sockets.events.onvotechange).length === 0) {
socket.on(Sockets.events.onvotechange, this.handle);
}
},
handle: function(data) {
Poll.view.updateResults(data, $('#poll-id-' + data.pollid));
}
}
},
emit: {
load: function(pollid, callback) {
socket.emit(Sockets.events.load, { pollid: pollid }, callback);
},
vote: function(voteData, callback) {
socket.emit(Sockets.events.vote, voteData, callback);
},
getDetails: function(data, callback) {
socket.emit(Sockets.events.details, data, callback);
}
}
var messages = {
load: 'plugins.poll.load',
vote: 'plugins.poll.vote',
getDetails: 'plugins.poll.getOptionDetails',
getConfig: 'plugins.poll.getConfig'
};

function initialise() {
for (var e in Sockets.on) {
if (Sockets.on.hasOwnProperty(e)) {
Sockets.on[e].register();
var handlers = [{
event: 'event:poll.votechange',
handle: function(data) {
Poll.view.updateResults(data, $('#poll-id-' + data.pollid));
}
}];

function init() {
handlers.forEach(function(handler) {
if (socket.listeners(handler.event).length === 0) {
socket.on(handler.event, handler.handle);
}
});

for (var m in messages) {
if (messages.hasOwnProperty(m)) {
Poll.sockets[m] = function(data, callback) {
socket.emit(messages[m], data, callback);
};
}
}
}

initialise();
Poll.sockets = {};

init();

Poll.sockets = {
emit: Sockets.emit
};
})(window.Poll);

106
public/js/vendor/bootstrap-datetimepicker.min.js vendored Normal file → Executable file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,8 @@
/**
* jQuery serializeObject
* @copyright 2014, macek <paulmacek@gmail.com>
* @link https://github.com/macek/jquery-serialize-object
* @license BSD
* @version 2.5.0
*/
!function(e,i){i(e,e.jQuery||e.Zepto||e.ender||e.$)}(this,function(e,i){function r(e,r){function n(e,i,r){return e[i]=r,e}function a(e,i){for(var r,a=e.match(t.key);void 0!==(r=a.pop());)if(t.push.test(r)){var u=s(e.replace(/\[\]$/,""));i=n([],u,i)}else t.fixed.test(r)?i=n([],r,i):t.named.test(r)&&(i=n({},r,i));return i}function s(e){return void 0===h[e]&&(h[e]=0),h[e]++}function u(e){switch(i('[name="'+e.name+'"]',r).attr("type")){case"checkbox":return"on"===e.value?!0:e.value;default:return e.value}}function f(i){if(!t.validate.test(i.name))return this;var r=a(i.name,u(i));return l=e.extend(!0,l,r),this}function d(i){if(!e.isArray(i))throw new Error("formSerializer.addPairs expects an Array");for(var r=0,t=i.length;t>r;r++)this.addPair(i[r]);return this}function o(){return l}function c(){return JSON.stringify(o())}var l={},h={};this.addPair=f,this.addPairs=d,this.serialize=o,this.serializeJSON=c}var t={validate:/^[a-z_][a-z0-9_]*(?:\[(?:\d*|[a-z0-9_]+)\])*$/i,key:/[a-z0-9_]+|(?=\[\])/gi,push:/^$/,fixed:/^\d+$/,named:/^[a-z0-9_]+$/i};return r.patterns=t,r.serializeObject=function(){return new r(i,this).addPairs(this.serializeArray()).serialize()},r.serializeJSON=function(){return new r(i,this).addPairs(this.serializeArray()).serializeJSON()},"undefined"!=typeof i.fn&&(i.fn.serializeObject=r.serializeObject,i.fn.serializeJSON=r.serializeJSON),e.FormSerializer=r,r});

5
public/js/vendor/moment.min.js vendored Normal file → Executable file

File diff suppressed because one or more lines are too long

502
public/less/vendor/bootstrap-datetimepicker.less vendored Normal file → Executable file
View file

@ -1,254 +1,352 @@
/*!
* Datetimepicker for Bootstrap v3
* Datetimepicker for Bootstrap 3
* version : 4.17.37
* https://github.com/Eonasdan/bootstrap-datetimepicker/
*/
@bs-datetimepicker-timepicker-font-size: 1.2em;
@bs-datetimepicker-active-bg: @btn-primary-bg;
@bs-datetimepicker-active-color: @btn-primary-color;
@bs-datetimepicker-border-radius: @border-radius-base;
@bs-datetimepicker-btn-hover-bg: @gray-lighter;
@bs-datetimepicker-disabled-color: @gray-light;
@bs-datetimepicker-alternate-color: @gray-light;
@bs-datetimepicker-secondary-border-color: #ccc;
@bs-datetimepicker-secondary-border-color-rgba: rgba(0, 0, 0, 0.2);
@bs-datetimepicker-primary-border-color: white;
@bs-datetimepicker-text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);

.bootstrap-datetimepicker-widget {
top: 0;
left: 0;
width: 250px;
padding: 4px;
margin-top: 1px;
z-index: 99999 !important;
border-radius: 4px;
list-style: none;

&.timepicker-sbs {
width: 600px;
}
&.dropdown-menu {
margin: 2px 0;
padding: 4px;
width: 19em;

&.bottom {
&:before {
&.timepicker-sbs {
@media (min-width: @screen-sm-min) {
width: 38em;
}

@media (min-width: @screen-md-min) {
width: 38em;
}

@media (min-width: @screen-lg-min) {
width: 38em;
}
}

&:before, &:after {
content: '';
display: inline-block;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-bottom: 7px solid #ccc;
border-bottom-color: rgba(0,0,0,.2);
position: absolute;
top: -7px;
left: 7px;
}

&:after {
content: '';
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid white;
position: absolute;
top: -6px;
left: 8px;
&.bottom {
&:before {
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-bottom: 7px solid @bs-datetimepicker-secondary-border-color;
border-bottom-color: @bs-datetimepicker-secondary-border-color-rgba;
top: -7px;
left: 7px;
}

&:after {
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid @bs-datetimepicker-primary-border-color;
top: -6px;
left: 8px;
}
}

&.top {
&:before {
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-top: 7px solid @bs-datetimepicker-secondary-border-color;
border-top-color: @bs-datetimepicker-secondary-border-color-rgba;
bottom: -7px;
left: 6px;
}

&:after {
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid @bs-datetimepicker-primary-border-color;
bottom: -6px;
left: 7px;
}
}

&.pull-right {
&:before {
left: auto;
right: 6px;
}

&:after {
left: auto;
right: 7px;
}
}
}

&.top {
&:before {
content: '';
display: inline-block;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-top: 7px solid #ccc;
border-top-color: rgba(0,0,0,.2);
position: absolute;
bottom: -7px;
left: 6px;
}

&:after {
content: '';
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid white;
position: absolute;
bottom: -6px;
left: 7px;
}
}

& .dow {
width: 14.2857%;
}

&.pull-right {
&:before {
left: auto;
right: 6px;
}

&:after {
left: auto;
right: 7px;
}
}

>ul {
list-style-type: none;
.list-unstyled {
margin: 0;
}

a[data-action] {
padding: 6px 0;
}

a[data-action]:active {
box-shadow: none;
}

.timepicker-hour, .timepicker-minute, .timepicker-second {
width: 100%;
width: 54px;
font-weight: bold;
font-size: 1.2em;
}

table[data-hour-format="12"] .separator {
width: 4px;
padding: 0;
font-size: @bs-datetimepicker-timepicker-font-size;
margin: 0;
}

.datepicker > div {
display: none;
button[data-action] {
padding: 6px;
}

.btn[data-action="incrementHours"]::after {
.sr-only();
content: "Increment Hours";
}

.btn[data-action="incrementMinutes"]::after {
.sr-only();
content: "Increment Minutes";
}

.btn[data-action="decrementHours"]::after {
.sr-only();
content: "Decrement Hours";
}

.btn[data-action="decrementMinutes"]::after {
.sr-only();
content: "Decrement Minutes";
}

.btn[data-action="showHours"]::after {
.sr-only();
content: "Show Hours";
}

.btn[data-action="showMinutes"]::after {
.sr-only();
content: "Show Minutes";
}

.btn[data-action="togglePeriod"]::after {
.sr-only();
content: "Toggle AM/PM";
}

.btn[data-action="clear"]::after {
.sr-only();
content: "Clear the picker";
}

.btn[data-action="today"]::after {
.sr-only();
content: "Set the date to today";
}

.picker-switch {
text-align: center;

&::after {
.sr-only();
content: "Toggle Date and Time Screens";
}

td {
padding: 0;
margin: 0;
height: auto;
width: auto;
line-height: inherit;

span {
line-height: 2.5;
height: 2.5em;
width: 100%;
}
}
}

table {
width: 100%;
margin: 0;
}

td,
th {
text-align: center;
width: 20px;
height: 20px;
border-radius: 4px;
}

td {
&.day:hover,
&.hour:hover,
&.minute:hover,
&.second:hover {
background: @gray-lighter;
cursor: pointer;
& td,
& th {
text-align: center;
border-radius: @bs-datetimepicker-border-radius;
}

&.old,
&.new {
color: @gray-light;
}
& th {
height: 20px;
line-height: 20px;
width: 20px;

&.today {
position: relative;

&:before {
content: '';
display: inline-block;
border-left: 7px solid transparent;
border-bottom: 7px solid @btn-primary-bg;
border-top-color: rgba(0, 0, 0, 0.2);
position: absolute;
bottom: 4px;
right: 4px;
}
}

&.active,
&.active:hover {
background-color: @btn-primary-bg;
color: #fff;
text-shadow: 0 -1px 0 rgba(0,0,0,.25);
}

&.active.today:before {
border-bottom-color: #fff;
}

&.disabled,
&.disabled:hover {
background: none;
color: @gray-light;
cursor: not-allowed;
}

span {
display: block;
width: 47px;
height: 54px;
line-height: 54px;
float: left;
margin: 2px;
cursor: pointer;
border-radius: 4px;

&:hover {
background: @gray-lighter;
}

&.active {
background-color: @btn-primary-bg;
color: #fff;
text-shadow: 0 -1px 0 rgba(0,0,0,.25);
}

&.old {
color: @gray-light;
&.picker-switch {
width: 145px;
}

&.disabled,
&.disabled:hover {
background: none;
color: @gray-light;
color: @bs-datetimepicker-disabled-color;
cursor: not-allowed;
}

&.prev::after {
.sr-only();
content: "Previous Month";
}

&.next::after {
.sr-only();
content: "Next Month";
}
}

& thead tr:first-child th {
cursor: pointer;

&:hover {
background: @bs-datetimepicker-btn-hover-bg;
}
}

& td {
height: 54px;
line-height: 54px;
width: 54px;

&.cw {
font-size: .8em;
height: 20px;
line-height: 20px;
color: @bs-datetimepicker-alternate-color;
}

&.day {
height: 20px;
line-height: 20px;
width: 20px;
}

&.day:hover,
&.hour:hover,
&.minute:hover,
&.second:hover {
background: @bs-datetimepicker-btn-hover-bg;
cursor: pointer;
}

&.old,
&.new {
color: @bs-datetimepicker-alternate-color;
}

&.today {
position: relative;

&:before {
content: '';
display: inline-block;
border: solid transparent;
border-width: 0 0 7px 7px;
border-bottom-color: @bs-datetimepicker-active-bg;
border-top-color: @bs-datetimepicker-secondary-border-color-rgba;
position: absolute;
bottom: 4px;
right: 4px;
}
}

&.active,
&.active:hover {
background-color: @bs-datetimepicker-active-bg;
color: @bs-datetimepicker-active-color;
text-shadow: @bs-datetimepicker-text-shadow;
}

&.active.today:before {
border-bottom-color: #fff;
}

&.disabled,
&.disabled:hover {
background: none;
color: @bs-datetimepicker-disabled-color;
cursor: not-allowed;
}

span {
display: inline-block;
width: 54px;
height: 54px;
line-height: 54px;
margin: 2px 1.5px;
cursor: pointer;
border-radius: @bs-datetimepicker-border-radius;

&:hover {
background: @bs-datetimepicker-btn-hover-bg;
}

&.active {
background-color: @bs-datetimepicker-active-bg;
color: @bs-datetimepicker-active-color;
text-shadow: @bs-datetimepicker-text-shadow;
}

&.old {
color: @bs-datetimepicker-alternate-color;
}

&.disabled,
&.disabled:hover {
background: none;
color: @bs-datetimepicker-disabled-color;
cursor: not-allowed;
}
}
}
}

th {
&.switch {
width: 145px;
}

&.next,
&.prev {
font-size: @font-size-base * 1.5;
}

&.disabled,
&.disabled:hover {
background: none;
color: @gray-light;
cursor: not-allowed;
&.usetwentyfour {
td.hour {
height: 27px;
line-height: 27px;
}
}

thead tr:first-child th {
&.wider {
width: 21em;
}

& .datepicker-decades .decade {
line-height: 1.8em !important;
}
}

.input-group.date {
& .input-group-addon {
cursor: pointer;

&:hover {
background: @gray-lighter;
}
}
}

.input-group {
&.date {
.input-group-addon span {
display: block;
cursor: pointer;
width: 16px;
height: 16px;
}
}
}

.bootstrap-datetimepicker-widget.left-oriented {
&:before {
left: auto;
right: 6px;
}

&:after {
left: auto;
right: 7px;
}
}

.bootstrap-datetimepicker-widget ul.list-unstyled li div.timepicker div.timepicker-picker table.table-condensed tbody > tr > td {
padding: 0px !important;
}

View file

@ -0,0 +1,59 @@
<div class="row">
<div class="col-lg-9">
<form class="form poll-settings">
<div class="panel panel-default">
<div class="panel-heading">[[poll:poll]]</div>
<div class="panel-body">
<div class="row">
<div class="col-lg-6">
<strong>[[poll:toggles]]</strong>
<div class="form-group">
<div class="checkbox">
<label>
<input type="checkbox" data-key="toggles.allowAnon" data-trim="false"> [[poll:allow_guests]]
</label>
</div>
</div>
</div>
<div class="col-lg-6">
<strong>[[poll:limits]]</strong>
<div class="form-group">
<label for="maxPollOptions">[[poll:max_options]]</label>
<input type="number" class="form-control" id="maxPollOptions" placeholder="10" min="1" max="100" data-key="limits.maxOptions">
</div>
</div>
</div>
</div>
</div>

<div class="panel panel-default">
<div class="panel-heading">[[poll:defaults]]</div>
<div class="panel-body">
<div class="row">
<div class="col-lg-6">
<div class="form-group">
<label for="defaultsTitle">[[poll:default_title]]</label>
<input type="text" class="form-control" id="defaultsTitle" placeholder="Poll" data-key="defaults.title">
</div>
<div class="form-group">
<label for="defaultsMaxVotes">[[poll:max_votes]]</label>
<input type="number" class="form-control" id="defaultsMaxVotes" placeholder="1" min="1" max="100" data-key="defaults.maxvotes">
<p class="help-block">[[poll:info_choices]]</p>
</div>
</div>
</div>
</div>
</div>
</form>
</div>

<div class="col-lg-3">
<div class="panel panel-default">
<div class="panel-heading">[[poll:settings]]</div>
<div class="panel-body">
<button id="save" class="btn btn-primary btn-block">[[poll:save]]</button>
<button id="reset" class="btn btn-warning btn-block">[[poll:reset]]</button>
</div>
</div>
</div>
</div>

View file

@ -1,70 +0,0 @@
<div class="row">
<div class="col-md-12">
<h1>[[poll:poll]]</h1>
</div>
</div>

<div class="row">
<div class="col-xs-6 pull-left">
<h2>[[poll:settings]]
<small>[[poll:change_settings]]</small>
<button id="reset" class="btn btn-warning btn-xs pull-right">[[poll:reset]]</button>
<button id="save" class="btn btn-success btn-xs pull-right">[[poll:save]]</button>
</h2>
<hr>
<form class="form" id="pollSettingsForm">
<h3>[[poll:toggles]]</h3>
<div class="form-group">
<div class="checkbox">
<label>
<input type="checkbox" data-key="toggles.allowAnon"> [[poll:allow_guests]]
</label>
</div>
</div>
<h3>[[poll:limits]]</h3>
<div class="form-group">
<label for="maxPollOptions">[[poll:max_options]]</label>
<input type="number" class="form-control" id="maxPollOptions" placeholder="10" min="1" max="100" data-key="limits.maxOptions">
</div>
<h3>[[poll:defaults]]</h3>
<div class="form-group">
<label for="defaultsTitle">[[poll:default_title]]</label>
<input type="text" class="form-control" id="defaultsTitle" placeholder="Poll" data-key="defaults.title">
</div>
<div class="form-group">
<label for="defaultsMaxVotes">[[poll:max_votes]]</label>
<input type="number" class="form-control" id="defaultsMaxVotes" placeholder="1" min="1" max="100" data-key="defaults.maxvotes">
<p class="help-block">[[poll:info_choices]]</p>
</div>
</form>
</div>
<div class="col-xs-6 pull-right">
<h2>[[poll:actions]]</h2>
<hr>
</div>
</div>

<script>
require(['settings'], function (settings) {
var wrapper = $('#pollSettingsForm');
settings.sync('poll', wrapper);
$('#save').click(function(event) {
event.preventDefault();
settings.persist('poll', wrapper, function(){
socket.emit('admin.plugins.poll.sync');
});
});
$('#reset').click(function(event) {
event.preventDefault();
bootbox.confirm('Are you sure you wish to reset the settings?', function(sure) {
if (sure) {
socket.emit('admin.plugins.poll.getDefaults', null, function (err, data) {
settings.set('poll', data, wrapper, function(){
socket.emit('admin.plugins.poll.sync');
});
});
}
});
});
});
</script>

55
templates/poll/creator.tpl Normal file → Executable file
View file

@ -1,28 +1,39 @@
<div id="pollErrorBox" class="alert alert-danger hidden"></div>
<form class="form" id="pollCreator">
<div id="pollErrorBox" class="alert alert-danger hidden"></div>

<div class="form-group">
<label for="pollInputTitle">[[poll:poll_title]]</label>
<input data-poll-setting="title" type="text" class="form-control" id="pollInputTitle" placeholder="[[poll:enter_poll_title]]">
</div>
<div class="form-group">
<label for="pollInputTitle">[[poll:poll_title]]</label>
<input type="text" name="settings[title]" id="pollInputTitle" value="{poll.settings.title}" placeholder="[[poll:enter_poll_title]]" class="form-control">
</div>

<div class="form-group">
<label for="pollInputOptions">[[poll:options]]</label>
<textarea id="pollInputOptions" class="form-control" rows="5" placeholder="[[poll:options_placeholder]]"></textarea>
</div>
<div class="form-group">
<label for="pollInputOptions">[[poll:options]]</label>
<!-- IF poll.options.length -->
<!-- BEGIN poll.options -->
<input type="text" name="options[]" id="pollInputOptions" value="@value" class="form-control"/>
<!-- END poll.options -->
<!-- ELSE -->
<input type="text" name="options[]" id="pollInputOptions" class="form-control"/>
<!-- ENDIF poll.options.length -->
<button type="button" id="pollAddOption" class="btn btn-primary btn-sm btn-block">Add option</button>
</div>

<h3>[[poll:settings]]</h3>
<hr>

<div class="form-group">
<label for="pollInputAmount">[[poll:max_votes]]</label>
<!-- TODO change this to defaults -->
<input data-poll-setting="max" type="number" min="1" max="10" step="1" class="form-control" id="pollInputAmount" placeholder="[[poll:enter_amount]]">
<p class="help-block">[[poll:info_choices]]</p>
</div>
<div class="form-group">
<label for="pollInputAmount">[[poll:max_votes]]</label>
<input type="number" name="settings[maxvotes]" id="pollInputAmount" value="{poll.settings.maxvotes}" min="1" max="10" step="1" placeholder="[[poll:enter_amount]]" class="form-control">
</div>

<div class="form-group">
<label for="pollInputAmount">[[poll:auto_end]]</label>
<input data-poll-setting="end" type="text" class="form-control" id="pollInputEnd" placeholder="[[poll:date_placeholder]]">
<p class="help-block">[[poll:auto_end_help]]</p>
</div>
<div class="form-group">
<label for="pollInputEnd">[[poll:auto_end]]</label>

<div id="dtBox"></div>
<div class='input-group date' id='pollInputEnd'>
<input type="text" name="settings[end]" value="{poll.settings.end}" placeholder="[[poll:date_placeholder]]" class="form-control" readonly>
<span class="input-group-addon">
<span class="fa fa-calendar"></span>
</span>
</div>
<p class="help-block">[[poll:auto_end_help]]</p>
</div>
</form>