diff --git a/languages/cs/poll.json b/languages/cs/poll.json index 428780c..67d92c1 100755 --- a/languages/cs/poll.json +++ b/languages/cs/poll.json @@ -23,7 +23,7 @@ "auto_end_placeholder": "Kliknutím vyberete datum a čas", "auto_end_help": "Není-li vyplňeno, hlasování nebude ukončeno nikdy", - "error.max_options": "Hlasování může obsahovat maximálně %d možností.", + "error.max_options": "Hlasování může obsahovat maximálně %1 možností.", "error.no_options": "Přidejte alespoň jednu možnost.", "error.valid_date": "Vyplňte prosím platné datum.", diff --git a/languages/de/poll.json b/languages/de/poll.json index bf6b7bb..90e5f90 100644 --- a/languages/de/poll.json +++ b/languages/de/poll.json @@ -22,7 +22,7 @@ "auto_end_placeholder": "Datum und Zeit eingeben", "auto_end_help": "Lasse dieses Feld leer, wenn die Umfrage niemals enden soll.", - "error.max_options": "Du kannst höchstens %d Optionen angeben.", + "error.max_options": "Du kannst höchstens %1 Optionen angeben.", "error.no_options": "Erstelle mindestens eine Option.", "error.valid_date": "Bitte gib ein gültiges Datum an.", diff --git a/languages/en_US/poll.json b/languages/en_US/poll.json index 66d98c1..046081d 100755 --- a/languages/en_US/poll.json +++ b/languages/en_US/poll.json @@ -24,7 +24,7 @@ "auto_end_placeholder": "Click to enter date and time", "auto_end_help": "Leaving this empty will never end the poll.", - "error.max_options": "You can only create %d options.", + "error.max_options": "You can only create %1 options.", "error.no_options": "Create at least one option.", "error.valid_date": "Please enter a valid date.", diff --git a/languages/es/poll.json b/languages/es/poll.json index aed3da5..6a95385 100644 --- a/languages/es/poll.json +++ b/languages/es/poll.json @@ -22,7 +22,7 @@ "auto_end_placeholder": "Click para introducir fecha y hora", "auto_end_help": "Dejar esta opción vacia hará que la encuesta no acabe nunca.", - "error.max_options": "Solo puedes crear %d opciones.", + "error.max_options": "Solo puedes crear %1 opciones.", "error.no_options": "Crea una opción al menos.", "error.valid_date": "Por favor introduce una fecha valida.", diff --git a/languages/fr/poll.json b/languages/fr/poll.json index 37d240b..f49d852 100755 --- a/languages/fr/poll.json +++ b/languages/fr/poll.json @@ -22,7 +22,7 @@ "auto_end_title": "Terminer le sondage automatiquement", "auto_end_placeholder": "Cliquez pour entrer la date et l'heure", "auto_end_help": "Laisser ce champ vide pour que le sondage ne se termine jamais.", - "error.max_options": "Vous ne pouvez créer que %d options.", + "error.max_options": "Vous ne pouvez créer que %1 options.", "error.no_options": "Créez au moins une option.", "error.valid_date": "Veuillez entrer une date valide.", "error.not_main": "Le sondage ne peut être ajouté que dans le post principal.", diff --git a/languages/gl/poll.json b/languages/gl/poll.json index dc8c926..a523750 100644 --- a/languages/gl/poll.json +++ b/languages/gl/poll.json @@ -22,7 +22,7 @@ "auto_end_placeholder": "Fai clic aquí para introduci-la data e a hora", "auto_end_help": "Deixar esta opción baleira fará que a enquisa non remate nunca.", - "error.max_options": "You can only create %d options.", + "error.max_options": "You can only create %1 options.", "error.no_options": "Create at least one option.", "error.valid_date": "Please enter a valid date.", diff --git a/languages/he/poll.json b/languages/he/poll.json index 9351cb9..4f5b38e 100644 --- a/languages/he/poll.json +++ b/languages/he/poll.json @@ -23,7 +23,7 @@ "auto_end_placeholder": "הקש להגדיר תאריך ושעה", "auto_end_help": "באם לא נבחר תאריך הסקר לא יפוג לעולם", - "error.max_options": "אתה יכול ליצור רק %d אפשרויות בחירה", + "error.max_options": "אתה יכול ליצור רק %1 אפשרויות בחירה", "error.no_options": "צור אפשרות אחת לפחות", "error.valid_date": "נא להזין תאריך חוקי", diff --git a/languages/it/poll.json b/languages/it/poll.json index b91398b..e824c82 100644 --- a/languages/it/poll.json +++ b/languages/it/poll.json @@ -22,7 +22,7 @@ "auto_end_placeholder": "Clicca per inserire data e ora", "auto_end_help": "Lasciare vuoto per non far terminare il sondaggio", - "error.max_options": "Puoi creare al massimo %d opzioni.", + "error.max_options": "Puoi creare al massimo %1 opzioni.", "error.no_options": "Crea almeno una opzione.", "error.valid_date": "Inserisci una data valida.", diff --git a/languages/pl/poll.json b/languages/pl/poll.json index 6cef12e..5d1b705 100644 --- a/languages/pl/poll.json +++ b/languages/pl/poll.json @@ -24,7 +24,7 @@ "auto_end_placeholder": "Kliknij, aby wprowadzić datę i godzinę", "auto_end_help": "Pozostaw pole puste, aby sonda trwała bez końca.", - "error.max_options": "Możesz utworzyć nie więcej niż %d opcji.", + "error.max_options": "Możesz utworzyć nie więcej niż %1 opcji.", "error.no_options": "Wymagana jest co najmniej jedna opcja.", "error.valid_date": "Wprowadź poprawną datę.", diff --git a/languages/pt-PT/poll.json b/languages/pt-PT/poll.json index 3b1eedb..aaaea7e 100644 --- a/languages/pt-PT/poll.json +++ b/languages/pt-PT/poll.json @@ -22,7 +22,7 @@ "auto_end_placeholder": "Clica para escolheres uma data e uma hora", "auto_end_help": "Deixar este campo vazio irá fazer com que a votação nunca encerre.", - "error.max_options": "Apenas podes criar %d opções.", + "error.max_options": "Apenas podes criar %1 opções.", "error.no_options": "Primeiro tens de criar pelo menos uma opção.", "error.valid_date": "Por favor introduz uma data válida.", diff --git a/languages/ro/poll.json b/languages/ro/poll.json index a55ec8a..0959c81 100644 --- a/languages/ro/poll.json +++ b/languages/ro/poll.json @@ -22,7 +22,7 @@ "auto_end_placeholder": "Introduceți data si ora", "auto_end_help": "Lăsați acest câmp liber dacă sondajul nu are termen.", - "error.max_options": "Puteți introduce un maxim de %d opțiuni.", + "error.max_options": "Puteți introduce un maxim de %1 opțiuni.", "error.no_options": "Introduceți ce-l puțin o opțiune.", "error.valid_date": "Vă rog introduceți o dată valabilă.", diff --git a/languages/ru/poll.json b/languages/ru/poll.json index 0f6b6ee..ebff45e 100755 --- a/languages/ru/poll.json +++ b/languages/ru/poll.json @@ -23,7 +23,7 @@ "auto_end_placeholder": "Нажмите, чтобы ввести дату и время", "auto_end_help": "Если оставить данное поле пустым, то опрос будет всегда открыт.", - "error.max_options": "Вы можете создать только %d опций.", + "error.max_options": "Вы можете создать только %1 опций.", "error.no_options": "Создайте хотя бы один вариант ответа.", "error.valid_date": "Введите корректную дату.", diff --git a/languages/tr/poll.json b/languages/tr/poll.json index a5e3431..5953c0a 100644 --- a/languages/tr/poll.json +++ b/languages/tr/poll.json @@ -23,7 +23,7 @@ "auto_end_placeholder": "Tarih ve saat bilgisini buraya girin", "auto_end_help": "Burayı boş bırakırsanız anket hiç sonlanmayacak.", - "error.max_options": "Sadece %d seçenek oluşturabilirsiniz.", + "error.max_options": "Sadece %1 seçenek oluşturabilirsiniz.", "error.no_options": "En az bir seçenek oluşturmalısınız", "error.valid_date": "Lütfen geçerli bir tarih girin", diff --git a/lib/config.js b/lib/config.js index ff5fb9e..1b93569 100755 --- a/lib/config.js +++ b/lib/config.js @@ -28,9 +28,20 @@ Config.defaults = { Config.getSettings = async function () { const settings = await meta.settings.get(Config.plugin.id); - return { + const merged = { ...Config.defaults, ...settings, }; + // NodeBB's settings helper stores checkboxes as 'on'/'off'; normalize to a boolean + // so parseInt(...) === 1 checks don't silently fail. + merged.allowGuestsToViewResults = ['1', 1, 'on', true].includes(merged.allowGuestsToViewResults); + // NodeBB's settings helper stores blank number fields as ''; fall back to defaults + // so an empty ACP field doesn't override the configured default. + merged.maxOptions = parseInt(merged.maxOptions, 10) || Config.defaults.maxOptions; + merged.maximumVotesPerUser = parseInt(merged.maximumVotesPerUser, 10) || Config.defaults.maximumVotesPerUser; + // NodeBB's settings helper stores blank text fields as ''; fall back to the + // configured default so an empty ACP field doesn't override the built-in default. + merged.defaultTitle = merged.defaultTitle || Config.defaults.defaultTitle; + return merged; }; diff --git a/lib/hooks.js b/lib/hooks.js index 545d499..97d2ff6 100755 --- a/lib/hooks.js +++ b/lib/hooks.js @@ -44,7 +44,6 @@ Hooks.filter.postCreate = async function (hookData) { // post is going to be saved to db, data is what is submitted by user const { post, data } = hookData; if (Array.isArray(data?.polls) && data.polls.length) { - await checkMaxOptions(data.polls); const savedPolls = await Poll.add(post, data.polls); if (savedPolls.length) { post.pollIds = JSON.stringify(savedPolls.map(p => String(p.pollId))); @@ -74,7 +73,6 @@ Hooks.filter.postEdit = async function (hookData) { return hookData; } const pollData = hookData.data.polls || []; - await checkMaxOptions(pollData); const toAdd = pollData.filter(p => !currentPollIds.includes(String(p.pollId))); if (toAdd.length) { @@ -136,15 +134,3 @@ async function canCreate(cid, uid) { } } -async function checkMaxOptions(polls) { - if (!Array.isArray(polls) || !polls.length) { - return; - } - const { maxOptions } = await Config.getSettings(); - polls.forEach((poll) => { - if (poll && poll.options.length > maxOptions) { - throw new Error(`[[poll:error.max_options, ${maxOptions}]]`); - } - }); -} - diff --git a/lib/poll.js b/lib/poll.js index c3f955d..5d1aa7a 100755 --- a/lib/poll.js +++ b/lib/poll.js @@ -3,6 +3,7 @@ const db = nodebb.require('./src/database'); const privileges = nodebb.require('./src/privileges'); +const Config = require('./config'); const Vote = require('./vote'); const Poll = exports; @@ -10,6 +11,7 @@ const Poll = exports; Poll.add = async function (postData, polls) { // postData is what is being saved to db // polls is submited by user + await validateMaxOptions(polls); const savePolls = []; for (const pollData of polls) { // eslint-disable-next-line no-await-in-loop @@ -75,6 +77,9 @@ Poll.edit = async function (postData, newPolls) { const toDelete = currentPollIds.filter(pollId => !newPollIds.includes(String(pollId))); const currentPolls = await db.getObjects(currentPollIds.map(pollId => `poll:${pollId}`)); + await validateMaxOptions(toAdd); + await validateMaxOptionsForEdit(toUpdate, currentPolls); + await Poll.deletePolls(toDelete); const savedPolls = await Poll.add(postData, toAdd); @@ -245,6 +250,46 @@ function tryParseOptions(optionsJson) { } } +async function validateMaxOptions(polls) { + if (!Array.isArray(polls) || !polls.length) { + return; + } + const settings = await Config.getSettings(); + const { maxOptions } = settings; + polls.forEach((poll) => { + if (!poll) { + return; + } + const optionCount = Array.isArray(poll.options) ? poll.options.length : 0; + if (optionCount > maxOptions) { + throw new Error(`[[poll:error.max_options, ${maxOptions}]]`); + } + }); +} + +async function validateMaxOptionsForEdit(updatedPolls, currentPolls) { + if (!Array.isArray(updatedPolls) || !updatedPolls.length) { + return; + } + const settings = await Config.getSettings(); + const { maxOptions } = settings; + updatedPolls.forEach((poll) => { + if (!poll) { + return; + } + const newOptionCount = Array.isArray(poll.options) ? poll.options.length : 0; + if (newOptionCount <= maxOptions) { + return; + } + const currentPoll = currentPolls.find(p => p && String(p.pollId) === String(poll.pollId)); + const currentOptions = currentPoll ? tryParseOptions(currentPoll.options) : []; + const currentOptionCount = Array.isArray(currentOptions) ? currentOptions.length : 0; + if (newOptionCount > currentOptionCount) { + throw new Error(`[[poll:error.max_options, ${maxOptions}]]`); + } + }); +} + Poll.getOption = async function (pollId, option, withVotes = false) { const [optionsJson, votes, voteCount] = await Promise.all([ db.getObjectField(`poll:${pollId}`, 'options'), diff --git a/lib/sockets.js b/lib/sockets.js index 8e09a35..1791356 100755 --- a/lib/sockets.js +++ b/lib/sockets.js @@ -15,7 +15,7 @@ const Sockets = exports; Sockets.get = async function (socket, data) { const settings = await Config.getSettings(); - const allowAnon = settings.allowGuestsToViewResults === 'on'; + const allowAnon = !!settings.allowGuestsToViewResults; if (!data) { throw new Error('Invalid request, request data is not defined'); @@ -27,13 +27,13 @@ Sockets.get = async function (socket, data) { return []; } return await Promise.all(data.pollIds.map(async (pollId) => { - const withVotes = allowAnon || socket.uid > 0; + const withVotes = !!socket.uid; const pollData = await Poll.get(pollId, socket.uid, withVotes); if (!pollData) { return null; } - if (parseInt(pollData.info.allowAnonVoting, 10) === 1) { + if (withVotes && parseInt(pollData.info.allowAnonVoting, 10) === 1) { await anonymizeVoters(socket.uid, pollId, pollData.options); } @@ -145,20 +145,18 @@ Sockets.removeVote = async function (socket, data) { }; Sockets.getOptionDetails = async function (socket, data) { - if (!data || isNaN(parseInt(data.pollId, 10)) || isNaN(parseInt(data.optionId, 10))) { + if (!socket.uid || !data || isNaN(parseInt(data.pollId, 10)) || isNaN(parseInt(data.optionId, 10))) { throw new Error('Invalid request'); } - const [poll, option, settings] = await Promise.all([ + const [poll, option] = await Promise.all([ Poll.getInfo(data.pollId), Poll.getOption(data.pollId, data.optionId, true), - Config.getSettings(), ]); if (!poll) { throw new Error('[[error:invalid-data]]'); } - const allowAnon = settings.allowGuestsToViewResults === 'on'; const canRead = await privileges.posts.can('topics:read', poll.pid, socket.uid); - if (!canRead || (!allowAnon && socket.uid <= 0)) { + if (!canRead) { throw new Error('[[error:no-privileges]]'); } if (!option || !option.votes || !option.votes.length) { @@ -170,7 +168,7 @@ Sockets.getOptionDetails = async function (socket, data) { } const userData = await NodeBB.User.getUsersFields(option.votes, [ - 'uid', 'username', 'userslug', 'picture', + 'uid', 'username', 'userslug', 'picture', 'displayname', 'icon:text', 'icon:bgColor', ]); option.votes = userData; @@ -205,7 +203,7 @@ async function anonymizeVoters(callerUid, pollId, options) { } const uidToIsAnon = _.zipObject(uids, isAnon); options.forEach((opt) => { - opt.votes = opt.votes.map( + opt.votes = (opt.votes || []).map( uid => (uidToIsAnon[uid] && String(callerUid) !== uid ? 0 : uid) ); }); diff --git a/library.js b/library.js index c319fc6..f7b3262 100755 --- a/library.js +++ b/library.js @@ -92,7 +92,7 @@ Plugin.renderPollWidget = async function (widget) { const Poll = require('./lib/poll'); const settings = await Config.getSettings(); - const allowAnon = settings.allowGuestsToViewResults === 'on'; + const allowAnon = !!settings.allowGuestsToViewResults; if (!widget.uid && !allowAnon) { return null; diff --git a/public/js/poll/creator.js b/public/js/poll/creator.js index d614ef1..f2f3af9 100755 --- a/public/js/poll/creator.js +++ b/public/js/poll/creator.js @@ -46,7 +46,6 @@ const html = await Benchpress.render('poll/manage', { post: postData }); if (modal) { - // modal.find('.bootbox-body').html(html[0].outerHTML); modal.find('.bootbox-body').html(html); handleSort({ modal, polls: postData.polls }); } else { @@ -158,7 +157,16 @@ Creator.show = function (modalTitle, poll, config) { return new Promise((resolve) => { app.require(['benchpressjs', 'modals']).then(([Benchpress, modals]) => { - Benchpress.render('poll/creator', { poll, config }).then(async function (html) { + const pollForTemplate = { ...poll }; + const end = parseInt(pollForTemplate.end, 10); + if (end > 0) { + const d = new Date(end); + const pad = n => String(n).padStart(2, '0'); + pollForTemplate.end = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + } else { + pollForTemplate.end = ''; + } + Benchpress.render('poll/creator', { poll: pollForTemplate, config }).then(async function (html) { const modal = await modals.dialog({ title: modalTitle, message: html, @@ -190,14 +198,15 @@ }, }); + setTimezoneLabel(modal); handleOptionSort({ modal }); // Add option adder modal.find('#pollAddOption') .off('click') .on('click', async function () { - const optionEls = modal.find('[component="post/poll/option/item"]'); - if (config.maxOptions <= optionEls.length) { + const optionCount = modal.find('#poll-options-container [component="post/poll/option/item"]').length; + if (config.maxOptions <= optionCount) { clearErrors(); error(`[[poll:error.max_options, ${config.maxOptions}]]`); return false; @@ -230,6 +239,14 @@ }); } + function setTimezoneLabel(modal) { + const offsetMinutes = -new Date().getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? '+' : '-'; + const hours = String(Math.floor(Math.abs(offsetMinutes) / 60)).padStart(2, '0'); + const minutes = String(Math.abs(offsetMinutes) % 60).padStart(2, '0'); + modal.find('[data-timezone-label]').text(`(UTC${sign}${hours}:${minutes})`); + } + async function error(message) { const errorBox = $('#pollErrorBox'); const translator = await app.require('translator'); diff --git a/public/js/poll/main.js b/public/js/poll/main.js index 8d5aca5..8993a75 100755 --- a/public/js/poll/main.js +++ b/public/js/poll/main.js @@ -71,16 +71,27 @@ $(document).ready(function () { }); function getPollByPost(post) { - if (!post || !post.hasOwnProperty('pollIds') || !post.pollIds) return; - let pollIds; - try { - pollIds = JSON.parse(post.pollIds || '[]'); - } catch (err) { - pollIds = []; + if (!post) return; + let pollIds = []; + if (post.pollIds) { + try { + pollIds = JSON.parse(post.pollIds); + } catch (err) { + pollIds = []; + } } + const postEl = $(`[component="post"][data-pid="${post.pid}"]`); + const container = postEl.find('[component="post/content"]'); + const validPollIdSet = new Set((pollIds || []).map(id => String(id))); + container.find('[data-poll-id]').each(function () { + const existingPollId = $(this).attr('data-poll-id'); + if (!validPollIdSet.has(existingPollId)) { + $(this).remove(); + delete Poll.view.polls[existingPollId]; + } + }); if (pollIds.length) { - const postEl = $(`[component="post"][data-pid="${post.pid}"]`); - getPolls(pollIds, postEl.find('[component="post/content"]')); + getPolls(pollIds, container); } } diff --git a/public/js/poll/view.js b/public/js/poll/view.js index c44c582..98cd19a 100644 --- a/public/js/poll/view.js +++ b/public/js/poll/view.js @@ -129,6 +129,9 @@ { // Option details register: function (view) { + if (!app.user.uid) { + return; + } const self = this; view.dom.resultsPanel.off('click').on('click', '.poll-result-votecount', function (e) { self.handle(view, e); @@ -201,7 +204,7 @@ }; View.prototype.voteUpdateAllowed = function () { - return parseInt(this.pollData.disallowVoteUpdate, 10) !== 1; + return parseInt(this.pollData.info.disallowVoteUpdate, 10) !== 1; }; View.prototype.pollEndedOrDeleted = function () { @@ -257,20 +260,26 @@ this.dom.messages.addClass('hidden'); }; - View.prototype.showOptionDetails = function (details) { - require(['benchpress', 'modals'], function (benchpress, modals) { - benchpress.render('poll/view/details', details).then(function (html) { - modals.dialog({ - title: `[[poll:x-users-voted-for-this-option, ${details.voteCount}]]`, - message: html, - backdrop: true, - buttons: { - close: { - label: 'Close', - }, - }, - }); - }); + View.prototype.showOptionDetails = async function (details) { + const [modals, Benchpress] = await app.require(['modals', 'benchpressjs']); + if (details.votes) { + const translator = await app.require('translator'); + await Promise.all(details.votes.map(async (voter) => { + if (!parseInt(voter.uid, 10) && voter.displayname) { + voter.displayname = await translator.translate(voter.displayname); + } + })); + } + const html = await Benchpress.render('poll/view/details', details); + await modals.dialog({ + title: `[[poll:x-users-voted-for-this-option, ${details.voteCount}]]`, + message: html, + backdrop: true, + buttons: { + close: { + label: '[[global:close]]', + }, + }, }); }; @@ -301,7 +310,7 @@ } } else { this.showVoteButton(); - if (this.pollData?.info?.allowAnonVoting) { + if (this.pollData.info.allowAnonVoting) { this.showVoteAnonButton(); } } diff --git a/templates/admin/plugins/poll.tpl b/templates/admin/plugins/poll.tpl index 44f2720..b9afbda 100755 --- a/templates/admin/plugins/poll.tpl +++ b/templates/admin/plugins/poll.tpl @@ -4,7 +4,7 @@
[[poll:info_choices]]
+ + +{{tx("poll:info_choices")}}
[[poll:auto_end_help]]
+{{tx("poll:auto_end_help")}}
{content}
+{{tx(content)}}