diff --git a/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 b/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 new file mode 100644 index 0000000..59b5f54 --- /dev/null +++ b/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 @@ -0,0 +1,203 @@ +import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; +import InputValidation from 'discourse/models/input-validation'; + +export default Ember.Controller.extend({ + regularPollType: 'regular', + numberPollType: 'number', + multiplePollType: 'multiple', + + init() { + this._super(); + this._setupPoll(); + }, + + @computed("regularPollType", "numberPollType", "multiplePollType") + pollTypes(regularPollType, numberPollType, multiplePollType) { + let types = []; + + types.push({ name: I18n.t("poll.ui_builder.poll_type.regular"), value: regularPollType }); + // TODO number & multiple do not work for polls with voter groups + types.push({ name: I18n.t("poll.ui_builder.poll_type.number"), value: numberPollType }); + types.push({ name: I18n.t("poll.ui_builder.poll_type.multiple"), value: multiplePollType }); + + return types; + }, + + @computed("pollType", "regularPollType") + isRegular(pollType, regularPollType) { + return pollType === regularPollType; + }, + + @computed("pollType", "pollOptionsCount", "multiplePollType") + isMultiple(pollType, count, multiplePollType) { + return (pollType === multiplePollType) && count > 0; + }, + + @computed("pollType", "numberPollType") + isNumber(pollType, numberPollType) { + return pollType === numberPollType; + }, + + @computed("isRegular") + showMinMax(isRegular) { + return !isRegular; + }, + + @computed("pollOptions") + pollOptionsCount(pollOptions) { + if (pollOptions.length === 0) return 0; + + let length = 0; + + pollOptions.split("\n").forEach(option => { + if (option.length !== 0) length += 1; + }); + + return length; + }, + + @observes("isMultiple", "isNumber", "pollOptionsCount") + _setPollMax() { + const isMultiple = this.get("isMultiple"); + const isNumber = this.get("isNumber"); + if (!isMultiple && !isNumber) return; + + if (isMultiple) { + this.set("pollMax", this.get("pollOptionsCount")); + } else if (isNumber) { + this.set("pollMax", this.siteSettings.poll_maximum_options); + } + }, + + @computed("isRegular", "isMultiple", "isNumber", "pollOptionsCount") + pollMinOptions(isRegular, isMultiple, isNumber, count) { + if (isRegular) return; + + if (isMultiple) { + return this._comboboxOptions(1, count + 1); + } else if (isNumber) { + return this._comboboxOptions(1, this.siteSettings.poll_maximum_options + 1); + } + }, + + @computed("isRegular", "isMultiple", "isNumber", "pollOptionsCount", "pollMin", "pollStep") + pollMaxOptions(isRegular, isMultiple, isNumber, count, pollMin, pollStep) { + if (isRegular) return; + const pollMinInt = parseInt(pollMin) || 1; + + if (isMultiple) { + return this._comboboxOptions(pollMinInt + 1, count + 1); + } else if (isNumber) { + let pollStepInt = parseInt(pollStep, 10); + if (pollStepInt < 1) { + pollStepInt = 1; + } + return this._comboboxOptions(pollMinInt + 1, pollMinInt + (this.siteSettings.poll_maximum_options * pollStepInt)); + } + }, + + @computed("isNumber", "pollMax") + pollStepOptions(isNumber, pollMax) { + if (!isNumber) return; + return this._comboboxOptions(1, (parseInt(pollMax) || 1) + 1); + }, + + @computed("isNumber", "showMinMax", "pollType", "publicPoll", "voterGroups", "pollOptions", "pollMin", "pollMax", "pollStep") + pollOutput(isNumber, showMinMax, pollType, publicPoll, voterGroups, pollOptions, pollMin, pollMax, pollStep) { + let pollHeader = '[poll'; + let output = ''; + + const match = this.get("toolbarEvent").getText().match(/\[poll(\s+name=[^\s\]]+)*.*\]/igm); + + if (match) { + pollHeader += ` name=poll${match.length + 1}`; + }; + + let step = pollStep; + if (step < 1) { + step = 1; + } + + if (pollType) pollHeader += ` type=${pollType}`; + if (pollMin && showMinMax) pollHeader += ` min=${pollMin}`; + if (pollMax) pollHeader += ` max=${pollMax}`; + if (isNumber) pollHeader += ` step=${step}`; + if (publicPoll) pollHeader += ' public=true'; + if (voterGroups) pollHeader += ' groups=true'; + pollHeader += ']'; + output += `${pollHeader}\n`; + + if (pollOptions.length > 0 && !isNumber) { + pollOptions.split("\n").forEach(option => { + if (option.length !== 0) output += `* ${option}\n`; + }); + } + + output += '[/poll]'; + return output; + }, + + @computed("pollOptionsCount", "isRegular", "isMultiple", "isNumber", "pollMin", "pollMax") + disableInsert(count, isRegular, isMultiple, isNumber, pollMin, pollMax) { + return (isRegular && count < 2) || (isMultiple && count < pollMin && pollMin >= pollMax) || (isNumber ? false : (count < 2)); + }, + + @computed("pollMin", "pollMax") + minMaxValueValidation(pollMin, pollMax) { + let options = { ok: true }; + + if (pollMin >= pollMax) { + options = { failed: true, reason: I18n.t("poll.ui_builder.help.invalid_values") }; + } + + return InputValidation.create(options); + }, + + @computed("pollStep") + minStepValueValidation(pollStep) { + let options = { ok: true }; + + if (pollStep < 1) { + options = { failed: true, reason: I18n.t("poll.ui_builder.help.min_step_value") }; + } + + return InputValidation.create(options); + }, + + @computed("disableInsert") + minNumOfOptionsValidation(disableInsert) { + let options = { ok: true }; + + if (disableInsert) { + options = { failed: true, reason: I18n.t("poll.ui_builder.help.options_count") }; + } + + return InputValidation.create(options); + }, + + _comboboxOptions(start_index, end_index) { + return _.range(start_index, end_index).map(number => { + return { value: number, name: number }; + }); + }, + + _setupPoll() { + this.setProperties({ + pollType: 'regular', + publicPoll: false, + voterGroups: false, + pollOptions: '', + pollMin: 1, + pollMax: null, + pollStep: 1 + }); + }, + + actions: { + insertPoll() { + this.get("toolbarEvent").addText(this.get("pollOutput")); + this.send("closeModal"); + this._setupPoll(); + } + } +}); diff --git a/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs b/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs new file mode 100644 index 0000000..83b360c --- /dev/null +++ b/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs @@ -0,0 +1,69 @@ +{{#d-modal-body title="poll.ui_builder.title" class="poll-ui-builder"}} +
+
+ + {{combo-box content=pollTypes + value=pollType + valueAttribute="value"}} +
+ + {{#if showMinMax}} +
+ + {{input type='number' + value=pollMin + valueAttribute="value" + class="poll-options-min"}} + {{input-tip validation=minMaxValueValidation}} +
+ + +
+ + {{input type='number' + value=pollMax + valueAttribute="value" + class="poll-options-max"}} +
+ + {{#if isNumber}} +
+ + {{input type='number' + value=pollStep + valueAttribute="value" + min="1" + class="poll-options-step"}} + {{input-tip validation=minStepValueValidation}} +
+ {{/if}} + {{/if}} + + + + +
+ +
+ + {{#unless isNumber}} +
+ + {{input-tip validation=minNumOfOptionsValidation}} + {{textarea value=pollOptions}} +
+ {{/unless}} +
+{{/d-modal-body}} + + diff --git a/poll/assets/javascripts/initializers/add-poll-ui-builder.js.es6 b/poll/assets/javascripts/initializers/add-poll-ui-builder.js.es6 new file mode 100644 index 0000000..9dbe65a --- /dev/null +++ b/poll/assets/javascripts/initializers/add-poll-ui-builder.js.es6 @@ -0,0 +1,40 @@ +import { withPluginApi } from 'discourse/lib/plugin-api'; +import computed from 'ember-addons/ember-computed-decorators'; +import showModal from 'discourse/lib/show-modal'; + +function initializePollUIBuilder(api) { + api.modifyClass('controller:composer', { + @computed('siteSettings.poll_enabled', 'siteSettings.poll_minimum_trust_level_to_create') + canBuildPoll(pollEnabled, minimumTrustLevel) { + return pollEnabled && + this.currentUser && + ( + this.currentUser.staff || + this.currentUser.trust_level >= minimumTrustLevel + ); + }, + + actions: { + showPollBuilder() { + showModal('poll-ui-builder').set('toolbarEvent', this.get('toolbarEvent')); + } + } + }); + + api.addToolbarPopupMenuOptionsCallback(function() { + return { + action: 'showPollBuilder', + icon: 'bar-chart-o', + label: 'poll.ui_builder.title', + condition: 'canBuildPoll' + }; + }); +} + +export default { + name: 'add-poll-ui-builder', + + initialize() { + withPluginApi('0.8.7', initializePollUIBuilder); + } +}; diff --git a/poll/assets/javascripts/initializers/extend-for-poll.js.es6 b/poll/assets/javascripts/initializers/extend-for-poll.js.es6 new file mode 100644 index 0000000..05df7aa --- /dev/null +++ b/poll/assets/javascripts/initializers/extend-for-poll.js.es6 @@ -0,0 +1,99 @@ +import { withPluginApi } from 'discourse/lib/plugin-api'; +import { observes } from "ember-addons/ember-computed-decorators"; +import { getRegister } from 'discourse-common/lib/get-owner'; +import WidgetGlue from 'discourse/widgets/glue'; + +function initializePolls(api) { + const register = getRegister(api); + + api.modifyClass('controller:topic', { + subscribe(){ + this._super(); + this.messageBus.subscribe("/polls/" + this.get("model.id"), msg => { + const post = this.get('model.postStream').findLoadedPost(msg.post_id); + if (post) { + post.set('polls', msg.polls); + } + }); + }, + unsubscribe(){ + this.messageBus.unsubscribe('/polls/*'); + this._super(); + } + }); + + api.modifyClass('model:post', { + _polls: null, + pollsObject: null, + + // we need a proper ember object so it is bindable + @observes("polls") + pollsChanged() { + const polls = this.get("polls"); + if (polls) { + this._polls = this._polls || {}; + _.map(polls, (v,k) => { + const existing = this._polls[k]; + if (existing) { + this._polls[k].setProperties(v); + } else { + this._polls[k] = Em.Object.create(v); + } + }); + this.set("pollsObject", this._polls); + _glued.forEach(g => g.queueRerender()); + } + } + }); + + const _glued = []; + function attachPolls($elem, helper) { + const $polls = $('.poll', $elem); + if (!$polls.length) { return; } + + const post = helper.getModel(); + api.preventCloak(post.id); + const votes = post.get('polls_votes') || {}; + + post.pollsChanged(); + + const polls = post.get("pollsObject"); + if (!polls) { return; } + + $polls.each((idx, pollElem) => { + const $poll = $(pollElem); + const pollName = $poll.data("poll-name"); + const poll = polls[pollName]; + if (poll) { + const isMultiple = poll.get('type') === 'multiple'; + + const glue = new WidgetGlue('discourse-poll', register, { + id: `${pollName}-${post.id}`, + post, + poll, + vote: votes[pollName] ? votes[pollName]["options"] : [], + voterGroupId: votes[pollName] ? votes[pollName]["voter_group_id"] : -1, + isMultiple, + }); + glue.appendTo(pollElem); + _glued.push(glue); + } + }); + } + + function cleanUpPolls() { + _glued.forEach(g => g.cleanUp()); + } + + api.includePostAttributes("polls", "polls_votes"); + api.decorateCooked(attachPolls, { onlyStream: true }); + api.cleanupStream(cleanUpPolls); +} + +export default { + name: "extend-for-poll", + + initialize() { + withPluginApi('0.8.7', initializePolls); + } +}; diff --git a/poll/assets/javascripts/lib/discourse-markdown/poll.js.es6 b/poll/assets/javascripts/lib/discourse-markdown/poll.js.es6 new file mode 100644 index 0000000..e200636 --- /dev/null +++ b/poll/assets/javascripts/lib/discourse-markdown/poll.js.es6 @@ -0,0 +1,476 @@ +/*eslint no-bitwise:0 */ + +const DATA_PREFIX = "data-poll-"; +const DEFAULT_POLL_NAME = "poll"; +const WHITELISTED_ATTRIBUTES = ["type", "name", "min", "max", "step", "order", "status", "public", "groups"]; + +function getHelpText(count, min, max) { + + // default values + if (isNaN(min) || min < 1) { min = 1; } + if (isNaN(max) || max > count) { max = count; } + + // add some help text + let help; + + if (max > 0) { + if (min === max) { + if (min > 1) { + help = I18n.t("poll.multiple.help.x_options", { count: min }); + } + } else if (min > 1) { + if (max < count) { + help = I18n.t("poll.multiple.help.between_min_and_max_options", { min: min, max: max }); + } else { + help = I18n.t("poll.multiple.help.at_least_min_options", { count: min }); + } + } else if (max <= count) { + help = I18n.t("poll.multiple.help.up_to_max_options", { count: max }); + } + } + + return help; +} + +function replaceToken(tokens, target, list) { + let pos = tokens.indexOf(target); + let level = tokens[pos].level; + + tokens.splice(pos, 1, ...list); + list[0].map = target.map; + + // resequence levels + for(;pos 0) { level++; } + } +} + +// analyzes the block to that we have poll options +function getListItems(tokens, startToken) { + + let i = tokens.length-1; + let listItems = []; + let buffer = []; + + for(;tokens[i]!==startToken;i--) { + if (i === 0) { + return; + } + + let token = tokens[i]; + if (token.level === 0) { + if (token.tag !== 'ol' && token.tag !== 'ul') { + return; + } + } + + if (token.level === 1 && token.nesting === 1) { + if (token.tag === 'li') { + listItems.push([token, buffer.reverse().join(' ')]); + } else { + return; + } + } + + if (token.level === 1 && token.nesting === 1 && token.tag === 'li') { + buffer = []; + } else { + if (token.type === 'text' || token.type === 'inline') { + buffer.push(token.content); + } + } + } + + return listItems.reverse(); +} + +function invalidPoll(state, tag) { + let token = state.push('text', '', 0); + token.content = '[/' + tag + ']'; +} + +const rule = { + tag: 'poll', + + before: function(state, tagInfo, raw){ + let token = state.push('text', '', 0); + token.content = raw; + token.bbcode_attrs = tagInfo.attrs; + token.bbcode_type = 'poll_open'; + }, + + after: function(state, openToken, raw) { + + let items = getListItems(state.tokens, openToken); + if (!items) { + return invalidPoll(state, raw); + } + + const attrs = openToken.bbcode_attrs; + + // default poll attributes + const attributes = [["class", "poll"]]; + + if (!attrs['status']) { + attributes.push([DATA_PREFIX + "status", "open"]); + } + + WHITELISTED_ATTRIBUTES.forEach(name => { + if (attrs[name]) { + attributes.push([DATA_PREFIX + name, attrs[name]]); + } + }); + + if (!attrs.name) { + attributes.push([DATA_PREFIX + "name", DEFAULT_POLL_NAME]); + } + + // we might need these values later... + let min = parseInt(attrs["min"], 10); + let max = parseInt(attrs["max"], 10); + let step = parseInt(attrs["step"], 10); + + // infinite loop if step < 1 + if (step < 1) { + step = 1; + } + + let header = []; + + let token = new state.Token('poll_open', 'div', 1); + token.block = true; + token.attrs = attributes; + header.push(token); + + token = new state.Token('poll_open', 'div', 1); + token.block = true; + header.push(token); + + token = new state.Token('poll_open', 'div', 1); + token.attrs = [['class', 'poll-container']]; + + header.push(token); + + // generate the options when the type is "number" + if (attrs["type"] === "number") { + // default values + if (isNaN(min)) { min = 1; } + if (isNaN(max)) { max = state.md.options.discourse.pollMaximumOptions; } + if (isNaN(step)) { step = 1; } + + if (items.length > 0) { + return invalidPoll(state, raw); + } + + // dynamically generate options + token = new state.Token('bullet_list_open', 'ul', 1); + header.push(token); + + for (let o = min; o <= max; o += step) { + token = new state.Token('list_item_open', 'li', 1); + items.push([token, String(o)]); + header.push(token); + + token = new state.Token('text', '', 0); + token.content = String(o); + header.push(token); + + token = new state.Token('list_item_close', 'li', -1); + header.push(token); + } + token = new state.Token('bullet_item_close', '', -1); + header.push(token); + } + + // flag items so we add hashes + for (let o = 0; o < items.length; o++) { + token = items[o][0]; + let text = items[o][1]; + + token.attrs = token.attrs || []; + let md5Hash = md5(JSON.stringify([text])); + token.attrs.push([DATA_PREFIX + 'option-id', md5Hash]); + } + + replaceToken(state.tokens, openToken, header); + + // we got to correct the level on the state + // we just resequenced + state.level = state.tokens[state.tokens.length-1].level; + + state.push('poll_close', 'div', -1); + + token = state.push('poll_open', 'div', 1); + token.attrs = [['class', 'poll-info']]; + + state.push('paragraph_open', 'p', 1); + + token = state.push('span_open', 'span', 1); + token.block = false; + token.attrs = [['class', 'info-number']]; + token = state.push('text', '', 0); + token.content = '0'; + state.push('span_close', 'span', -1); + + token = state.push('span_open', 'span', 1); + token.block = false; + token.attrs = [['class', 'info-text']]; + token = state.push('text', '', 0); + token.content = I18n.t("poll.voters", { count: 0 }); + state.push('span_close', 'span', -1); + + state.push('paragraph_close', 'p', -1); + + // multiple help text + if (attributes[DATA_PREFIX + "type"] === "multiple") { + let help = getHelpText(items.length, min, max); + if (help) { + state.push('paragraph_open', 'p', 1); + token = state.push('html_inline', '', 0); + token.content = help; + state.push('paragraph_close', 'p', -1); + } + } + + if (attributes[DATA_PREFIX + 'public'] === 'true') { + state.push('paragraph_open', 'p', 1); + token = state.push('text', '', 0); + token.content = I18n.t('poll.public.title'); + state.push('paragraph_close', 'p', -1); + } + + state.push('poll_close', 'div', -1); + state.push('poll_close', 'div', -1); + + token = state.push('poll_open', 'div', 1); + token.attrs = [['class', 'poll-buttons']]; + + if (attributes[DATA_PREFIX + 'type'] === 'multiple') { + token = state.push('link_open', 'a', 1); + token.block = false; + token.attrs = [ + ['class', 'button cast-votes'], + ['title', I18n.t('poll.cast-votes.title')] + ]; + + token = state.push('text', '', 0); + token.content = I18n.t('poll.cast-votes.label'); + + state.push('link_close', 'a', -1); + } + + token = state.push('link_open', 'a', 1); + token.block = false; + token.attrs = [ + ['class', 'button toggle-results'], + ['title', I18n.t('poll.show-results.title')] + ]; + + token = state.push('text', '', 0); + token.content = I18n.t("poll.show-results.label"); + + state.push('link_close', 'a', -1); + + state.push('poll_close', 'div', -1); + state.push('poll_close', 'div', -1); + } +}; + +function newApiInit(helper) { + helper.registerOptions((opts, siteSettings) => { + opts.features.poll = !!siteSettings.poll_enabled; + opts.pollMaximumOptions = siteSettings.poll_maximum_options; + }); + + helper.registerPlugin(md => { + md.block.bbcode.ruler.push('poll', rule); + }); +} + +export function setup(helper) { + helper.whiteList([ + 'div.poll', + 'div.poll-info', + 'div.poll-container', + 'div.poll-buttons', + 'div[data-*]', + 'span.info-number', + 'span.info-text', + 'a.button.cast-votes', + 'a.button.toggle-results', + 'li[data-*]' + ]); + + newApiInit(helper); + +} + +/*! + * Joseph Myer's md5() algorithm wrapped in a self-invoked function to prevent + * global namespace polution, modified to hash unicode characters as UTF-8. + * + * Copyright 1999-2010, Joseph Myers, Paul Johnston, Greg Holt, Will Bond + * http://www.myersdaily.org/joseph/javascript/md5-text.html + * http://pajhome.org.uk/crypt/md5 + * + * Released under the BSD license + * http://www.opensource.org/licenses/bsd-license + */ +function md5cycle(x, k) { + var a = x[0], b = x[1], c = x[2], d = x[3]; + + a = ff(a, b, c, d, k[0], 7, -680876936); + d = ff(d, a, b, c, k[1], 12, -389564586); + c = ff(c, d, a, b, k[2], 17, 606105819); + b = ff(b, c, d, a, k[3], 22, -1044525330); + a = ff(a, b, c, d, k[4], 7, -176418897); + d = ff(d, a, b, c, k[5], 12, 1200080426); + c = ff(c, d, a, b, k[6], 17, -1473231341); + b = ff(b, c, d, a, k[7], 22, -45705983); + a = ff(a, b, c, d, k[8], 7, 1770035416); + d = ff(d, a, b, c, k[9], 12, -1958414417); + c = ff(c, d, a, b, k[10], 17, -42063); + b = ff(b, c, d, a, k[11], 22, -1990404162); + a = ff(a, b, c, d, k[12], 7, 1804603682); + d = ff(d, a, b, c, k[13], 12, -40341101); + c = ff(c, d, a, b, k[14], 17, -1502002290); + b = ff(b, c, d, a, k[15], 22, 1236535329); + + a = gg(a, b, c, d, k[1], 5, -165796510); + d = gg(d, a, b, c, k[6], 9, -1069501632); + c = gg(c, d, a, b, k[11], 14, 643717713); + b = gg(b, c, d, a, k[0], 20, -373897302); + a = gg(a, b, c, d, k[5], 5, -701558691); + d = gg(d, a, b, c, k[10], 9, 38016083); + c = gg(c, d, a, b, k[15], 14, -660478335); + b = gg(b, c, d, a, k[4], 20, -405537848); + a = gg(a, b, c, d, k[9], 5, 568446438); + d = gg(d, a, b, c, k[14], 9, -1019803690); + c = gg(c, d, a, b, k[3], 14, -187363961); + b = gg(b, c, d, a, k[8], 20, 1163531501); + a = gg(a, b, c, d, k[13], 5, -1444681467); + d = gg(d, a, b, c, k[2], 9, -51403784); + c = gg(c, d, a, b, k[7], 14, 1735328473); + b = gg(b, c, d, a, k[12], 20, -1926607734); + + a = hh(a, b, c, d, k[5], 4, -378558); + d = hh(d, a, b, c, k[8], 11, -2022574463); + c = hh(c, d, a, b, k[11], 16, 1839030562); + b = hh(b, c, d, a, k[14], 23, -35309556); + a = hh(a, b, c, d, k[1], 4, -1530992060); + d = hh(d, a, b, c, k[4], 11, 1272893353); + c = hh(c, d, a, b, k[7], 16, -155497632); + b = hh(b, c, d, a, k[10], 23, -1094730640); + a = hh(a, b, c, d, k[13], 4, 681279174); + d = hh(d, a, b, c, k[0], 11, -358537222); + c = hh(c, d, a, b, k[3], 16, -722521979); + b = hh(b, c, d, a, k[6], 23, 76029189); + a = hh(a, b, c, d, k[9], 4, -640364487); + d = hh(d, a, b, c, k[12], 11, -421815835); + c = hh(c, d, a, b, k[15], 16, 530742520); + b = hh(b, c, d, a, k[2], 23, -995338651); + + a = ii(a, b, c, d, k[0], 6, -198630844); + d = ii(d, a, b, c, k[7], 10, 1126891415); + c = ii(c, d, a, b, k[14], 15, -1416354905); + b = ii(b, c, d, a, k[5], 21, -57434055); + a = ii(a, b, c, d, k[12], 6, 1700485571); + d = ii(d, a, b, c, k[3], 10, -1894986606); + c = ii(c, d, a, b, k[10], 15, -1051523); + b = ii(b, c, d, a, k[1], 21, -2054922799); + a = ii(a, b, c, d, k[8], 6, 1873313359); + d = ii(d, a, b, c, k[15], 10, -30611744); + c = ii(c, d, a, b, k[6], 15, -1560198380); + b = ii(b, c, d, a, k[13], 21, 1309151649); + a = ii(a, b, c, d, k[4], 6, -145523070); + d = ii(d, a, b, c, k[11], 10, -1120210379); + c = ii(c, d, a, b, k[2], 15, 718787259); + b = ii(b, c, d, a, k[9], 21, -343485551); + + x[0] = add32(a, x[0]); + x[1] = add32(b, x[1]); + x[2] = add32(c, x[2]); + x[3] = add32(d, x[3]); +} + +function cmn(q, a, b, x, s, t) { + a = add32(add32(a, q), add32(x, t)); + return add32((a << s) | (a >>> (32 - s)), b); +} + +function ff(a, b, c, d, x, s, t) { + return cmn((b & c) | ((~b) & d), a, b, x, s, t); +} + +function gg(a, b, c, d, x, s, t) { + return cmn((b & d) | (c & (~d)), a, b, x, s, t); +} + +function hh(a, b, c, d, x, s, t) { + return cmn(b ^ c ^ d, a, b, x, s, t); +} + +function ii(a, b, c, d, x, s, t) { + return cmn(c ^ (b | (~d)), a, b, x, s, t); +} + +function md51(s) { + // Converts the string to UTF-8 "bytes" when necessary + if (/[\x80-\xFF]/.test(s)) { + s = unescape(encodeURI(s)); + } + var n = s.length, state = [1732584193, -271733879, -1732584194, 271733878], i; + for (i = 64; i <= s.length; i += 64) { + md5cycle(state, md5blk(s.substring(i - 64, i))); + } + s = s.substring(i - 64); + var tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + for (i = 0; i < s.length; i++) + tail[i >> 2] |= s.charCodeAt(i) << ((i % 4) << 3); + tail[i >> 2] |= 0x80 << ((i % 4) << 3); + if (i > 55) { + md5cycle(state, tail); + for (i = 0; i < 16; i++) tail[i] = 0; + } + tail[14] = n * 8; + md5cycle(state, tail); + return state; +} + +function md5blk(s) { /* I figured global was faster. */ + var md5blks = [], i; /* Andy King said do it this way. */ + for (i = 0; i < 64; i += 4) { + md5blks[i >> 2] = s.charCodeAt(i) + + (s.charCodeAt(i + 1) << 8) + + (s.charCodeAt(i + 2) << 16) + + (s.charCodeAt(i + 3) << 24); + } + return md5blks; +} + +var hex_chr = '0123456789abcdef'.split(''); + +function rhex(n) { + var s = '', j = 0; + for (; j < 4; j++) + s += hex_chr[(n >> (j * 8 + 4)) & 0x0F] + + hex_chr[(n >> (j * 8)) & 0x0F]; + return s; +} + +function hex(x) { + for (var i = 0; i < x.length; i++) + x[i] = rhex(x[i]); + return x.join(''); +} + +function add32(a, b) { + return (a + b) & 0xFFFFFFFF; +} + +function md5(s) { + return hex(md51(s)); +} diff --git a/poll/assets/javascripts/lib/even-round.js.es6 b/poll/assets/javascripts/lib/even-round.js.es6 new file mode 100644 index 0000000..12806e2 --- /dev/null +++ b/poll/assets/javascripts/lib/even-round.js.es6 @@ -0,0 +1,29 @@ +// works as described on http://stackoverflow.com/a/13483710 +function sumsUpTo100(percentages) { + return percentages.map(p => Math.floor(p)).reduce((a, b) => a + b) === 100; +} + +export default function(percentages) { + var decimals = percentages.map(a => a % 1); + const sumOfDecimals = Math.ceil(decimals.reduce((a, b) => a + b)); + // compensate error by adding 1 to n items with the greatest decimal part + for (let i = 0, max = decimals.length; i < sumOfDecimals && i < max; i++) { + // find the greatest item in the decimals array, set it to 0, + // and increase the corresponding item in the percentages array by 1 + let greatest = 0; + let index = 0; + for (let j=0; j < decimals.length; j++) { + if (decimals[j] > greatest) { + index = j; + greatest = decimals[j]; + } + } + ++percentages[index]; + decimals[index] = 0; + // quit early when there is a rounding issue + if (sumsUpTo100(percentages)) break; + } + + return percentages.map(p => Math.floor(p)); +}; + diff --git a/poll/assets/javascripts/widgets/discourse-poll.js.es6 b/poll/assets/javascripts/widgets/discourse-poll.js.es6 new file mode 100644 index 0000000..294a615 --- /dev/null +++ b/poll/assets/javascripts/widgets/discourse-poll.js.es6 @@ -0,0 +1,814 @@ +import { createWidget } from 'discourse/widgets/widget'; +import { h } from 'virtual-dom'; +import { iconNode } from 'discourse-common/lib/icon-library'; +import RawHtml from 'discourse/widgets/raw-html'; +import { ajax } from 'discourse/lib/ajax'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; +import evenRound from "discourse/plugins/poll/lib/even-round"; +import { avatarFor } from 'discourse/widgets/post'; +import round from "discourse/lib/round"; + +function optionHtml(option) { + return new RawHtml({ html: `${option.html}` }); +} + +function fetchVoters(payload) { + return ajax("/polls/voters.json", { + type: "get", + data: payload + }).catch((error) => { + if (error) { + popupAjaxError(error); + } else { + bootbox.alert(I18n.t('poll.error_while_fetching_voters')); + } + }); +} + +function getVoterGroupName(groupId) { + if (groupId == 0) { + return "Requester"; + } else if (groupId == 1) { + return "Worker"; + } else { + return "Unknown Voter"; + } +} + +function getColors(numColors) { + var colors = []; + if (numColors == 0) return colors; + for(var i = 0; i < 360; i += 360 / numColors) { + var color = "hsl(" + i + ", 92%, 83%)"; + colors.push(color); + } + return colors; +} + +function sumValues(object) { + var sum = 0; + for (var p in object) { + sum += object[p]; + } + return sum; +} + +createWidget('discourse-poll-option', { + tagName: 'li', + + buildAttributes(attrs) { + return { 'data-poll-option-id': attrs.option.id }; + }, + + html(attrs) { + const result = []; + + const { option, vote } = attrs; + const chosen = vote.indexOf(option.id) !== -1; + + if (attrs.isMultiple) { + result.push(iconNode(chosen ? 'check-square-o' : 'square-o')); + } else { + result.push(iconNode(chosen ? 'dot-circle-o' : 'circle-o')); + } + result.push(' '); + result.push(optionHtml(option)); + return result; + }, + + click(e) { + if ($(e.target).closest("a").length === 0) { + this.sendWidgetAction('toggleOption', this.attrs.option); + } + } +}); + +createWidget('discourse-poll-voter-group', { + tagName: 'div.poll-voter-group-item', + + html(attrs) { + const result = []; + + const { groupId } = attrs; + + return getVoterGroupName(groupId); + }, + + click(e) { + if ($(e.target).closest("a").length === 0) { + this.sendWidgetAction('updateVoterGroup', this.attrs.groupId); + } + } +}); + +createWidget('discourse-poll-load-more', { + tagName: 'div.poll-voters-toggle-expand', + buildKey: attrs => `${attrs.id}-load-more`, + + defaultState() { + return { loading: false }; + }, + + html(attrs, state) { + return state.loading ? h('div.spinner.small') : h('a', iconNode('chevron-down')); + }, + + click() { + const { state } = this; + if (state.loading) { return; } + + state.loading = true; + return this.sendWidgetAction('loadMore').finally(() => state.loading = false); + } + +}); + +createWidget('discourse-poll-voters', { + tagName: 'ul.poll-voters-list', + buildKey: attrs => attrs.id(), + + defaultState() { + return { + loaded: 'new', + pollVoters: [], + offset: 1, + }; + }, + + fetchVoters() { + const { attrs, state } = this; + if (state.loaded === 'loading') { return; } + + state.loaded = 'loading'; + + return fetchVoters({ + post_id: attrs.postId, + poll_name: attrs.pollName, + option_id: attrs.optionId, + offset: state.offset + }).then(result => { + state.loaded = 'loaded'; + state.offset += 1; + + const pollResult = result[attrs.pollName]; + const newVoters = attrs.pollType === 'number' ? pollResult : pollResult[attrs.optionId]; + state.pollVoters = state.pollVoters.concat(newVoters); + + this.scheduleRerender(); + }); + }, + + loadMore() { + return this.fetchVoters(); + }, + + html(attrs, state) { + if (attrs.pollVoters && state.loaded === 'new') { + state.pollVoters = attrs.pollVoters; + } + + const contents = state.pollVoters.map(user => { + return h('li', [avatarFor('tiny', { + username: user.username, + template: user.avatar_template + }), ' ']); + }); + + if (state.pollVoters.length < attrs.totalVotes) { + contents.push(this.attach('discourse-poll-load-more', { id: attrs.id() })); + } + + return h('div.poll-voters', contents); + } + +}); + +createWidget('discourse-poll-standard-results', { + tagName: 'ul.results', + buildKey: attrs => `${attrs.id}-standard-results`, + + defaultState() { + return { + loaded: 'new' + }; + }, + + fetchVoters() { + const { attrs, state } = this; + + if (state.loaded === 'new') { + fetchVoters({ + post_id: attrs.post.id, + poll_name: attrs.poll.get('name') + }).then(result => { + state.voters = result[attrs.poll.get('name')]; + state.loaded = 'loaded'; + this.scheduleRerender(); + }); + } + }, + + html(attrs, state) { + const { poll } = attrs; + const options = poll.get('options'); + const displayVoterGroups = poll.get('groups'); + + if (options) { + const voters = poll.get('voters'); + const isPublic = poll.get('public'); + + if (displayVoterGroups) { + var percentages = {}; + var rounded = {}; + for (var k in voters) { + percentages[k] = voters[k] === 0 ? + Array(options.length).fill(0) : + options.map(o => k in o.votes ? 100 * o.votes[k] / voters[k] : 0); + + rounded[k] = attrs.isMultiple ? percentages[k].map(Math.floor) : evenRound(percentages[k]); + } + + const colors = getColors(options.length); + const contents = []; + for (var k in voters) { + contents.push(h('div.voter-group', + h('p', getVoterGroupName(k)) + )); + contents.push(h('div.bar-back', options.map((option, idx) => { + const chosen = (attrs.vote || []).includes(option.id) && k == attrs.voterGroupId; + const per = rounded[k][idx].toString(); + + return h('div.bar-groups', { + className: `${chosen ? 'chosen-groups' : ''}`, + attributes: { + style: `width:${per}%; background: ${colors[idx]}`, + }, + }, + h('p.option-groups', `${per > 0 ? option.html + ' ' + per + '%' : ''}`)); + }))); + } + return contents; + + } else { + + var totalVoters = sumValues(voters); + + const ordered = _.clone(options).sort((a, b) => { + const aVotes = sumValues(a.votes); + const bVotes = sumValues(b.votes); + if (aVotes < bVotes) { + return 1; + } else if (aVotes === bVotes) { + if (a.html < b.html) { + return -1; + } else { + return 1; + } + } else { + return -1; + } + }); + + const percentages = totalVoters === 0 ? + Array(ordered.length).fill(0) : + ordered.map(o => 100 * sumValues(o.votes) / totalVoters); + + const rounded = attrs.isMultiple ? percentages.map(Math.floor) : evenRound(percentages); + + return ordered.map((option, idx) => { + const contents = []; + const per = rounded[idx].toString(); + const chosen = (attrs.vote || []).includes(option.id); + + contents.push(h('div.option', + h('p', [ h('span.percentage', `${per}%`), optionHtml(option) ]) + )); + + contents.push(h('div.bar-back', + h('div.bar', { attributes: { style: `width:${per}%` }}) + )); + + return h('li', { className: `${chosen ? 'chosen' : ''}` }, contents); + }); + + } + + + + // TODO add back in ability to view who has voted on what option + + // if (isPublic) this.fetchVoters(); + + // if (isPublic) { + // contents.push(this.attach('discourse-poll-voters', { + // id: () => `poll-voters-${option.id}`, + // postId: attrs.post.id, + // optionId: option.id, + // pollName: poll.get('name'), + // totalVotes: option.votes, + // pollVoters: (state.voters && state.voters[option.id]) || [] + // })); + // } + } + } +}); + +createWidget('discourse-poll-number-results', { + buildKey: attrs => `${attrs.id}-number-results`, + + defaultState() { + return { + loaded: 'new' + }; + }, + + fetchVoters() { + const { attrs, state } = this; + + if (state.loaded === 'new') { + + fetchVoters({ + post_id: attrs.post.id, + poll_name: attrs.poll.get('name') + }).then(result => { + state.voters = result[attrs.poll.get('name')]; + state.loaded = 'loaded'; + this.scheduleRerender(); + }); + } + }, + + html(attrs, state) { + const { poll } = attrs; + const isPublic = poll.get('public'); + + const totalScore = poll.get('options').reduce((total, o) => { + return total + parseInt(o.html, 10) * parseInt(o.votes, 10); + }, 0); + + const voters = poll.voters; + const average = voters === 0 ? 0 : round(totalScore / voters, -2); + const averageRating = I18n.t("poll.average_rating", { average }); + const results = [h('div.poll-results-number-rating', + new RawHtml({ html: `${averageRating}` }))]; + + // if (isPublic) { + // this.fetchVoters(); + + // results.push(this.attach('discourse-poll-voters', { + // id: () => `poll-voters-${poll.get('name')}`, + // totalVotes: poll.get('voters'), + // pollVoters: state.voters || [], + // postId: attrs.post.id, + // pollName: poll.get('name'), + // pollType: poll.get('type') + // })); + // } + + return results; + } +}); + +createWidget('discourse-poll-container', { + tagName: 'div.poll-container', + html(attrs) { + const { poll } = attrs; + + if (attrs.showResults) { + const type = poll.get('type') === 'number' ? 'number' : 'standard'; + return this.attach(`discourse-poll-${type}-results`, attrs); + } + + const displayVoterGroups = poll.get('groups'); + + if (!displayVoterGroups) { + const options = poll.get('options'); + if (options) { + return h('ul', options.map(option => { + return this.attach('discourse-poll-option', { + option, + isMultiple: attrs.isMultiple, + vote: attrs.vote + }); + })); + } + } else { + if (attrs.voterGroupId == -1) { + const contents = []; + contents.push(h('p', [ + 'Oh no! You are not eligible to vote. Only active users are eligible to vote. See the ', + h('a', { + className: 'no-track-link', + attributes: { + href: this.siteSettings.poll_constitution_link, + target: '_blank' + } + }, + 'Daemo Constitution'), + ' for more information.' + ])); + return contents; + + } else if (attrs.voterGroupId == 2) { + const voterGroupIds = [0, 1]; + const contents = []; + contents.push(h('p', 'Choose the group you wish to represent in this poll:')); + contents.push(h('div', voterGroupIds.map(groupId => { + return this.attach('discourse-poll-voter-group', { + groupId + }); + }))); + return contents; + + } else { + const options = poll.get('options'); + if (options) { + const contents = []; + contents.push(h('p', 'Voting as ' + getVoterGroupName(attrs.voterGroupId) + ':')); + contents.push(h('ul', options.map(option => { + return this.attach('discourse-poll-option', { + option, + isMultiple: attrs.isMultiple, + vote: attrs.vote + }); + }))); + return contents; + } + } + } + } +}); + +createWidget('discourse-poll-info', { + tagName: 'div.poll-info', + + multipleHelpText(min, max, options) { + if (max > 0) { + if (min === max) { + if (min > 1) { + return I18n.t("poll.multiple.help.x_options", { count: min }); + } + } else if (min > 1) { + if (max < options) { + return I18n.t("poll.multiple.help.between_min_and_max_options", { min, max }); + } else { + return I18n.t("poll.multiple.help.at_least_min_options", { count: min }); + } + } else if (max <= options) { + return I18n.t("poll.multiple.help.up_to_max_options", { count: max }); + } + } + }, + + html(attrs) { + const { poll } = attrs; + const voters = poll.get('voters'); + const displayVoterGroups = poll.get('groups'); + const result = []; + + if (displayVoterGroups) { + for (var k in voters) { + const count = voters[k]; + result.push(h('p', [h('span.info-group', getVoterGroupName(k))])); + result.push(h('p', [ + h('span.info-number-groups', count.toString()), + h('span.info-text', I18n.t('poll.voters', { count })) + ])); + + if (attrs.isMultiple) { + if (attrs.showResults) { + const options = poll.get('options'); + const totalVotes = poll.get('options').reduce((total, o) => { + return total + (k in o.votes ? parseInt(o.votes[k], 10) : 0); + }, 0); + + result.push(h('p', [ + h('span.info-number', totalVotes.toString()), + h('span.info-text', I18n.t("poll.total_votes", { count: totalVotes })) + ])); + } + } + } + } else { + var count = 0; + for (var k in voters) { + count += voters[k]; + } + + result.push([h('p', [ + h('span.info-number', count.toString()), + h('span.info-text', I18n.t('poll.voters', { count })) + ])]); + + if (attrs.isMultiple) { + if (attrs.showResults) { + const totalVotes = poll.get('options').reduce((total, o) => { + return total + (k in o.votes ? parseInt(o.votes[k], 10) : 0); + }, 0); + + result.push(h('p', [ + h('span.info-number', totalVotes.toString()), + h('span.info-text', I18n.t("poll.total_votes", { count: totalVotes })) + ])); + } + } + } + + if (attrs.isMultiple && !attrs.showResults) { + const help = this.multipleHelpText(attrs.min, attrs.max, poll.get('options.length')); + if (help) { + result.push(new RawHtml({ html: `${help}` })); + } + } + + if (!attrs.showResults && attrs.poll.get('public')) { + result.push(h('p', I18n.t('poll.public.title'))); + } + + return result; + } +}); + +createWidget('discourse-poll-buttons', { + tagName: 'div.poll-buttons', + + html(attrs) { + const results = []; + const { poll, post } = attrs; + const topicArchived = post.get('topic.archived'); + const isClosed = poll.get('status') === 'closed'; + const hideResultsDisabled = isClosed || topicArchived; + + if (attrs.isMultiple && !hideResultsDisabled) { + const castVotesDisabled = !attrs.canCastVotes; + results.push(this.attach('button', { + className: `btn cast-votes ${castVotesDisabled ? '' : 'btn-primary'}`, + label: 'poll.cast-votes.label', + title: 'poll.cast-votes.title', + disabled: castVotesDisabled, + action: 'castVotes' + })); + results.push(' '); + } + + const voters = poll.get('voters'); + + var numVoters = 0; + for (var k in voters) { + numVoters += voters[k]; + } + + if (attrs.showResults) { + results.push(this.attach('button', { + className: 'btn toggle-results', + label: 'poll.hide-results.label', + title: 'poll.hide-results.title', + icon: 'eye-slash', + disabled: hideResultsDisabled, + action: 'toggleResults' + })); + } else { + results.push(this.attach('button', { + className: 'btn toggle-results', + label: 'poll.show-results.label', + title: 'poll.show-results.title', + icon: 'eye', + disabled: numVoters === 0, + action: 'toggleResults' + })); + } + + if (this.currentUser && + (this.currentUser.get("id") === post.get('user_id') || + this.currentUser.get("staff")) && + !topicArchived) { + + if (isClosed) { + results.push(this.attach('button', { + className: 'btn toggle-status', + label: 'poll.open.label', + title: 'poll.open.title', + icon: 'unlock-alt', + action: 'toggleStatus' + })); + } else { + results.push(this.attach('button', { + className: 'btn toggle-status btn-danger', + label: 'poll.close.label', + title: 'poll.close.title', + icon: 'lock', + action: 'toggleStatus' + })); + } + } + + + return results; + } +}); + +export default createWidget('discourse-poll', { + tagName: 'div.poll', + buildKey: attrs => attrs.id, + + buildAttributes(attrs) { + const { poll } = attrs; + return { + "data-poll-type": poll.get('type'), + "data-poll-name": poll.get('name'), + "data-poll-status": poll.get('status'), + "data-poll-public": poll.get('public') + }; + }, + + defaultState(attrs) { + const { poll, post } = attrs; + + return { loading: false, + showResults: poll.get('isClosed') || post.get('topic.archived'), + voterGroupId: -1 }; + }, + + html(attrs, state) { + const { showResults, voterGroupId } = state; + this.fetchVoterGroupId(); + const newAttrs = jQuery.extend({}, attrs, { + showResults, + voterGroupId, + canCastVotes: this.canCastVotes(), + min: this.min(), + max: this.max() + }); + return h('div', [ + this.attach('discourse-poll-container', newAttrs), + this.attach('discourse-poll-info', newAttrs), + this.attach('discourse-poll-buttons', newAttrs) + ]); + }, + + isClosed() { + return this.attrs.poll.get('status') === "closed"; + }, + + min() { + let min = parseInt(this.attrs.poll.min, 10); + if (isNaN(min) || min < 1) { min = 1; } + return min; + }, + + max() { + let max = parseInt(this.attrs.poll.max, 10); + const numOptions = this.attrs.poll.options.length; + if (isNaN(max) || max > numOptions) { max = numOptions; } + return max; + }, + + canCastVotes() { + const { state, attrs } = this; + if (this.isClosed() || state.showResults || state.loading || state.voterGroupId == -1) { + return false; + } + + const selectedOptionCount = attrs.vote.length; + if (attrs.isMultiple) { + return selectedOptionCount >= this.min() && selectedOptionCount <= this.max(); + } + return selectedOptionCount > 0; + }, + + toggleStatus() { + const { state, attrs } = this; + const { poll } = attrs; + const isClosed = poll.get('status') === 'closed'; + + bootbox.confirm( + I18n.t(isClosed ? "poll.open.confirm" : "poll.close.confirm"), + I18n.t("no_value"), + I18n.t("yes_value"), + confirmed => { + if (confirmed) { + state.loading = true; + + const status = isClosed ? "open" : "closed"; + ajax("/polls/toggle_status", { + type: "PUT", + data: { + post_id: attrs.post.get('id'), + poll_name: poll.get('name'), + status, + } + }).then(() => { + poll.set('status', status); + this.scheduleRerender(); + }).catch((error) => { + if (error) { + popupAjaxError(error); + } else { + bootbox.alert(I18n.t("poll.error_while_toggling_status")); + } + }).finally(() => { + state.loading = false; + }); + } + } + ); + }, + + toggleResults() { + this.state.showResults = !this.state.showResults; + }, + + showLogin() { + const appRoute = this.register.lookup('route:application'); + appRoute.send('showLogin'); + }, + + toggleOption(option) { + if (this.isClosed()) { return; } + if (!this.currentUser) { this.showLogin(); } + + const { attrs } = this; + const { vote } = attrs; + + const chosenIdx = vote.indexOf(option.id); + if (!attrs.isMultiple) { + vote.length = 0; + } + + if (chosenIdx !== -1) { + vote.splice(chosenIdx, 1); + } else { + vote.push(option.id); + } + + if (!attrs.isMultiple) { + return this.castVotes(); + } + }, + + castVotes() { + if (!this.canCastVotes()) { return; } + if (!this.currentUser) { return this.showLogin(); } + + const { attrs, state } = this; + + state.loading = true; + + return ajax("/polls/vote", { + type: "PUT", + data: { + post_id: attrs.post.id, + poll_name: attrs.poll.name, + options: attrs.vote, + voter_group_id: state.voterGroupId, + } + }).then(() => { + state.showResults = true; + }).catch((error) => { + if (error) { + popupAjaxError(error); + } else { + bootbox.alert(I18n.t("poll.error_while_casting_votes")); + } + }).finally(() => { + state.loading = false; + }); + }, + + fetchVoterGroupId() { + const { state } = this; + + var self = this; + $.ajax({ + type: 'GET', + url: this.siteSettings.poll_voter_info_endpoint, + xhrFields: { + withCredentials: true + }, + success: function(response){ + var groupId = -1; + if (response['requester'] && !response['worker']) { + groupId = 0; + } else if (!response['requester'] && response['worker']) { + groupId = 1; + } else if (response['requester'] && response['worker']) { + groupId = 2; + } + + if (state.voterGroupId != groupId) { + self.updateVoterGroup(groupId); + } + }, + error: function() { + bootbox.alert(I18n.t("poll.error_while_fetching_voter_group")); + } + }); + }, + + updateVoterGroup(groupId) { + const { state } = this; + + state.voterGroupId = groupId + this.scheduleRerender(); + } +}); diff --git a/poll/assets/stylesheets/common/poll-ui-builder.scss b/poll/assets/stylesheets/common/poll-ui-builder.scss new file mode 100644 index 0000000..184cd20 --- /dev/null +++ b/poll/assets/stylesheets/common/poll-ui-builder.scss @@ -0,0 +1,25 @@ +.poll-ui-builder-form { + margin: 0px; + + .input-group { + padding: 10px; + } + + label { + font-weight: bold; + display: inline; + } + + .input-group-label { + display: inline-block; + width: 45px; + } + + .combobox { + margin-right: 5px; + } + + .poll-options-min, .poll-options-max, .poll-options-step { + width: 70px !important; + } +} diff --git a/poll/assets/stylesheets/common/poll.scss b/poll/assets/stylesheets/common/poll.scss new file mode 100644 index 0000000..9188869 --- /dev/null +++ b/poll/assets/stylesheets/common/poll.scss @@ -0,0 +1,182 @@ +$border-color: rgb(219,219,219); +$text-color: #9E9E9E; + +$option-background: dark-light-diff($primary, $secondary, 90%, -65%); + +div.poll { + margin: 10px 0px; + border: 1px solid $border-color; + + @include unselectable; + + ul, ol { + margin: 0; + padding: 0; + list-style: none; + display: inline-block; + width: 100%; + } + + li { + cursor: pointer; + font-size: 15px; + margin-bottom: 10px; + } + + li[data-poll-option-id] { + color: $primary; + padding: .5em .7em .7em .5em; + } + + .button { + display: inline-block; + padding: 6px 12px; + margin-right: 5px; + text-align: center; + cursor: pointer; + color: $primary; + background: dark-light-diff($primary, $secondary, 90%, -65%); + + &:hover { + background: dark-light-diff($primary, $secondary, 65%, -75%); + color: #fff; + } + } + + .poll-info { + color: $text-color; + text-align: center; + vertical-align: middle; + + .info-number { + font-size: 3.5em; + } + + .info-text { + font-size: 1.5em; + } + + .info-number-groups { + font-size: 2.5em; + } + + .info-group { + font-size: 1.5em; + } + } + + .poll-container { + vertical-align: middle; + padding: 10px; + + .poll-results-number-rating { + font-size: 2em; + } + } + + .poll-buttons { + button { + float: none; + } + } + + .poll-voters-list { + li { + display: inline; + } + + margin-top: 4px; + } + + .poll-voters-toggle-expand { + width: 100%; + text-align: center; + } + + .poll-voter-group-item { + width: 40%; + height: 40px; + line-height: 40px; + margin: 10px; + float: left; + cursor: pointer; + text-align: center; + background: dark-light-diff($primary, $secondary, 90%, -65%); + + &:hover { + background: dark-light-diff($primary, $secondary, 65%, -75%); + color: #fff; + } + } + + .results { + + .option { + padding-bottom: 5px; + p { + margin: 0; + } + } + + .option-groups { + font-weight: bold; + text-align: center; + } + + .voter-group { + width: 100%; + display: inline-block; + font-size: 20px; + font-weight: bold; + padding-top: 20px; + padding-bottom: 5px; + p { + margin: 0; + } + } + + .percentage { + font-size: 20px; + float: right; + color: $text-color; + margin-left: 5px; + } + + .bar-back { + background: $option-background; + } + + .bar { + height: 10px; + background: dark-light-diff($primary, $secondary, 50%, -25%);; + } + + .chosen .bar { + background: $tertiary; + } + + .bar-groups { + height: 40px; + display: inline-block; + float: left; + background: dark-light-diff($primary, $secondary, 50%, -25%); + } + + .chosen-groups { + -webkit-box-shadow:inset 0px 0px 0px 5px $option-background; + -moz-box-shadow:inset 0px 0px 0px 5px $option-background; + box-shadow:inset 0px 0px 0px 5px $option-background; + } + } + + &[data-poll-type="number"] { + + li[data-poll-option-id] { + display: inline-block; + width: 45px; + margin-right: 5px; + } + + } + +} diff --git a/poll/assets/stylesheets/desktop/poll.scss b/poll/assets/stylesheets/desktop/poll.scss new file mode 100644 index 0000000..cc8ed28 --- /dev/null +++ b/poll/assets/stylesheets/desktop/poll.scss @@ -0,0 +1,60 @@ +div.poll { + display: table; + min-width: 500px; + width: 100%; + box-sizing: border-box; + + .poll-info { + min-width: 150px; + width: 100%; + display: table-cell; + border-left: 1px solid $border-color; + + p { + margin: 40px 20px; + } + + .info-text { + display: block; + } + } + + .poll-container { + display: table-cell; + min-width: 330px; + width: 100%; + } + + .poll-buttons { + border-top: 1px solid $border-color; + padding: 10px; + + .toggle-status { + float: right; + } + } +} + +.d-editor-preview { + .poll-buttons { + a:not(:first-child) { + margin-left: 5px; + } + } + + .poll { + li[data-poll-option-id]:before { + font-family: FontAwesome; + content: "\f10c"; + margin-right: 3px; + position: relative; + top: 1px; + } + + &[data-poll-type="multiple"] { + li[data-poll-option-id]:before { + content: "\f096"; + } + } + } +} diff --git a/poll/assets/stylesheets/mobile/poll.scss b/poll/assets/stylesheets/mobile/poll.scss new file mode 100644 index 0000000..1c35f64 --- /dev/null +++ b/poll/assets/stylesheets/mobile/poll.scss @@ -0,0 +1,13 @@ +div.poll { + .poll-buttons { + padding: 0 5px 5px 5px; + + button { + margin: 4px 2px; + } + } + + .poll-info { + .info-text:before {content:"\00a0";} // nbsp + } +} diff --git a/poll/config/locales/client.en.yml b/poll/config/locales/client.en.yml new file mode 100644 index 0000000..8926d42 --- /dev/null +++ b/poll/config/locales/client.en.yml @@ -0,0 +1,94 @@ +# encoding: utf-8 +# This file contains content for the client portion of the poll plugin, sent out +# to the Javascript app. +# +# To work with us on translations, see: +# https://www.transifex.com/projects/p/discourse-org/ +# +# This is a "source" file, which is used by Transifex to get translations for other languages. +# After this file is changed, it needs to be pushed by a maintainer to Transifex: +# +# tx push -s +# +# Read more here: https://meta.discourse.org/t/contribute-a-translation-to-discourse/14882 +# +# To validate this YAML file after you change it, please paste it into +# http://yamllint.com/ + +en: + js: + poll: + voters: + one: "voter" + other: "voters" + total_votes: + one: "total vote" + other: "total votes" + + average_rating: "Average rating: %{average}." + + public: + title: "Votes are public." + + multiple: + help: + at_least_min_options: + one: "Choose at least 1 option" + other: "Choose at least %{count} options" + up_to_max_options: + one: "Choose up to 1 option" + other: "Choose up to %{count} options" + x_options: + one: "Choose 1 option" + other: "Choose %{count} options" + between_min_and_max_options: "Choose between %{min} and %{max} options" + + cast-votes: + title: "Cast your votes" + label: "Vote now!" + + show-results: + title: "Display the poll results" + label: "Show results" + + hide-results: + title: "Back to your votes" + label: "Hide results" + + open: + title: "Open the poll" + label: "Open" + confirm: "Are you sure you want to open this poll?" + + close: + title: "Close the poll" + label: "Close" + confirm: "Are you sure you want to close this poll?" + + error_while_fetching_voter_group: "Sorry, there was an error fetching your voter group. Make sure you are logged into Daemo." + error_while_toggling_status: "Sorry, there was an error toggling the status of this poll." + error_while_casting_votes: "Sorry, there was an error casting your votes." + error_while_fetching_voters: "Sorry, there was an error displaying the voters." + + ui_builder: + title: Build Poll + insert: Insert Poll + help: + options_count: Enter at least 2 options + invalid_values: Minimum value must be smaller than the maximum value. + min_step_value: The minimum step value is 1 + poll_type: + label: Type + regular: Single Choice + multiple: Multiple Choice + number: Number Rating + poll_config: + max: Max + min: Min + step: Step + poll_public: + label: Show who voted + poll_options: + label: Enter one poll option per line + voter_groups: + label: "Separate voters into groups (requester, worker, etc.)" diff --git a/poll/config/locales/server.en.yml b/poll/config/locales/server.en.yml new file mode 100644 index 0000000..f888a7b --- /dev/null +++ b/poll/config/locales/server.en.yml @@ -0,0 +1,70 @@ +# encoding: utf-8 +# This file contains content for the server portion of the poll plugin used by Ruby +# +# To work with us on translations, see: +# https://www.transifex.com/projects/p/discourse-org/ +# +# This is a "source" file, which is used by Transifex to get translations for other languages. +# After this file is changed, it needs to be pushed by a maintainer to Transifex: +# +# tx push -s +# +# Read more here: https://meta.discourse.org/t/contribute-a-translation-to-discourse/14882 +# +# To validate this YAML file after you change it, please paste it into +# http://yamllint.com/ + +en: + site_settings: + poll_enabled: "Allow polls?" + poll_maximum_options: "Maximum number of options allowed in a poll." + poll_edit_window_mins: "Number of minutes after post creation during which polls can be edited." + poll_minimum_trust_level_to_create: "Define the minimum trust level needed to create polls." + poll_voter_info_endpoint: "Endpoint to retrieve voter group information." + poll_constitution_link: "Link to Daemo Constitution." + + poll: + multiple_polls_without_name: "There are multiple polls without a name. Use the 'name' attribute to uniquely identify your polls." + multiple_polls_with_same_name: "There are multiple polls with the same name: %{name}. Use the 'name' attribute to uniquely identify your polls." + + default_poll_must_have_at_least_2_options: "Poll must have at least 2 options." + named_poll_must_have_at_least_2_options: "Poll named %{name} must have at least 2 options." + + default_poll_must_have_less_options: + one: "Poll must have less than 1 option." + other: "Poll must have less than %{count} options." + named_poll_must_have_less_options: + one: "Poll named %{name} must have less than 1 option." + other: "Poll named %{name} must have less than %{count} options." + + default_poll_must_have_different_options: "Poll must have different options." + named_poll_must_have_different_options: "Poll named %{name} must have different options." + + default_poll_with_multiple_choices_has_invalid_parameters: "Poll with multiple choices has invalid parameters." + named_poll_with_multiple_choices_has_invalid_parameters: "Poll named %{name} with multiple choice has invalid parameters." + + requires_at_least_1_valid_option: "You must select at least 1 valid option." + + default_cannot_be_made_public: "Poll with votes cannot be made public." + named_cannot_be_made_public: "Poll named %{name} has votes cannot be made public." + + edit_window_expired: + cannot_change_polls: "You cannot add, remove or rename polls after the first %{minutes} minutes." + op_cannot_edit_options: "You cannot add or remove poll options after the first %{minutes} minutes. Please contact a moderator if you need to edit a poll option." + staff_cannot_add_or_remove_options: "You cannot add or remove poll options after the first %{minutes} minutes. You should close this topic and create a new one instead." + + no_polls_associated_with_this_post: "No polls are associated with this post." + no_poll_with_this_name: "No poll named %{name} associated with this post." + + post_is_deleted: "Cannot act on a deleted post." + + topic_must_be_open_to_vote: "The topic must be open to vote." + poll_must_be_open_to_vote: "Poll must be open to vote." + + topic_must_be_open_to_toggle_status: "The topic must be open to toggle status." + only_staff_or_op_can_toggle_status: "Only a staff member or the original poster can toggle a poll status." + + insufficient_rights_to_create: "You are not allowed to create polls." + + email: + link_to_poll: "Click to view the poll." diff --git a/poll/config/settings.yml b/poll/config/settings.yml new file mode 100644 index 0000000..4c891f1 --- /dev/null +++ b/poll/config/settings.yml @@ -0,0 +1,19 @@ +plugins: + poll_enabled: + default: true + client: true + poll_maximum_options: + default: 20 + client: true + poll_edit_window_mins: + default: 5 + poll_minimum_trust_level_to_create: + default: 0 + client: true + enum: 'TrustLevelSetting' + poll_voter_info_endpoint: + default: '' + client: true + poll_constitution_link: + default: '' + client: true diff --git a/poll/lib/polls_updater.rb b/poll/lib/polls_updater.rb new file mode 100644 index 0000000..e9a00b7 --- /dev/null +++ b/poll/lib/polls_updater.rb @@ -0,0 +1,151 @@ +module DiscoursePoll + class PollsUpdater + VALID_POLLS_CONFIGS = %w{type min max public groups}.map(&:freeze) + + def self.update(post, polls) + # load previous polls + previous_polls = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] || {} + + # extract options + current_option_ids = extract_option_ids(polls) + previous_option_ids = extract_option_ids(previous_polls) + + # are the polls different? + if polls_updated?(polls, previous_polls) || (current_option_ids != previous_option_ids) + has_votes = total_votes(previous_polls) > 0 + + # outside of the edit window? + poll_edit_window_mins = SiteSetting.poll_edit_window_mins + + if post.created_at < poll_edit_window_mins.minutes.ago && has_votes + # cannot add/remove/rename polls + if polls.keys.sort != previous_polls.keys.sort + post.errors.add(:base, I18n.t( + "poll.edit_window_expired.cannot_change_polls", minutes: poll_edit_window_mins + )) + + return + end + + # deal with option changes + if User.staff.pluck(:id).include?(post.last_editor_id) + # staff can only edit options + polls.each_key do |poll_name| + if polls[poll_name]["options"].size != previous_polls[poll_name]["options"].size && previous_polls[poll_name]["voters"].to_i > 0 + post.errors.add(:base, I18n.t( + "poll.edit_window_expired.staff_cannot_add_or_remove_options", + minutes: poll_edit_window_mins + )) + + return + end + end + else + # OP cannot edit poll options + post.errors.add(:base, I18n.t( + "poll.edit_window_expired.op_cannot_edit_options", + minutes: poll_edit_window_mins + )) + + return + end + end + + # try to merge votes + polls.each_key do |poll_name| + next unless previous_polls.has_key?(poll_name) + return if has_votes && private_to_public_poll?(post, previous_polls, polls, poll_name) + + # when the # of options has changed, reset all the votes + if polls[poll_name]["options"].size != previous_polls[poll_name]["options"].size + PostCustomField.where(post_id: post.id, name: DiscoursePoll::VOTES_CUSTOM_FIELD).destroy_all + post.clear_custom_fields + next + end + + polls[poll_name]["voters"] = previous_polls[poll_name]["voters"] + + if previous_polls[poll_name].has_key?("anonymous_voters") + polls[poll_name]["anonymous_voters"] = previous_polls[poll_name]["anonymous_voters"] + end + + previous_options = previous_polls[poll_name]["options"] + public_poll = polls[poll_name]["public"] == "true" + + polls[poll_name]["options"].each_with_index do |option, index| + previous_option = previous_options[index] + option["votes"] = previous_option["votes"] + + if previous_option["id"] != option["id"] + if votes_fields = post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD] + votes_fields.each do |key, value| + next unless value[poll_name] + index = value[poll_name].index(previous_option["id"]) + votes_fields[key][poll_name][index] = option["id"] if index + end + end + end + + if previous_option.has_key?("anonymous_votes") + option["anonymous_votes"] = previous_option["anonymous_votes"] + end + + if public_poll && previous_option.has_key?("voter_ids") + option["voter_ids"] = previous_option["voter_ids"] + end + end + end + + # immediately store the polls + post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls + post.save_custom_fields(true) + + # publish the changes + MessageBus.publish("/polls/#{post.topic_id}", post_id: post.id, polls: polls) + end + end + + def self.polls_updated?(current_polls, previous_polls) + return true if (current_polls.keys.sort != previous_polls.keys.sort) + + current_polls.each_key do |poll_name| + if !previous_polls[poll_name] || + (current_polls[poll_name].values_at(*VALID_POLLS_CONFIGS) != previous_polls[poll_name].values_at(*VALID_POLLS_CONFIGS)) + + return true + end + end + + false + end + + def self.extract_option_ids(polls) + polls.values.map { |p| p["options"].map { |o| o["id"] } }.flatten.sort + end + + def self.total_votes(polls) + polls.map { |key, value| value["voters"].map {|k,v| v.to_i}.sum }.sum + end + + private + + def self.private_to_public_poll?(post, previous_polls, current_polls, poll_name) + _previous_poll = previous_polls[poll_name] + current_poll = current_polls[poll_name] + + if previous_polls["public"].nil? && current_poll["public"] == "true" + error = + if poll_name == DiscoursePoll::DEFAULT_POLL_NAME + I18n.t("poll.default_cannot_be_made_public") + else + I18n.t("poll.named_cannot_be_made_public", name: poll_name) + end + + post.errors.add(:base, error) + return true + end + + false + end + end +end diff --git a/poll/lib/polls_validator.rb b/poll/lib/polls_validator.rb new file mode 100644 index 0000000..2321f7f --- /dev/null +++ b/poll/lib/polls_validator.rb @@ -0,0 +1,113 @@ +module DiscoursePoll + class PollsValidator + def initialize(post) + @post = post + end + + def validate_polls + polls = {} + + extracted_polls = DiscoursePoll::Poll::extract(@post.raw, @post.topic_id, @post.user_id) + + extracted_polls.each do |poll| + # polls should have a unique name + return false unless unique_poll_name?(polls, poll) + + # options must be unique + return false unless unique_options?(poll) + + # at least 2 options + return false unless at_least_two_options?(poll) + + # maximum # of options + return false unless valid_number_of_options?(poll) + + # poll with multiple choices + return false unless valid_multiple_choice_settings?(poll) + + # store the valid poll + polls[poll["name"]] = poll + end + + polls + end + + private + + def unique_poll_name?(polls, poll) + if polls.has_key?(poll["name"]) + if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME + @post.errors.add(:base, I18n.t("poll.multiple_polls_without_name")) + else + @post.errors.add(:base, I18n.t("poll.multiple_polls_with_same_name", name: poll["name"])) + end + + return false + end + + true + end + + def unique_options?(poll) + if poll["options"].map { |o| o["id"] }.uniq.size != poll["options"].size + if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME + @post.errors.add(:base, I18n.t("poll.default_poll_must_have_different_options")) + else + @post.errors.add(:base, I18n.t("poll.named_poll_must_have_different_options", name: poll["name"])) + end + + return false + end + + true + end + + def at_least_two_options?(poll) + if poll["options"].size < 2 + if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME + @post.errors.add(:base, I18n.t("poll.default_poll_must_have_at_least_2_options")) + else + @post.errors.add(:base, I18n.t("poll.named_poll_must_have_at_least_2_options", name: poll["name"])) + end + + return false + end + + true + end + + def valid_number_of_options?(poll) + if poll["options"].size > SiteSetting.poll_maximum_options + if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME + @post.errors.add(:base, I18n.t("poll.default_poll_must_have_less_options", count: SiteSetting.poll_maximum_options)) + else + @post.errors.add(:base, I18n.t("poll.named_poll_must_have_less_options", name: poll["name"], count: SiteSetting.poll_maximum_options)) + end + + return false + end + + true + end + + def valid_multiple_choice_settings?(poll) + if poll["type"] == "multiple" + num_of_options = poll["options"].size + min = (poll["min"].presence || 1).to_i + max = (poll["max"].presence || num_of_options).to_i + + if min > max || min <= 0 || max <= 0 || max > num_of_options || min >= num_of_options + if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME + @post.errors.add(:base, I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters")) + else + @post.errors.add(:base, I18n.t("poll.named_poll_with_multiple_choices_has_invalid_parameters", name: poll["name"])) + end + + return false + end + end + + true + end + end +end diff --git a/poll/lib/post_validator.rb b/poll/lib/post_validator.rb new file mode 100644 index 0000000..7301fdd --- /dev/null +++ b/poll/lib/post_validator.rb @@ -0,0 +1,22 @@ +module DiscoursePoll + class PostValidator + def initialize(post) + @post = post + end + + def validate_post + min_trust_level = SiteSetting.poll_minimum_trust_level_to_create + trusted = @post&.user&.staff? || + @post&.user&.trust_level >= TrustLevel[min_trust_level] + + if !trusted + message = I18n.t("poll.insufficient_rights_to_create") + + @post.errors.add(:base, message) + return false + end + + true + end + end +end diff --git a/poll/plugin.rb b/poll/plugin.rb new file mode 100644 index 0000000..57a90da --- /dev/null +++ b/poll/plugin.rb @@ -0,0 +1,411 @@ +# name: poll +# about: modified from official poll plugin for Discourse (not currently compatible with official poll plugin) +# url: https://github.com/crowdresearch/voting-plugin + +register_asset "stylesheets/common/poll.scss" +register_asset "stylesheets/common/poll-ui-builder.scss" +register_asset "stylesheets/desktop/poll.scss", :desktop +register_asset "stylesheets/mobile/poll.scss", :mobile + +PLUGIN_NAME ||= "discourse_poll".freeze + +DATA_PREFIX ||= "data-poll-".freeze + +after_initialize do + module ::DiscoursePoll + DEFAULT_POLL_NAME ||= "poll".freeze + POLLS_CUSTOM_FIELD ||= "polls".freeze + VOTES_CUSTOM_FIELD ||= "polls-votes".freeze + + autoload :PostValidator, "#{Rails.root}/plugins/poll/lib/post_validator" + autoload :PollsValidator, "#{Rails.root}/plugins/poll/lib/polls_validator" + autoload :PollsUpdater, "#{Rails.root}/plugins/poll/lib/polls_updater" + + class Engine < ::Rails::Engine + engine_name PLUGIN_NAME + isolate_namespace DiscoursePoll + end + end + + class DiscoursePoll::Poll + class << self + + def vote(post_id, poll_name, options, voter_group_id, user) + DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post_id}") do + user_id = user.id + post = Post.find_by(id: post_id) + + # post must not be deleted + if post.nil? || post.trashed? + raise StandardError.new I18n.t("poll.post_is_deleted") + end + + # topic must not be archived + if post.topic.try(:archived) + raise StandardError.new I18n.t("poll.topic_must_be_open_to_vote") + end + + polls = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] + + raise StandardError.new I18n.t("poll.no_polls_associated_with_this_post") if polls.blank? + + poll = polls[poll_name] + + raise StandardError.new I18n.t("poll.no_poll_with_this_name", name: poll_name) if poll.blank? + raise StandardError.new I18n.t("poll.poll_must_be_open_to_vote") if poll["status"] != "open" + public_poll = (poll["public"] == "true") + + # remove options that aren't available in the poll + available_options = poll["options"].map { |o| o["id"] }.to_set + options.select! { |o| available_options.include?(o) } + + raise StandardError.new I18n.t("poll.requires_at_least_1_valid_option") if options.empty? + + poll["voters"] = Hash.new(0) + all_options = Hash.new { |h, k| h[k] = Hash.new(0) } + + post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD] ||= {} + post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]["#{user_id}"] ||= {} + post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]["#{user_id}"][poll_name] ||= {} + post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]["#{user_id}"][poll_name]["options"] = options + post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]["#{user_id}"][poll_name]["voter_group_id"] = voter_group_id + + post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].each do |_, user_votes| + next unless votes = user_votes[poll_name] + + vg_id = votes["voter_group_id"] + + votes["options"].each do |option| + all_options[option][vg_id] += 1 + end + + poll["voters"][vg_id] += 1 if (available_options & votes["options"].to_set).size > 0 + end + + poll["options"].each do |option| + option["votes"] = all_options[option["id"]] + + if public_poll + option["voter_ids"] ||= [] + + if options.include?(option["id"]) + option["voter_ids"] << user_id if !option["voter_ids"].include?(user_id) + else + option["voter_ids"].delete(user_id) + end + end + end + + post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls + post.save_custom_fields(true) + + payload = { post_id: post_id, polls: polls } + + if public_poll + payload.merge!(user: UserNameSerializer.new(user).serializable_hash) + end + + MessageBus.publish("/polls/#{post.topic_id}", payload) + + return [poll, options] + end + end + + def toggle_status(post_id, poll_name, status, user_id) + DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post_id}") do + post = Post.find_by(id: post_id) + + # post must not be deleted + if post.nil? || post.trashed? + raise StandardError.new I18n.t("poll.post_is_deleted") + end + + # topic must not be archived + if post.topic.try(:archived) + raise StandardError.new I18n.t("poll.topic_must_be_open_to_toggle_status") + end + + user = User.find_by(id: user_id) + + # either staff member or OP + unless user_id == post.user_id || user.try(:staff?) + raise StandardError.new I18n.t("poll.only_staff_or_op_can_toggle_status") + end + + polls = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] + + raise StandardError.new I18n.t("poll.no_polls_associated_with_this_post") if polls.blank? + raise StandardError.new I18n.t("poll.no_poll_with_this_name", name: poll_name) if polls[poll_name].blank? + + polls[poll_name]["status"] = status + + post.save_custom_fields(true) + + MessageBus.publish("/polls/#{post.topic_id}", post_id: post.id, polls: polls) + + polls[poll_name] + end + end + + def extract(raw, topic_id, user_id = nil) + # TODO: we should fix the callback mess so that the cooked version is available + # in the validators instead of cooking twice + cooked = PrettyText.cook(raw, topic_id: topic_id, user_id: user_id) + parsed = Nokogiri::HTML(cooked) + + extracted_polls = [] + + # extract polls + parsed.css("div.poll").each do |p| + poll = { "options" => [], "voters" => Hash.new(0) } + + # extract attributes + p.attributes.values.each do |attribute| + if attribute.name.start_with?(DATA_PREFIX) + poll[attribute.name[DATA_PREFIX.length..-1]] = CGI.escapeHTML(attribute.value || "") + end + end + + # extract options + p.css("li[#{DATA_PREFIX}option-id]").each do |o| + option_id = o.attributes[DATA_PREFIX + "option-id"].value || "" + poll["options"] << { "id" => option_id, "html" => o.inner_html, "votes" => Hash.new(0) } + end + + # add the poll + extracted_polls << poll + end + + extracted_polls + end + end + end + + require_dependency "application_controller" + + class DiscoursePoll::PollsController < ::ApplicationController + requires_plugin PLUGIN_NAME + + before_action :ensure_logged_in, except: [:voters] + + def vote + post_id = params.require(:post_id) + poll_name = params.require(:poll_name) + options = params.require(:options) + voter_group_id = params.require(:voter_group_id) + + begin + poll, options = DiscoursePoll::Poll.vote(post_id, poll_name, options, voter_group_id, current_user) + render json: { poll: poll, vote: options } + rescue StandardError => e + render_json_error e.message + end + end + + def toggle_status + post_id = params.require(:post_id) + poll_name = params.require(:poll_name) + status = params.require(:status) + user_id = current_user.id + + begin + poll = DiscoursePoll::Poll.toggle_status(post_id, poll_name, status, user_id) + render json: { poll: poll } + rescue StandardError => e + render_json_error e.message + end + end + + def voters + post_id = params.require(:post_id) + poll_name = params.require(:poll_name) + + post = Post.find_by(id: post_id) + raise Discourse::InvalidParameters.new("post_id is invalid") if !post + + poll = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD][poll_name] + raise Discourse::InvalidParameters.new("poll_name is invalid") if !poll + + voter_limit = (params[:voter_limit] || 25).to_i + voter_limit = 0 if voter_limit < 0 + voter_limit = 50 if voter_limit > 50 + + user_ids = [] + options = poll["options"] + + if poll["type"] != "number" + + per_option_voters = {} + + options.each do |option| + if (params[:option_id]) + next unless option["id"] == params[:option_id].to_s + end + + next unless option["voter_ids"] + voters = option["voter_ids"].slice((params[:offset].to_i || 0) * voter_limit, voter_limit) + per_option_voters[option["id"]] = Set.new(voters) + user_ids << voters + end + + user_ids.flatten! + user_ids.uniq! + + poll_votes = post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD] + + result = {} + + User.where(id: user_ids).map do |user| + user_hash = UserNameSerializer.new(user).serializable_hash + + poll_votes[user.id.to_s][poll_name].each do |option_id| + if (params[:option_id]) + next unless option_id == params[:option_id].to_s + end + + voters = per_option_voters[option_id] + # we may have a user from a different vote + next unless voters.include?(user.id) + + result[option_id] ||= [] + result[option_id] << user_hash + end + end + else + user_ids = options.map { |option| option["voter_ids"] }.sort! + user_ids.flatten! + user_ids.uniq! + user_ids = user_ids.slice((params[:offset].to_i || 0) * voter_limit, voter_limit) + + result = [] + + User.where(id: user_ids).map do |user| + result << UserNameSerializer.new(user).serializable_hash + end + end + + render json: { poll_name => result } + end + end + + DiscoursePoll::Engine.routes.draw do + put "/vote" => "polls#vote" + put "/toggle_status" => "polls#toggle_status" + get "/voters" => 'polls#voters' + end + + Discourse::Application.routes.append do + mount ::DiscoursePoll::Engine, at: "/polls" + end + + Post.class_eval do + attr_accessor :polls + + after_save do + next if self.polls.blank? || !self.polls.is_a?(Hash) + + post = self + polls = self.polls + + DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post.id}") do + post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls + post.save_custom_fields(true) + end + end + end + + validate(:post, :validate_polls) do |force = nil| + # only care when raw has changed! + return unless self.raw_changed? || force + + validator = DiscoursePoll::PollsValidator.new(self) + return unless (polls = validator.validate_polls) + + if !polls.empty? + validator = DiscoursePoll::PostValidator.new(self) + return unless validator.validate_post + end + + # are we updating a post? + if self.id.present? + DistributedMutex.synchronize("#{PLUGIN_NAME}-#{self.id}") do + DiscoursePoll::PollsUpdater.update(self, polls) + end + else + self.polls = polls + end + + true + end + + NewPostManager.add_handler(1) do |manager| + post = Post.new(raw: manager.args[:raw]) + + if !DiscoursePoll::PollsValidator.new(post).validate_polls + result = NewPostResult.new(:poll, false) + + post.errors.full_messages.each do |message| + result.errors[:base] << message + end + + result + else + manager.args["is_poll"] = true + nil + end + end + + on(:approved_post) do |queued_post, created_post| + if queued_post.post_options["is_poll"] + created_post.validate_polls(true) + end + end + + register_post_custom_field_type(DiscoursePoll::POLLS_CUSTOM_FIELD, :json) + register_post_custom_field_type(DiscoursePoll::VOTES_CUSTOM_FIELD, :json) + + topic_view_post_custom_fields_whitelister do |user| + user ? [DiscoursePoll::POLLS_CUSTOM_FIELD, DiscoursePoll::VOTES_CUSTOM_FIELD] : [DiscoursePoll::POLLS_CUSTOM_FIELD] + end + + on(:reduce_cooked) do |fragment, post| + if post.nil? || post.trashed? + fragment.css(".poll, [data-poll-name]").each(&:remove) + else + post_url = "#{Discourse.base_url}#{post.url}" + fragment.css(".poll, [data-poll-name]").each do |poll| + poll.replace "

#{I18n.t("poll.email.link_to_poll")}

" + end + end + end + + # tells the front-end we have a poll for that post + on(:post_created) do |post| + next if post.is_first_post? || post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].blank? + MessageBus.publish("/polls/#{post.topic_id}", post_id: post.id, + polls: post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]) + end + + add_to_serializer(:post, :polls, false) do + polls = post_custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].dup + + polls.each do |_, poll| + poll["options"].each do |option| + option.delete("voter_ids") + end + end + end + + add_to_serializer(:post, :include_polls?) { post_custom_fields.present? && post_custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].present? } + + add_to_serializer(:post, :polls_votes, false) do + post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]["#{scope.user.id}"] + end + + add_to_serializer(:post, :include_polls_votes?) do + return unless scope.user + return unless post_custom_fields.present? + return unless post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].present? + post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].has_key?("#{scope.user.id}") + end +end