Index: trunk/extensions/QPoll/i18n/qp.i18n.php |
— | — | @@ -102,6 +102,8 @@ |
103 | 103 | 'qp_error_no_stats' => 'No statistical data is available, because no one has voted for this poll, yet (address=$1).', |
104 | 104 | 'qp_error_address_in_decl_mode' => 'Cannot get an address of the poll in declaration mode.', |
105 | 105 | 'qp_error_question_not_implemented' => 'Questions of such type are not implemented: $1.', |
| 106 | + 'qp_error_question_empty_body' => 'Question body is empty.', |
| 107 | + 'qp_error_question_no_proposals' => 'Question does not have any proposals defined.', |
106 | 108 | 'qp_error_invalid_question_type' => 'Invalid question type: $1.', |
107 | 109 | 'qp_error_invalid_question_name' => 'Invalid question name: $1.', |
108 | 110 | 'qp_error_type_in_stats_mode' => 'Question type cannot be defined in statistical display mode: $1.', |
— | — | @@ -126,10 +128,12 @@ |
127 | 129 | 'qp_error_too_long_category_options_values' => 'Category options values are too long to be stored in the database.', |
128 | 130 | 'qp_error_too_long_proposal_text' => 'Proposal text is too long to be stored in the database.', |
129 | 131 | 'qp_error_too_long_proposal_name' => 'Proposal name is too long to be stored in the database.', |
130 | | - 'qp_error_invalid_proposal_name' => 'Proposal name cannot be numeric.', |
| 132 | + 'qp_error_multiline_proposal_name' => 'Proposal name cannot contain multiple text lines.', |
| 133 | + 'qp_error_numeric_proposal_name' => 'Proposal name cannot be numeric.', |
131 | 134 | 'qp_error_too_few_categories' => 'At least two categories must be defined.', |
132 | 135 | 'qp_error_too_few_spans' => 'Every category group must contain at least two subcategories.', |
133 | 136 | 'qp_error_no_answer' => 'Unanswered proposal.', |
| 137 | + 'qp_error_not_enough_categories_answered' => 'Not enough categories selected.', |
134 | 138 | 'qp_error_unique' => 'Question of type unique() has more proposals than possible answers defined: Impossible to complete.', |
135 | 139 | 'qp_error_no_more_attempts' => 'You have reached maximal number of submitting attempts for this poll.', |
136 | 140 | 'qp_error_no_interpretation' => 'Interpretation script does not exist.', |
— | — | @@ -234,6 +238,8 @@ |
235 | 239 | 'qp_error_dependance_in_stats_mode' => 'Poll "dependance" attribute is meaningless in statistical display mode.', |
236 | 240 | 'qp_error_address_in_decl_mode' => 'Poll "address" attribute is meaningless in poll declaration / voting mode.', |
237 | 241 | 'qp_error_question_not_implemented' => 'Invalid value of qustion xml-like "type" attribute was specified. There is no such type of question. Please read the manual for list of valid question types.', |
| 242 | + 'qp_error_question_empty_body' => 'Question body contains no proposals / categories definition.', |
| 243 | + 'qp_error_question_no_proposals' => 'Question body contains no proposals definition, there is nothing to vote for.', |
238 | 244 | 'qp_error_invalid_question_type' => '{{Identical|Invalid value of qustion xml-like "type" attribute was specified. There is no such type of question. Please read the manual for list of valid question types.}}', |
239 | 245 | 'qp_error_invalid_question_name' => '{{Identical|Invalid value of qustion xml-like "name" attribute was specified. Numeric names are not allowed due to possible index clash with integer question ids. Empty names are not allowed as impossible to reference. Too long names are not allowed, otherwise they will be improperly truncated when stored into DB field.}}', |
240 | 246 | 'qp_error_type_in_stats_mode' => 'Question\'s "type" xml-like attribute is meaningless in statistical display mode.', |
— | — | @@ -250,8 +256,10 @@ |
251 | 257 | 'qp_error_too_long_category_options_values' => 'Question type="text" categories with more than one text option to chose are displayed as html select/options list. Submitted (chosen) options values are stored in the database field. If the total length of chosen values is too long, some of the values will be partially lost and select/options will not be properly highlighted. That\'s why the length limit is enforced.', |
252 | 258 | 'qp_error_too_long_proposal_text' => "Question type=\"text\" stores it's proposal parts and category definitions in 'proposal_text' field of database table, serialized. If serialized data is longer than database table field length, some of data will be lost and unserialization will be impossible.", |
253 | 259 | 'qp_error_too_long_proposal_name' => "Proposal name is defined to be used in interpretation scripts. It is stored in 'proposal_text' field of database table in such case. When the length of proposal name overflows the field length, the name will be truncated, and proposal will not be addressable by it's name in the interpretation script.", |
254 | | - 'qp_error_invalid_proposal_name' => 'Proposal name should not be numeric to avoid possible reference clash with proposal ids, which are integer numbers.', |
| 260 | + 'qp_error_multiline_proposal_name' => 'Proposal name should not contain next characters: line feed and carriage return.', |
| 261 | + 'qp_error_numeric_proposal_name' => 'Proposal name should not be numeric to avoid possible reference clash with proposal ids, which are integer numbers.', |
255 | 262 | 'qp_error_too_few_spans' => 'Every category group should include at least two subcategories', |
| 263 | + 'qp_error_not_enough_categories_answered' => 'Current proposal\'s "catreq" attribute or it\'s inherited value of poll / question "catreq" attribute requires more than one category to be selected in the current proposal.', |
256 | 264 | 'qp_error_no_interpretation' => 'Title of interpretation script was specified in poll header, but no article was found with that title. Either remove "interpretation" xml attribute of poll or create the title specified by "interpretation" attribute.', |
257 | 265 | 'qp_error_interpretation_no_return' => 'Interpretation script missed an return statement.', |
258 | 266 | 'qp_error_structured_interpretation_is_too_long' => "Structured interpretation is serialized string containing scalar value or an associative array stored into database table field. It's purpose is to have measurable, easily processable interpretation result for the particular poll which then can be processed by external tools (via XLS export) or, to be read and processed by next poll interpretation script (data import and in the future maybe an export as well). When the serialized string is too long, it should never be stored, otherwise it will be truncated by DBMS so it cannot be properly unserialized later.", |
— | — | @@ -881,7 +889,7 @@ |
882 | 890 | 'qp_error_too_long_category_options_values' => 'Die Werte der Kategorieoptionen sind zu lang, um in der Datenbank gespeichert werden zu können.', |
883 | 891 | 'qp_error_too_long_proposal_text' => 'Der vorgeschlagene Text ist zu lang, um in der Datenbank gespeichert werden zu können.', |
884 | 892 | 'qp_error_too_long_proposal_name' => 'Der vorgeschlagene Name ist zu lang, um in der Datenbank gespeichert werden zu können.', |
885 | | - 'qp_error_invalid_proposal_name' => 'Der vorgeschlagene Name darf nicht ausschließlich aus Zahlen bestehen.', |
| 893 | + 'qp_error_numeric_proposal_name' => 'Der vorgeschlagene Name darf nicht ausschließlich aus Zahlen bestehen.', |
886 | 894 | 'qp_error_too_few_categories' => 'Es müssen mindestens zwei Kategorien festgelegt werden.', |
887 | 895 | 'qp_error_too_few_spans' => 'Jede Kategoriengruppe muss mindestens zwei Unterkategorien enthalten.', |
888 | 896 | 'qp_error_no_answer' => 'Unbeantworteter Vorschlag', |
— | — | @@ -1204,7 +1212,7 @@ |
1205 | 1213 | 'qp_error_too_long_category_options_values' => 'Les valeurs des options catégorie sont trop longues pour être stockées dans la base de données.', |
1206 | 1214 | 'qp_error_too_long_proposal_text' => 'Le texte de la proposition est trop long pour être enregistré dans la base de données', |
1207 | 1215 | 'qp_error_too_long_proposal_name' => 'Le nom de la proposition est trop long pour être stocké dans la base de données.', |
1208 | | - 'qp_error_invalid_proposal_name' => 'Le nom de la proposition ne peut pas être numérique.', |
| 1216 | + 'qp_error_numeric_proposal_name' => 'Le nom de la proposition ne peut pas être numérique.', |
1209 | 1217 | 'qp_error_too_few_categories' => 'Au moins deux catégories doivent être définies', |
1210 | 1218 | 'qp_error_too_few_spans' => 'Toute classe de catégorie nécessite au moins deux réponses possibles définies', |
1211 | 1219 | 'qp_error_no_answer' => 'Proposition sans réponse', |
— | — | @@ -1356,7 +1364,7 @@ |
1357 | 1365 | 'qp_error_too_long_category_options_values' => 'Os valores das opcións da categoría son longos de máis para almacenalos na base de datos.', |
1358 | 1366 | 'qp_error_too_long_proposal_text' => 'O texto da proposta é longo de máis para almacenalo na base de datos.', |
1359 | 1367 | 'qp_error_too_long_proposal_name' => 'O nome da proposta é longo de máis para almacenalo na base de datos.', |
1360 | | - 'qp_error_invalid_proposal_name' => 'O nome da proposta non pode ser numérico.', |
| 1368 | + 'qp_error_numeric_proposal_name' => 'O nome da proposta non pode ser numérico.', |
1361 | 1369 | 'qp_error_too_few_categories' => 'Débense definir, polo menos, dúas categorías.', |
1362 | 1370 | 'qp_error_too_few_spans' => 'Cada clase de categoría necesita definidas, polo menos, dúas respostas posibles.', |
1363 | 1371 | 'qp_error_no_answer' => 'Proposta sen resposta.', |
— | — | @@ -1755,7 +1763,7 @@ |
1756 | 1764 | 'qp_error_too_long_category_options_values' => 'Le valores del optiones de categoria es troppo longe pro esser immagazinate in le base de datos.', |
1757 | 1765 | 'qp_error_too_long_proposal_text' => 'Le texto del proposition es troppo longe pro poter esser immagazinate in le base de datos', |
1758 | 1766 | 'qp_error_too_long_proposal_name' => 'Le nomine del proposition es troppo longe pro poter esser immagazinate in le base de datos.', |
1759 | | - 'qp_error_invalid_proposal_name' => 'Le nomine del proposition non pote esser numeric.', |
| 1767 | + 'qp_error_numeric_proposal_name' => 'Le nomine del proposition non pote esser numeric.', |
1760 | 1768 | 'qp_error_too_few_categories' => 'Al minus duo categorias debe esser definite', |
1761 | 1769 | 'qp_error_too_few_spans' => 'Cata classe de categoria require le definition de al minus duo responsas possibile', |
1762 | 1770 | 'qp_error_no_answer' => 'Proposition sin responsa', |
— | — | @@ -2146,7 +2154,7 @@ |
2147 | 2155 | 'qp_error_too_long_category_options_values' => 'Можностите за категоријата се предолги за да можат да се зачуваат во базата.', |
2148 | 2156 | 'qp_error_too_long_proposal_text' => 'Текстот на предлогот е предолг за да може да се складира во базата', |
2149 | 2157 | 'qp_error_too_long_proposal_name' => 'Името на предлогот е предолго за да се зачува во базата.', |
2150 | | - 'qp_error_invalid_proposal_name' => 'Името на предлогот не може да биде број.', |
| 2158 | + 'qp_error_numeric_proposal_name' => 'Името на предлогот не може да биде број.', |
2151 | 2159 | 'qp_error_too_few_categories' => 'Мора да определите барем две категории', |
2152 | 2160 | 'qp_error_too_few_spans' => 'Секоја класа на категории бара да определите барем два можни одговора', |
2153 | 2161 | 'qp_error_no_answer' => 'Неодговорен предлог', |
— | — | @@ -2263,7 +2271,7 @@ |
2264 | 2272 | 'qp_error_too_long_category_options_values' => 'De categorie-optiewaarden zijn te lang om opgeslagen te kunnen worden in de database.', |
2265 | 2273 | 'qp_error_too_long_proposal_text' => 'Het tekstvoorstel is te lang om opgeslagen te kunnen worden in de database.', |
2266 | 2274 | 'qp_error_too_long_proposal_name' => 'De voorstelnaam is te lang om te worden opgeslagen in de database.', |
2267 | | - 'qp_error_invalid_proposal_name' => 'De naam van het voorstel mag niet numeriek zijn.', |
| 2275 | + 'qp_error_numeric_proposal_name' => 'De naam van het voorstel mag niet numeriek zijn.', |
2268 | 2276 | 'qp_error_too_few_categories' => 'Er moeten tenminste twee categorieën gedefinieerd worden.', |
2269 | 2277 | 'qp_error_too_few_spans' => 'Voor iedere categorieklasse dienen tenminste twee mogelijk antwoorden gedefinieerd te zijn', |
2270 | 2278 | 'qp_error_no_answer' => 'Onbeantwoord voorstel', |
— | — | @@ -2778,6 +2786,8 @@ |
2779 | 2787 | 'qp_error_no_stats' => 'Статистика голосования недоступна, так как еще никто не голосовал в этом опросе (address=$1)', |
2780 | 2788 | 'qp_error_address_in_decl_mode' => 'Недопустимо задавать адрес опроса (address) в режиме определения', |
2781 | 2789 | 'qp_error_question_not_implemented' => 'Вопросы данного типа не реализованы в коде расширения: $1', |
| 2790 | + 'qp_error_question_empty_body' => 'Текст определения вопроса пуст.', |
| 2791 | + 'qp_error_question_no_proposals' => 'Текст определения вопроса не содержит ни одной строки вопроса.', |
2782 | 2792 | 'qp_error_invalid_question_type' => 'Недопустимый тип вопроса: $1', |
2783 | 2793 | 'qp_error_invalid_question_name' => 'Недопустимое имя вопроса: $1', |
2784 | 2794 | 'qp_error_type_in_stats_mode' => 'Недопустимо определять тип вопроса в статистическом режиме: $1', |
— | — | @@ -2797,10 +2807,12 @@ |
2798 | 2808 | 'qp_error_too_long_category_option_value' => 'Вариант ответа для данной категории слишком длинный для сохранения в базе данных', |
2799 | 2809 | 'qp_error_too_long_category_options_values' => 'Варианты ответов для данной категории слишком длинны для сохранения в базе данных', |
2800 | 2810 | 'qp_error_too_long_proposal_text' => 'Строка вопроса слишком длинна для сохранения в базе данных', |
2801 | | - 'qp_error_invalid_proposal_name' => 'Имя строки вопроса не может быть числом.', |
| 2811 | + 'qp_error_multiline_proposal_name' => 'Имя строки вопроса не должно содержать в себе более одной строки текста.', |
| 2812 | + 'qp_error_numeric_proposal_name' => 'Имя строки вопроса не может быть числом.', |
2802 | 2813 | 'qp_error_too_few_categories' => 'Каждый вопрос должен иметь по крайней мере два варианта ответа', |
2803 | 2814 | 'qp_error_too_few_spans' => 'Каждая подкатегория вопроса требует по меньшей мере два варианта ответа', |
2804 | 2815 | 'qp_error_no_answer' => 'Нет ответа на вопрос', |
| 2816 | + 'qp_error_not_enough_categories_answered' => 'Недостаточное количество заполненных категорий в строке вопроса.', |
2805 | 2817 | 'qp_error_unique' => 'Опрос, имеющий тип unique(), не должен иметь больше ответов чем вопросов', |
2806 | 2818 | 'qp_error_no_more_attempts' => 'Исчерпано количество попыток ответа на данный опрос', |
2807 | 2819 | 'qp_error_no_interpretation' => 'Скрипт интерпретации не найден', |
Index: trunk/extensions/QPoll/clientside/qp_user.css |
— | — | @@ -10,7 +10,7 @@ |
11 | 11 | .qpoll table.object .spans { color:Gray; } |
12 | 12 | .qpoll table.object .categories { color:#444455; } |
13 | 13 | .qpoll table.object .proposal { } |
14 | | -.qpoll span.proposalerror { color:Red; font-weight:600; } |
| 14 | +.qpoll span.proposalerror, div.proposalerror { color:Red; font-weight:600; } |
15 | 15 | .qpoll tr.proposalerror { background-color: Snow; } |
16 | 16 | .qpoll .settings td { padding: 0.1em 0.4em 0.1em 0.4em } |
17 | 17 | .qpoll table.settings { background-color:transparent; } |
Index: trunk/extensions/QPoll/ctrl/qp_propattrs.php |
— | — | @@ -19,7 +19,8 @@ |
20 | 20 | # index of $this->rawkeys (when available) |
21 | 21 | protected $rawidx; |
22 | 22 | # code of error after getting attributes |
23 | | - # 0 means there is no error |
| 23 | + # integer 0 means there is no error |
| 24 | + # string key of wiki error message |
24 | 25 | public $error; |
25 | 26 | # proposal name (for interpretation scripts); |
26 | 27 | # '' means there is no name |
— | — | @@ -47,12 +48,20 @@ |
48 | 49 | */ |
49 | 50 | public function setQuestion( qp_StubQuestion $question ) { |
50 | 51 | $this->question = $question; |
51 | | - $this->rawkeys = array_keys( $question->raws ); |
52 | | - $this->rawidx = 0; |
| 52 | + $this->reset(); |
53 | 53 | } |
54 | 54 | |
55 | 55 | /** |
56 | | - * Iterates through question raws array. |
| 56 | + * Begin new iteration through raw proposals. |
| 57 | + */ |
| 58 | + public function reset() { |
| 59 | + $this->rawkeys = array_keys( $this->question->raws ); |
| 60 | + $this->rawidx = $this->question->rawProposalKey; |
| 61 | + } |
| 62 | + |
| 63 | + /** |
| 64 | + * Iterates through question raws array, using only raw proposals. |
| 65 | + * Raw categories and category spans (if any) are processed separately. |
57 | 66 | * @return boolean |
58 | 67 | * true $this properties are populated |
59 | 68 | * false there are no more raws available |
— | — | @@ -90,7 +99,7 @@ |
91 | 100 | $this->cpdef = $proposal_text; |
92 | 101 | $matches = array(); |
93 | 102 | # try to match the raw proposal name (without specific attributes) |
94 | | - preg_match( '`^:\|\s*(.+?)\s*\|\s*(.+?)\s*$`u', $this->cpdef, $matches ); |
| 103 | + preg_match( '/^:\|\s*(.+?)\s*\|\s*(.+?)\s*$/su', $this->cpdef, $matches ); |
95 | 104 | if ( count( $matches ) < 3 || |
96 | 105 | ( $this->name = $matches[1] ) === '' ) { |
97 | 106 | # raw proposal name is not defined or empty |
— | — | @@ -98,7 +107,7 @@ |
99 | 108 | } |
100 | 109 | # check, whether raw proposal name will fit into the corresponding DB field |
101 | 110 | if ( strlen( $this->getAttrDef() ) >= qp_Setup::$field_max_len['proposal_text'] ) { |
102 | | - $this->setError( qp_Setup::ERROR_TOO_LONG_PROPNAME ); |
| 111 | + $this->setError( 'qp_error_too_long_proposal_name' ); |
103 | 112 | return; |
104 | 113 | } |
105 | 114 | # try to get xml-like attributes; |
— | — | @@ -114,8 +123,11 @@ |
115 | 124 | $this->emptytext = self::getSaneEmptyText( $paramkeys['emptytext'] ); |
116 | 125 | } |
117 | 126 | if ( is_numeric( $this->name ) ) { |
118 | | - $this->setError( qp_Setup::ERROR_NUMERIC_PROPNAME ); |
| 127 | + $this->setError( 'qp_error_numeric_proposal_name' ); |
119 | 128 | return; |
| 129 | + } elseif ( preg_match( '/$.^/msu', $this->name ) ) { |
| 130 | + $this->setError( 'qp_error_multiline_proposal_name' ); |
| 131 | + return; |
120 | 132 | } |
121 | 133 | # remove raw proposal name from proposal definition |
122 | 134 | $this->cpdef = $matches[2]; |
— | — | @@ -194,28 +206,25 @@ |
195 | 207 | /** |
196 | 208 | * Checks, whether current proposal has not enough of user-answered categories, |
197 | 209 | * according to current question instance. |
198 | | - * @param $proposal_id integer |
199 | | - * id of existing question's proposal |
200 | | - * @param $prop_cats_count |
201 | | - * integer total amount of categories in current proposal |
| 210 | + * @param $answered_cats_count integer |
| 211 | + * number of user-answered categories in current proposal |
| 212 | + * @param $total_cats_count integer |
| 213 | + * total amount of categories in current proposal |
202 | 214 | * @return boolean |
203 | 215 | * true not enough of categories are filled |
204 | 216 | * false otherwise |
205 | 217 | */ |
206 | | - function hasMissingCategories( $proposal_id, $prop_cats_count ) { |
| 218 | + function hasMissingCategories( $answered_cats_count, $total_cats_count ) { |
207 | 219 | # How many categories has to be answered, |
208 | 220 | # all defined in row or the amount specified by "catreq" attribute? |
209 | 221 | # total amount of categories in current proposal |
210 | | - $countRequired = ($this->catreq === 'all') ? $prop_cats_count : $this->catreq; |
211 | | - if ( $countRequired > $prop_cats_count ) { |
| 222 | + $countRequired = ($this->catreq === 'all') ? $total_cats_count : $this->catreq; |
| 223 | + if ( $countRequired > $total_cats_count ) { |
212 | 224 | # do not require to fill more categories |
213 | 225 | # than is available in current proposal row |
214 | | - $countRequired = $prop_cats_count; |
| 226 | + $countRequired = $total_cats_count; |
215 | 227 | } |
216 | | - $answered_cat_count = array_key_exists( $proposal_id, $this->question->mProposalCategoryId ) ? |
217 | | - count( $this->question->mProposalCategoryId[$proposal_id] ) : |
218 | | - 0; |
219 | | - return $answered_cat_count < $countRequired; |
| 228 | + return $answered_cats_count < $countRequired; |
220 | 229 | } |
221 | 230 | |
222 | 231 | /** |
Index: trunk/extensions/QPoll/ctrl/poll/qp_pollstats.php |
— | — | @@ -91,13 +91,15 @@ |
92 | 92 | */ |
93 | 93 | function parseInput( $input ) { |
94 | 94 | $this->questions = new qp_QuestionCollection(); |
95 | | - # question attributes split pattern |
96 | | - $splitPattern = '`\s*{|}\s*\n*`u'; |
97 | | - # preg_split counts the matches starting from zero |
98 | | - $unparsedAttributes = preg_split( $splitPattern, $input, -1, PREG_SPLIT_NO_EMPTY ); |
99 | | - # we count questions starting from 1 |
100 | | - array_unshift( $unparsedAttributes, null ); |
101 | | - unset( $unparsedAttributes[0] ); |
| 95 | + # match questions in statistical mode |
| 96 | + $unparsedAttributes = array(); |
| 97 | + if ( preg_match_all( '/^\s*\{(.*?)\}\s*$/msu', $input, $matches ) ) { |
| 98 | + $unparsedAttributes = $matches[1]; |
| 99 | + # increase key values of $unparsedAttributes by one, |
| 100 | + # because questions are numbered from one, not zero |
| 101 | + array_unshift( $unparsedAttributes, null ); |
| 102 | + unset( $unparsedAttributes[0] ); |
| 103 | + } |
102 | 104 | # first pass: parse the headers |
103 | 105 | foreach ( $this->pollStore->Questions as $qdata ) { |
104 | 106 | $question = new qp_QuestionStats( |
— | — | @@ -106,11 +108,9 @@ |
107 | 109 | $qdata->type, |
108 | 110 | $qdata->question_id |
109 | 111 | ); |
110 | | - if ( isset( $unparsedAttributes[$qdata->question_id] ) ) { |
111 | | - $attr_str = $unparsedAttributes[$qdata->question_id]; |
112 | | - } else { |
113 | | - $attr_str = ''; |
114 | | - } |
| 112 | + $attr_str = isset( $unparsedAttributes[$qdata->question_id] ) ? |
| 113 | + $unparsedAttributes[$qdata->question_id] : |
| 114 | + ''; |
115 | 115 | $paramkeys = array(); |
116 | 116 | $type = $this->getQuestionAttributes( $attr_str, $paramkeys ); |
117 | 117 | $question->applyAttributes( $paramkeys ); |
Index: trunk/extensions/QPoll/ctrl/poll/qp_poll.php |
— | — | @@ -42,6 +42,13 @@ |
43 | 43 | */ |
44 | 44 | class qp_Poll extends qp_AbstractPoll { |
45 | 45 | |
| 46 | + # question header match pattern without line begin/end, braces, regexp modifiers |
| 47 | + private static $headerPattern = '[^{\|].*?\|.*?[^}\|]'; |
| 48 | + # question header split pattern |
| 49 | + private static $splitPattern; |
| 50 | + # question header match pattern with line begin/end, braces, regexp modifiers |
| 51 | + private static $matchPattern; |
| 52 | + |
46 | 53 | # optional address of the poll which must be answered first |
47 | 54 | var $dependsOn = ''; |
48 | 55 | # optional template used to interpret user vote in the Special:Pollresults page |
— | — | @@ -56,6 +63,15 @@ |
57 | 64 | |
58 | 65 | function __construct( array $argv, qp_PollView $view ) { |
59 | 66 | parent::__construct( $argv, $view ); |
| 67 | + # Split header / body |
| 68 | + # Includes surrounding newlines and header's curly braces into match in parentheses |
| 69 | + # because raw question bodies has to be trimmed from single surrounding newlines. |
| 70 | + # Otherwise, these newlines will cause false positive detection of multiline proposals. |
| 71 | + self::$splitPattern = '/((?:$.^|^)\s*{' . self::$headerPattern . '\}\s*$.^)/msu'; |
| 72 | + # match header |
| 73 | + # Does not include surrounding newlines and header's curly braces into match in parentheses |
| 74 | + # because we need to parse only the inner part (common question and optional categories). |
| 75 | + self::$matchPattern = '/^\s*{(' . self::$headerPattern . ')\}\s*$/msu'; |
60 | 76 | # dependance attr |
61 | 77 | if ( array_key_exists( 'dependance', $argv ) ) { |
62 | 78 | $this->dependsOn = trim( $argv['dependance'] ); |
— | — | @@ -324,26 +340,25 @@ |
325 | 341 | */ |
326 | 342 | function parseQuestionsHeaders( $input ) { |
327 | 343 | $this->questions = new qp_QuestionCollection(); |
328 | | - $splitPattern = '`(^|\n\s*)\n\s*{`u'; |
329 | | - $unparsedQuestions = preg_split( $splitPattern, $input, -1, PREG_SPLIT_NO_EMPTY ); |
330 | | - $questionPattern = '`(.*?[^|\}])\}[ \t]*(\n(.*)|$)`su'; |
| 344 | + $unparsedQuestions = preg_split( self::$splitPattern, $input, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE ); |
331 | 345 | # first pass: parse the headers |
332 | | - foreach ( $unparsedQuestions as $unparsedQuestion ) { |
333 | | - # If this "unparsedQuestion" is not a full question, |
334 | | - # we put the text into a buffer to add it at the beginning of the next question. |
335 | | - if ( !empty( $buffer ) ) { |
336 | | - $unparsedQuestion = "$buffer\n\n{" . $unparsedQuestion; |
337 | | - } |
338 | | - if ( preg_match( $questionPattern, $unparsedQuestion, $matches ) ) { |
339 | | - $buffer = ""; |
340 | | - $header = isset( $matches[1] ) ? $matches[1] : ''; |
341 | | - $body = isset( $matches[3] ) ? $matches[3] : null; |
| 346 | + $header = |
| 347 | + $body = ''; |
| 348 | + $matches = array(); |
| 349 | + while ( list( $key, $part ) = each( $unparsedQuestions ) ) { |
| 350 | + if ( preg_match( self::$matchPattern, $part, $matches ) ) { |
| 351 | + $body = ''; |
| 352 | + $header = $matches[1]; |
| 353 | + } else { |
| 354 | + $body = $part; |
342 | 355 | $question = $this->parseQuestionHeader( $header, $body ); |
343 | 356 | $this->questions->add( $question ); |
344 | | - } else { |
345 | | - $buffer = $unparsedQuestion; |
346 | 357 | } |
347 | 358 | } |
| 359 | + if ( $body === '' ) { |
| 360 | + $question = $this->parseQuestionHeader( $header, $body ); |
| 361 | + $this->questions->add( $question ); |
| 362 | + } |
348 | 363 | } |
349 | 364 | |
350 | 365 | /** |
— | — | @@ -380,17 +395,20 @@ |
381 | 396 | */ |
382 | 397 | function parseMainHeader( $header ) { |
383 | 398 | # split common question and question attributes from the header |
384 | | - @list( $common_question, $attr_str ) = preg_split( '`\n\|([^\|].*)\s*$`u', $header, -1, PREG_SPLIT_DELIM_CAPTURE ); |
385 | | - |
| 399 | + $parts = explode( '|', $header ); |
| 400 | + # last pipe character separates attributes from common question |
| 401 | + $attr_str = array_pop( $parts ); |
| 402 | + # this enables to use tables and templates in common question |
| 403 | + $common_question = implode( '|', $parts ); |
386 | 404 | $error_msg = ''; |
| 405 | + # assume that question name does not exists (by default) |
| 406 | + $name = null; |
387 | 407 | if ( isset( $attr_str ) ) { |
388 | 408 | $paramkeys = array(); |
389 | 409 | $type = $this->getQuestionAttributes( $attr_str, $paramkeys ); |
390 | 410 | if ( !array_key_exists( $type, qp_Setup::$questionTypes ) ) { |
391 | 411 | $error_msg = wfMsg( 'qp_error_invalid_question_type', qp_Setup::entities( $type ) ); |
392 | 412 | } |
393 | | - # assume that question name does not exists (by default) |
394 | | - $name = null; |
395 | 413 | if ( $paramkeys['name'] !== null && |
396 | 414 | ( $name = trim( $paramkeys['name'] ) ) === '' || |
397 | 415 | is_numeric( $name ) || |
— | — | @@ -452,9 +470,13 @@ |
453 | 471 | $question = $this->parseMainHeader( $header ); |
454 | 472 | if ( $question->getState() != 'error' ) { |
455 | 473 | # load previous user choice, when it's available and DB header is compatible with parsed header |
456 | | - if ( $body === null || !method_exists( $question, 'parseBody' ) ) { |
| 474 | + if ( !method_exists( $question, 'parseBody' ) ) { |
457 | 475 | $question->setState( 'error', wfMsgHtml( 'qp_error_question_not_implemented', qp_Setup::entities( $question->mType ) ) ); |
| 476 | + } elseif ( $body === '' ) { |
| 477 | + $question->setState( 'error', wfMsgHtml( 'qp_error_question_empty_body' ) ); |
458 | 478 | } else { |
| 479 | + # build $question->raws[] |
| 480 | + $question->splitRawProposals( $body ); |
459 | 481 | # parse the categories and spans (metacategories) |
460 | 482 | $question->parseBodyHeader( $body ); |
461 | 483 | } |
— | — | @@ -466,40 +488,51 @@ |
467 | 489 | * Populates the question with data and builds question->view |
468 | 490 | */ |
469 | 491 | function parseQuestionBody( qp_StubQuestion $question ) { |
470 | | - if ( $question->getState() == 'error' ) { |
471 | | - # error occured during the previously performed header parsing, do not process further |
472 | | - $question->view->addHeaderError(); |
473 | | - # http get: invalid question syntax, parse errors will cause submit button disabled |
474 | | - $this->pollStore->stateError(); |
475 | | - return; |
476 | | - } |
477 | | - # populate $question with raw source values |
478 | | - $question->getQuestionAnswer( $this->pollStore ); |
479 | | - # check whether the global showresults level prohibits to show statistical data |
480 | | - # to the users who hasn't voted |
481 | | - if ( qp_Setup::$global_showresults <= 1 && !$question->alreadyVoted ) { |
482 | | - # suppress statistical results when the current user hasn't voted the question |
483 | | - $question->view->showResults = array( 'type' => 0 ); |
484 | | - } |
485 | | - # parse the question body |
486 | | - # will populate $question->view which can be modified accodring to quiz results |
487 | | - # warning! parameters are passed only by value, not the reference |
488 | | - $question->parseBody(); |
489 | | - if ( $this->mBeingCorrected ) { |
490 | | - if ( $question->getState() == '' ) { |
491 | | - # question is OK, store it into pollStore |
492 | | - $this->pollStore->setQuestion( $question ); |
| 492 | + try { |
| 493 | + if ( $question->getState() == 'error' ) { |
| 494 | + throw new Exception( 'qp_error' ); |
| 495 | + } |
| 496 | + # populate $question with raw source values |
| 497 | + $question->getQuestionAnswer( $this->pollStore ); |
| 498 | + # check whether the global showresults level prohibits to show statistical data |
| 499 | + # to the users who hasn't voted |
| 500 | + if ( qp_Setup::$global_showresults <= 1 && !$question->alreadyVoted ) { |
| 501 | + # suppress statistical results when the current user hasn't voted the question |
| 502 | + $question->view->showResults = array( 'type' => 0 ); |
| 503 | + } |
| 504 | + # parse the question body |
| 505 | + # will populate $question->view which can be modified accodring to quiz results |
| 506 | + # warning! parameters are passed only by value, not the reference |
| 507 | + $question->parseBody(); |
| 508 | + $question->isEmpty(); |
| 509 | + if ( $question->getState() == 'error' ) { |
| 510 | + throw new Exception( 'qp_error' ); |
| 511 | + } |
| 512 | + if ( $this->mBeingCorrected ) { |
| 513 | + if ( $question->getState() == '' ) { |
| 514 | + # question is OK, store it into pollStore |
| 515 | + $this->pollStore->setQuestion( $question ); |
| 516 | + } else { |
| 517 | + # http post: not every proposals were answered: do not update DB |
| 518 | + $this->pollStore->stateIncomplete(); |
| 519 | + } |
493 | 520 | } else { |
494 | | - # http post: not every proposals were answered: do not update DB |
495 | | - $this->pollStore->stateIncomplete(); |
| 521 | + # this is the get, not the post: do not update DB |
| 522 | + if ( $question->getState() == '' ) { |
| 523 | + $this->pollStore->stateIncomplete(); |
| 524 | + } else { |
| 525 | + # http get: invalid question syntax, parse errors will cause submit button disabled |
| 526 | + $this->pollStore->stateError(); |
| 527 | + } |
496 | 528 | } |
497 | | - } else { |
498 | | - # this is the get, not the post: do not update DB |
499 | | - if ( $question->getState() == '' ) { |
500 | | - $this->pollStore->stateIncomplete(); |
501 | | - } else { |
| 529 | + } catch ( Exception $e ) { |
| 530 | + if ( $e->getMessage() === 'qp_error' ) { |
| 531 | + # error occured during the previously performed header parsing, do not process further |
502 | 532 | # http get: invalid question syntax, parse errors will cause submit button disabled |
503 | 533 | $this->pollStore->stateError(); |
| 534 | + return; |
| 535 | + } else { |
| 536 | + throw new MWException( $e->getMessage() ); |
504 | 537 | } |
505 | 538 | } |
506 | 539 | } |
Index: trunk/extensions/QPoll/ctrl/question/qp_textquestion.php |
— | — | @@ -287,12 +287,10 @@ |
288 | 288 | |
289 | 289 | /** |
290 | 290 | * Parses question body header. |
291 | | - * Text questions do not have "body header" (no definitions of spans and categories) |
292 | | - * so, this method just splits raw lines of body text to analyze raws in $this->parseBody() |
293 | | - * @param $input - the text of question body |
| 291 | + * Text questions do not have "body header" (no definitions of spans and categories). |
294 | 292 | */ |
295 | | - function parseBodyHeader( $input ) { |
296 | | - $this->raws = preg_split( '`\n`su', $input, -1, PREG_SPLIT_NO_EMPTY ); |
| 293 | + function parseBodyHeader() { |
| 294 | + /* noop */ |
297 | 295 | } |
298 | 296 | |
299 | 297 | /** |
— | — | @@ -478,10 +476,8 @@ |
479 | 477 | $opt->reset(); |
480 | 478 | $this->propview = new qp_TextQuestionProposalView( $proposalId, $this ); |
481 | 479 | # get proposal name and optional attributes (if any) |
482 | | - if ( $prop_attrs->error === qp_Setup::ERROR_TOO_LONG_PROPNAME ) { |
483 | | - $this->propview->prependErrorToken( wfMsg( 'qp_error_too_long_proposal_name' ), 'error' ); |
484 | | - } elseif ( $prop_attrs->error === qp_Setup::ERROR_NUMERIC_PROPNAME ) { |
485 | | - $this->propview->prependErrorToken( wfMsg( 'qp_error_invalid_proposal_name' ), 'error' ); |
| 480 | + if ( is_string( $prop_attrs->error ) ) { |
| 481 | + $this->propview->prependErrorToken( wfMsg( $prop_attrs->error, 'error' ) ); |
486 | 482 | } |
487 | 483 | $this->dbtokens = $brace_stack = array(); |
488 | 484 | $dbtokens_idx = -1; |
— | — | @@ -575,9 +571,17 @@ |
576 | 572 | $this->propview->catreq = $prop_attrs->catreq; |
577 | 573 | ## Check for unanswered categories. |
578 | 574 | if ( $this->poll->mBeingCorrected && |
579 | | - $prop_attrs->hasMissingCategories( $proposalId, $catId ) ) { |
| 575 | + $prop_attrs->hasMissingCategories( |
| 576 | + $answered_cats_count = $this->getAnsweredCatCount( $proposalId ), |
| 577 | + $catId |
| 578 | + ) ) { |
580 | 579 | $prev_state = $this->getState(); |
581 | | - $this->propview->prependErrorToken( wfMsg( 'qp_error_no_answer' ), 'NA' ); |
| 580 | + $this->propview->prependErrorToken( |
| 581 | + ($answered_cats_count > 0) ? |
| 582 | + wfMsg( 'qp_error_not_enough_categories_answered' ) : |
| 583 | + wfMsg( 'qp_error_no_answer' ) |
| 584 | + , 'NA' |
| 585 | + ); |
582 | 586 | } |
583 | 587 | $this->view->addProposal( $proposalId, $this->propview ); |
584 | 588 | $proposalId++; |
Index: trunk/extensions/QPoll/ctrl/question/qp_mixedquestion.php |
— | — | @@ -13,14 +13,15 @@ |
14 | 14 | /** |
15 | 15 | * Creates question view which should be renreded and |
16 | 16 | * also may be altered during the poll generation |
| 17 | + * todo: this method is too long, split to smaller parts |
17 | 18 | */ |
18 | 19 | function parseBody() { |
19 | 20 | global $wgContLang; |
20 | | - $this->mProposalPattern = '`^'; |
| 21 | + $proposalPattern = '/^'; |
21 | 22 | foreach ( $this->mCategories as $catDesc ) { |
22 | | - $this->mProposalPattern .= '(\[\]|\(\)|<>)'; |
| 23 | + $proposalPattern .= '(\[\]|\(\)|<>)'; |
23 | 24 | } |
24 | | - $this->mProposalPattern .= '(.*)`u'; |
| 25 | + $proposalPattern .= '(.*)/su'; |
25 | 26 | $proposalId = -1; |
26 | 27 | # set static view state for the future qp_TabularQuestionProposalView instances |
27 | 28 | qp_TabularQuestionProposalView::applyViewState( $this->view ); |
— | — | @@ -30,7 +31,7 @@ |
31 | 32 | # new proposal view |
32 | 33 | $pview = new qp_TabularQuestionProposalView( $proposalId + 1, $this ); |
33 | 34 | # get the list of categories ($matches) |
34 | | - if ( preg_match( $this->mProposalPattern, $prop_attrs->cpdef, $matches ) ) { |
| 35 | + if ( preg_match( $proposalPattern, $prop_attrs->cpdef, $matches ) ) { |
35 | 36 | $prop_attrs->dbText = array_pop( $matches ); // current proposal text |
36 | 37 | array_shift( $matches ); // remove "at whole" match |
37 | 38 | $last_matches = $matches; |
— | — | @@ -47,10 +48,8 @@ |
48 | 49 | $pview->text = $prop_attrs->dbText; |
49 | 50 | $proposalId++; |
50 | 51 | # set proposal name (if any) |
51 | | - if ( $prop_attrs->error === qp_Setup::ERROR_TOO_LONG_PROPNAME ) { |
52 | | - $pview->prependErrorMessage( wfMsg( 'qp_error_too_long_proposal_name' ), 'error' ); |
53 | | - } elseif ( $prop_attrs->error === qp_Setup::ERROR_NUMERIC_PROPNAME ) { |
54 | | - $pview->prependErrorMessage( wfMsg( 'qp_error_invalid_proposal_name' ), 'error' ); |
| 52 | + if ( is_string( $prop_attrs->error ) ) { |
| 53 | + $pview->prependErrorMessage( wfMsg( $prop_attrs->error, 'error' ) ); |
55 | 54 | } elseif ( $prop_attrs->name !== '' ) { |
56 | 55 | $this->mProposalNames[$proposalId] = $prop_attrs->name; |
57 | 56 | } |
— | — | @@ -137,9 +136,17 @@ |
138 | 137 | throw new Exception( 'qp_error' ); |
139 | 138 | } |
140 | 139 | if ( $this->poll->mBeingCorrected && |
141 | | - $prop_attrs->hasMissingCategories( $proposalId, count( $this->mCategories ) ) ) { |
142 | | - # the proposal was submitted but has not enough answered categories |
143 | | - $pview->prependErrorMessage( wfMsg( 'qp_error_no_answer' ), 'NA' ); |
| 140 | + $prop_attrs->hasMissingCategories( |
| 141 | + $answered_cats_count = $this->getAnsweredCatCount( $proposalId ), |
| 142 | + count( $this->mCategories ) |
| 143 | + ) ) { |
| 144 | + # the proposal was submitted but has not enough categories answered |
| 145 | + $pview->prependErrorMessage( |
| 146 | + ($answered_cats_count > 0) ? |
| 147 | + wfMsg( 'qp_error_not_enough_categories_answered' ) : |
| 148 | + wfMsg( 'qp_error_no_answer' ) |
| 149 | + , 'NA' |
| 150 | + ); |
144 | 151 | # if there was no previous errors, hightlight the whole row |
145 | 152 | if ( $this->getState() == '' ) { |
146 | 153 | throw new Exception( 'qp_error' ); |
Index: trunk/extensions/QPoll/ctrl/question/qp_tabularquestion.php |
— | — | @@ -10,6 +10,9 @@ |
11 | 11 | */ |
12 | 12 | class qp_TabularQuestion extends qp_StubQuestion { |
13 | 13 | |
| 14 | + private static $categoryPattern = '/^\s*\|(.*)/su'; |
| 15 | + private static $categorySpansPattern = '/^\s*[\|!].*/su'; |
| 16 | + |
14 | 17 | ## default proposal attributes |
15 | 18 | # do not allow empty text fields submission / storage by default |
16 | 19 | var $mEmptyText = false; |
— | — | @@ -17,48 +20,30 @@ |
18 | 21 | var $mCatReq = 1; |
19 | 22 | |
20 | 23 | /** |
21 | | - * Constructor |
22 | | - * @public |
23 | | - * @param $poll object |
24 | | - * an instance of question's parent controller |
25 | | - * @param $view object |
26 | | - * an instance of question view "linked" to this question |
27 | | - * @param $questionId integer |
28 | | - * identifier of the question used to generate input names |
29 | | - * @param $name mixed |
30 | | - * null when question has no name / invalid name |
31 | | - * string valid question name |
32 | | - */ |
33 | | - function __construct( qp_AbstractPoll $poll, qp_StubQuestionView $view, $questionId, $name ) { |
34 | | - parent::__construct( $poll, $view, $questionId, $name ); |
35 | | - $this->mProposalPattern = '`^[^\|\!].*`u'; |
36 | | - $this->mCategoryPattern = '`^\|(\n|[^\|].*\n)`u'; |
37 | | - } |
38 | | - |
39 | | - /** |
40 | 24 | * Builds internal & visual representations of categories and spans according to their |
41 | 25 | * text definition in the question body |
42 | | - * @param $input - the text of question body |
43 | 26 | */ |
44 | | - function parseBodyHeader( $input ) { |
45 | | - $this->raws = preg_split( '`\n`su', $input, -1, PREG_SPLIT_NO_EMPTY ); |
46 | | - $categorySpans = false; |
47 | | - if ( isset( $this->raws[1] ) ) { |
48 | | - $categorySpans = preg_match( $this->mCategoryPattern, $this->raws[1] . "\n", $matches ); |
49 | | - } |
50 | | - if ( !$categorySpans && isset( $this->raws[0] ) ) { |
51 | | - preg_match( $this->mCategoryPattern, $this->raws[0] . "\n", $matches ); |
52 | | - } |
| 27 | + function parseBodyHeader() { |
53 | 28 | # parse the header - spans and categories |
54 | | - $catString = isset( $matches[1] ) ? $matches[1] : ''; |
55 | | - $catRow = $this->parseCategories( $catString ); |
56 | | - if ( $categorySpans ) { |
| 29 | + $matches = array(); |
| 30 | + if ( isset( $this->raws[1] ) && |
| 31 | + preg_match( self::$categorySpansPattern, $this->raws[0] ) && |
| 32 | + preg_match( self::$categoryPattern, $this->raws[1], $matches ) ) { |
| 33 | + # category spans are found, raw proposals begin at key 2 |
| 34 | + $this->rawProposalKey = 2; |
| 35 | + $catRow = $this->parseCategories( $matches[1] ); |
57 | 36 | $spansRow = $this->parseCategorySpans( $this->raws[0] ); |
58 | 37 | # if there are multiple spans, "turn on" borders for span and category cells |
59 | 38 | if ( count( $this->mCategorySpans ) > 1 ) { |
60 | 39 | $this->view->categoriesStyle .= 'border:1px solid gray;'; |
61 | 40 | } |
62 | 41 | $this->view->addSpanRow( $spansRow ); |
| 42 | + } else { |
| 43 | + # no category spans, raw proposals begin at key 1 |
| 44 | + $this->rawProposalKey = 1; |
| 45 | + $catRow = preg_match( self::$categoryPattern, $this->raws[0], $matches ) ? |
| 46 | + $this->parseCategories( $matches[1] ) : |
| 47 | + $this->parseCategories( $this->raws[0] ); |
63 | 48 | } |
64 | 49 | # do not render single empty category at all (on user's request) |
65 | 50 | if ( count( $this->mCategories ) == 1 && |
— | — | @@ -78,10 +63,10 @@ |
79 | 64 | # build "raw" $categories array |
80 | 65 | # split tokens |
81 | 66 | $cat_split = preg_split( '`({{|}}|\[\[|\]\]|\|)`u', $input, -1, PREG_SPLIT_DELIM_CAPTURE ); |
82 | | - $matching_braces = Array(); |
| 67 | + $matching_braces = array(); |
83 | 68 | $curr_elem = ''; |
84 | | - $categories = Array(); |
85 | | - foreach ( $cat_split as $part ) { |
| 69 | + $categories = array(); |
| 70 | + foreach ( $cat_split as $key => $part ) { |
86 | 71 | switch ( $part ) { |
87 | 72 | case '|' : |
88 | 73 | if ( count( $matching_braces ) == 0 ) { |
— | — | @@ -154,9 +139,9 @@ |
155 | 140 | # build "raw" spans array |
156 | 141 | # split tokens |
157 | 142 | $span_split = preg_split( '`({{|}}|\[\[|\]\]|\||\!)`u', $input, -1, PREG_SPLIT_DELIM_CAPTURE ); |
158 | | - $matching_braces = Array(); |
| 143 | + $matching_braces = array(); |
159 | 144 | $curr_elem = null; |
160 | | - $spans = Array(); |
| 145 | + $spans = array(); |
161 | 146 | if ( isset( $span_split[0] ) && $span_split[0] == '' ) { |
162 | 147 | array_shift( $span_split ); |
163 | 148 | if ( isset( $span_split[0] ) && in_array( $span_split[0], array( '!', '|' ) ) ) { |
— | — | @@ -288,17 +273,12 @@ |
289 | 274 | $prop_attrs = qp_Setup::$propAttrs; |
290 | 275 | $prop_attrs->setQuestion( $this ); |
291 | 276 | while ( $prop_attrs->iterate() ) { |
292 | | - if ( !preg_match( $this->mProposalPattern, $prop_attrs->cpdef, $matches ) ) { |
293 | | - continue; |
294 | | - } |
295 | 277 | # new proposal view |
296 | 278 | $pview = new qp_TabularQuestionProposalView( $proposalId + 1, $this ); |
297 | 279 | $proposalId++; |
298 | | - $prop_attrs->dbText = $pview->text = array_pop( $matches ); |
299 | | - if ( $prop_attrs->error === qp_Setup::ERROR_TOO_LONG_PROPNAME ) { |
300 | | - $pview->prependErrorMessage( wfMsg( 'qp_error_too_long_proposal_name' ), 'error' ); |
301 | | - } elseif ( $prop_attrs->error === qp_Setup::ERROR_NUMERIC_PROPNAME ) { |
302 | | - $pview->prependErrorMessage( wfMsg( 'qp_error_invalid_proposal_name' ), 'error' ); |
| 280 | + $prop_attrs->dbText = $pview->text = $prop_attrs->cpdef; |
| 281 | + if ( is_string( $prop_attrs->error ) ) { |
| 282 | + $pview->prependErrorMessage( wfMsg( $prop_attrs->error ), 'error' ); |
303 | 283 | } elseif ( $prop_attrs->name !== '' ) { |
304 | 284 | $this->mProposalNames[$proposalId] = $prop_attrs->name; |
305 | 285 | } |
— | — | @@ -375,13 +355,21 @@ |
376 | 356 | } |
377 | 357 | # If the proposal was submitted but unanswered |
378 | 358 | if ( $this->poll->mBeingCorrected && |
379 | | - $prop_attrs->hasMissingCategories( $proposalId, count( $this->mCategories ) ) ) { |
| 359 | + $prop_attrs->hasMissingCategories( |
| 360 | + $answered_cats_count = $this->getAnsweredCatCount( $proposalId ), |
| 361 | + count( $this->mCategories ) |
| 362 | + ) ) { |
380 | 363 | # if there was no previous errors, hightlight the whole row |
381 | 364 | if ( $this->getState() == '' ) { |
382 | 365 | $pview->addCellsClass( 'error' ); |
383 | 366 | } |
384 | | - # the proposal was submitted but has not enough answered categories |
385 | | - $pview->prependErrorMessage( wfMsg( 'qp_error_no_answer' ), 'NA' ); |
| 367 | + # the proposal was submitted but has not enough categories answered |
| 368 | + $pview->prependErrorMessage( |
| 369 | + ($answered_cats_count > 0) ? |
| 370 | + wfMsg( 'qp_error_not_enough_categories_answered' ) : |
| 371 | + wfMsg( 'qp_error_no_answer' ) |
| 372 | + , 'NA' |
| 373 | + ); |
386 | 374 | } |
387 | 375 | if ( $pview->text !== null ) { |
388 | 376 | $this->view->addProposal( $proposalId, $pview ); |
Index: trunk/extensions/QPoll/ctrl/question/qp_stubquestion.php |
— | — | @@ -12,6 +12,11 @@ |
13 | 13 | */ |
14 | 14 | class qp_StubQuestion extends qp_AbstractQuestion { |
15 | 15 | |
| 16 | + # array of raw question source lines |
| 17 | + var $raws; |
| 18 | + # key of first raw proposal in $this->raws |
| 19 | + var $rawProposalKey = 0; |
| 20 | + |
16 | 21 | # optional question literal name, used to address questions in interpretation scripts |
17 | 22 | var $mName = null; |
18 | 23 | |
— | — | @@ -70,6 +75,32 @@ |
71 | 76 | return array_search( $proposalName, $this->mProposalNames, true ); |
72 | 77 | } |
73 | 78 | |
| 79 | + private function substBackslashNL( &$s ) { |
| 80 | + # warning: in single quoted regexp replace '\\\' translates into '\'; |
| 81 | + # without trim(), regexp match in qp_PropAttrs::getFromSource() will fail. |
| 82 | + $s = preg_replace( '/^(\s*\\\??\s*)$/mu', '', trim( $s ) ); |
| 83 | + } |
| 84 | + |
| 85 | + /** |
| 86 | + * Split raw question body into raw proposals and optionally |
| 87 | + * raw categories / raw category spans, when available. |
| 88 | + */ |
| 89 | + function splitRawProposals( $input ) { |
| 90 | + # detect type of raw proposals |
| 91 | + # originally was: preg_match( '/(?:^|\n)\s*\\??\s*(?:$|\n)/', $input ) |
| 92 | + # multiline raw proposals have empty lines and also optionally have lines |
| 93 | + # containing only '\' character. |
| 94 | + if ( preg_match( '/^\s*\\??\s*$/mu', $input ) ) { |
| 95 | + # multiline raw proposals |
| 96 | + # warning: in single quoted regexp split '\\' translates into '\' |
| 97 | + $this->raws = preg_split( '/^(\s*\\??\s*)$/mu', $input, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE ); |
| 98 | + array_walk( $this->raws, array( __CLASS__, 'substBackslashNL' ) ); |
| 99 | + } else { |
| 100 | + # single line raw proposals |
| 101 | + $this->raws = preg_split( '`\n`su', $input, -1, PREG_SPLIT_NO_EMPTY ); |
| 102 | + } |
| 103 | + } |
| 104 | + |
74 | 105 | # load some question fields from qp_QuestionData given |
75 | 106 | # (usually qp_QuestionData is an array property of qp_PollStore instance) |
76 | 107 | # @param $qdata - an instance of qp_QuestionData |
— | — | @@ -135,6 +166,19 @@ |
136 | 167 | } |
137 | 168 | |
138 | 169 | /** |
| 170 | + * @param $proposal_id integer |
| 171 | + * id of existing question's proposal |
| 172 | + * @return integer |
| 173 | + * count of answered categories by current submitting user for |
| 174 | + * specified proposal id |
| 175 | + */ |
| 176 | + function getAnsweredCatCount( $proposal_id ) { |
| 177 | + return array_key_exists( $proposal_id, $this->mProposalCategoryId ) ? |
| 178 | + count( $this->mProposalCategoryId[$proposal_id] ) : |
| 179 | + 0; |
| 180 | + } |
| 181 | + |
| 182 | + /** |
139 | 183 | * Applies previousely parsed attributes from main header into question's view |
140 | 184 | * (all attributes but type) |
141 | 185 | * @param $paramkeys array |
— | — | @@ -169,6 +213,15 @@ |
170 | 214 | } |
171 | 215 | |
172 | 216 | /** |
| 217 | + * Raises an error in case parsed question does not have any proposals. |
| 218 | + */ |
| 219 | + function isEmpty() { |
| 220 | + if ( count( $this->mProposalText ) === 0 ) { |
| 221 | + $this->setState( 'error', wfMsgHtml( 'qp_error_question_no_proposals' ) ); |
| 222 | + }; |
| 223 | + } |
| 224 | + |
| 225 | + /** |
173 | 226 | * Creates question view which should be renreded and |
174 | 227 | * also may be altered during the poll generation |
175 | 228 | */ |
Index: trunk/extensions/QPoll/ctrl/question/qp_questionstats.php |
— | — | @@ -69,7 +69,6 @@ |
70 | 70 | */ |
71 | 71 | function statsParseBody() { |
72 | 72 | if ( $this->getState() == 'error' ) { |
73 | | - $this->view->addHeaderError(); |
74 | 73 | return; |
75 | 74 | } |
76 | 75 | $catRow = $this->parseCategories(); |
— | — | @@ -82,6 +81,7 @@ |
83 | 82 | $this->view->addSpanRow( $spansRow ); |
84 | 83 | } |
85 | 84 | $this->view->addCategoryRow( $catRow ); |
| 85 | + # set static view state for the future qp_QuestionStatsProposalView instances |
86 | 86 | qp_QuestionStatsProposalView::applyViewState( $this->view ); |
87 | 87 | foreach ( $this->mProposalText as $proposalId => $text ) { |
88 | 88 | $pview = new qp_QuestionStatsProposalView( $proposalId, $this ); |
Index: trunk/extensions/QPoll/qp_user.php |
— | — | @@ -145,8 +145,6 @@ |
146 | 146 | const NO_ERROR = 0; |
147 | 147 | const ERROR_MISSED_TITLE = 1; |
148 | 148 | const ERROR_INVALID_ADDRESS = 2; |
149 | | - const ERROR_TOO_LONG_PROPNAME = 3; |
150 | | - const ERROR_NUMERIC_PROPNAME = 4; |
151 | 149 | |
152 | 150 | # unicode entity used to display selected checkboxes and radiobuttons in |
153 | 151 | # result views at Special:Pollresults page |
— | — | @@ -253,7 +251,7 @@ |
254 | 252 | # otherwise checking of dependance chain will fail: |
255 | 253 | 'dependance' => 768, |
256 | 254 | # limited due to performance improvements (to fit into DB row), |
257 | | - # and also to properly truncate UFT8 tails: |
| 255 | + # and also to properly truncate UTF-8 tails: |
258 | 256 | 'common_question' => 768, |
259 | 257 | # limited to maximal length of DB field |
260 | 258 | 'question_name' => 255, |
— | — | @@ -266,7 +264,7 @@ |
267 | 265 | # may be lost: |
268 | 266 | 'text_answer' => 768, |
269 | 267 | # limited due to performance improvements (to fit into DB row), |
270 | | - # and also to properly truncate UFT8 tails: |
| 268 | + # and also to properly truncate UTF-8 tails: |
271 | 269 | 'long_interpretation' => 768, |
272 | 270 | # 'serialized_interpretation' is not longer than DB field size (65535), |
273 | 271 | # otherwise unserialization of structured answer will be invalid: |
— | — | @@ -285,16 +283,15 @@ |
286 | 284 | # result views at Special:Pollresults page |
287 | 285 | static $resultsCheckCode = '+'; |
288 | 286 | |
289 | | - |
290 | | - static function entities( $s ) { |
| 287 | + public static function entities( $s ) { |
291 | 288 | return htmlentities( $s, ENT_QUOTES, 'UTF-8' ); |
292 | 289 | } |
293 | 290 | |
294 | | - static function specialchars( $s ) { |
| 291 | + public static function specialchars( $s ) { |
295 | 292 | return htmlentities( $s, ENT_QUOTES, 'UTF-8' ); |
296 | 293 | } |
297 | 294 | |
298 | | - static function entity_decode( $s ) { |
| 295 | + public static function entity_decode( $s ) { |
299 | 296 | return html_entity_decode( $s, ENT_QUOTES, 'UTF-8' ); |
300 | 297 | } |
301 | 298 | |
Index: trunk/extensions/QPoll/view/proposal/qp_tabularquestionproposalview.php |
— | — | @@ -106,7 +106,7 @@ |
107 | 107 | * @param $rowClass - string set rowClass value, boolean false (do not set) |
108 | 108 | */ |
109 | 109 | function prependErrorMessage( $msg, $state, $rowClass = 'proposalerror' ) { |
110 | | - $this->text = $this->bodyErrorMessage( $msg, $state, $rowClass ) . $this->text; |
| 110 | + $this->text = $this->ctrl->view->bodyErrorMessage( $msg, $state, $rowClass ) . $this->text; |
111 | 111 | } |
112 | 112 | |
113 | 113 | /** |
— | — | @@ -117,7 +117,7 @@ |
118 | 118 | * @param $rowClass - string set rowClass value, boolean false (do not set) |
119 | 119 | */ |
120 | 120 | function setErrorMessage( $msg, $state, $rowClass = 'proposalerror' ) { |
121 | | - $this->text = $this->bodyErrorMessage( $msg, $state, $rowClass ); |
| 121 | + $this->text = $this->ctrl->view->bodyErrorMessage( $msg, $state, $rowClass ); |
122 | 122 | } |
123 | 123 | |
124 | 124 | /** |
— | — | @@ -199,7 +199,7 @@ |
200 | 200 | } |
201 | 201 | # highlight the input |
202 | 202 | qp_Renderer::addClass( $cat_tag, 'cat_error' ); |
203 | | - array_unshift( $cat_tag, $this->bodyErrorMessage( $cat_desc, '', false ) . '<br />' ); |
| 203 | + array_unshift( $cat_tag, $this->ctrl->view->bodyErrorMessage( $cat_desc, '', false ) . '<br />' ); |
204 | 204 | } |
205 | 205 | } |
206 | 206 | return $foundCats; |
Index: trunk/extensions/QPoll/view/proposal/qp_stubquestionproposalview.php |
— | — | @@ -50,25 +50,4 @@ |
51 | 51 | } |
52 | 52 | } |
53 | 53 | |
54 | | - /** |
55 | | - * Outputs question body parser error/warning message; also set new controller state |
56 | | - * @param $msg - text of message |
57 | | - * @param $state - set new question controller state |
58 | | - * note that the 'error' state cannot be changed and '' state cannot be set |
59 | | - * @param $rowClass - string set rowClass value, boolean false (do not set) |
60 | | - */ |
61 | | - function bodyErrorMessage( $msg, $state, $rowClass = 'proposalerror' ) { |
62 | | - $prev_state = $this->ctrl->getState(); |
63 | | - # do not clear previous errors (do not call setState() when $state == '') |
64 | | - if ( $state != '' ) { |
65 | | - $this->ctrl->setState( $state, $msg ); |
66 | | - } |
67 | | - if ( is_string( $rowClass ) ) { |
68 | | - $this->rowClass = $rowClass; |
69 | | - } |
70 | | - # will show only the first error, when the state is not clean (not '') |
71 | | - return ( $prev_state == '' ) ? '<span class="proposalerror" title="' . qp_Setup::specialchars( $msg ) . '">???</span> ' : ''; |
72 | | - } |
73 | | - |
74 | 54 | } /* end of qp_StubQuestionProposalView */ |
75 | | - |
Index: trunk/extensions/QPoll/view/proposal/qp_textquestionproposalview.php |
— | — | @@ -91,7 +91,7 @@ |
92 | 92 | * @param $rowClass - string set rowClass value, boolean false (do not set) |
93 | 93 | */ |
94 | 94 | function prependErrorToken( $msg, $state, $rowClass = 'proposalerror' ) { |
95 | | - $errmsg = $this->bodyErrorMessage( $msg, $state, $rowClass ); |
| 95 | + $errmsg = $this->ctrl->view->bodyErrorMessage( $msg, $state, $rowClass ); |
96 | 96 | # note: when $state == '' every $errmsg is non-empty; |
97 | 97 | # when $state == 'error' only the first $errmsg is non-empty; |
98 | 98 | if ( $errmsg !== '' ) { |
— | — | @@ -110,7 +110,7 @@ |
111 | 111 | * @param $rowClass - string set rowClass value, boolean false (do not set) |
112 | 112 | */ |
113 | 113 | function addErrorToken( $msg, $state, $rowClass = 'proposalerror' ) { |
114 | | - $errmsg = $this->bodyErrorMessage( $msg, $state, $rowClass ); |
| 114 | + $errmsg = $this->ctrl->view->bodyErrorMessage( $msg, $state, $rowClass ); |
115 | 115 | # note: when $state == '' every $errmsg is non-empty; |
116 | 116 | # when $state == 'error' only the first $errmsg is non-empty; |
117 | 117 | if ( $errmsg !== '' ) { |
— | — | @@ -142,7 +142,7 @@ |
143 | 143 | $cat_desc = wfMsg( 'qp_interpetation_wrong_answer' ); |
144 | 144 | } |
145 | 145 | # mark the input to highlight it during the rendering |
146 | | - if ( ( $msg = $this->bodyErrorMessage( $cat_desc, '', false ) ) !=='' ) { |
| 146 | + if ( ( $msg = $this->ctrl->view->bodyErrorMessage( $cat_desc, '', false ) ) !=='' ) { |
147 | 147 | # we call with question state = '', so the returned $msg never should be empty |
148 | 148 | # unless there was a syntax error, however during the interpretation stage there |
149 | 149 | # should be no syntax errors, so we can assume that $msg is never equal to '' |
Index: trunk/extensions/QPoll/view/question/qp_tabularquestionview.php |
— | — | @@ -126,7 +126,7 @@ |
127 | 127 | * @return boolean true for valid value, false otherwise |
128 | 128 | */ |
129 | 129 | function isCSSLengthValid( $width ) { |
130 | | - preg_match( '`^\s*(\d+)(px|em|en|%|)\s*$`', $width, $matches ); |
| 130 | + preg_match( '`^\s*(\d+)(px|em|%|)\s*$`', $width, $matches ); |
131 | 131 | return count( $matches > 1 ) && $matches[1] > 0; |
132 | 132 | } |
133 | 133 | |
— | — | @@ -266,20 +266,13 @@ |
267 | 267 | function renderTable() { |
268 | 268 | $questionTable = array(); |
269 | 269 | # add header views to $questionTable |
| 270 | + $rowattrs = array(); |
270 | 271 | foreach ( $this->hviews as $header ) { |
271 | | - $rowattrs = array(); |
272 | | - $attribute_maps = array(); |
273 | | - if ( is_object( $header ) ) { |
274 | | - $row = &$header->row; |
275 | | - $rowattrs['class'] = $header->className; |
276 | | - $attribute_maps = &$header->attribute_maps; |
277 | | - } else { |
278 | | - $row = &$header; |
279 | | - } |
| 272 | + $rowattrs['class'] = $header->className; |
280 | 273 | if ( $this->transposed ) { |
281 | | - qp_Renderer::addColumn( $questionTable, $row, $rowattrs, 'th', $attribute_maps ); |
| 274 | + qp_Renderer::addColumn( $questionTable, $header->row, $rowattrs, 'th', $header->attribute_maps ); |
282 | 275 | } else { |
283 | | - qp_Renderer::addRow( $questionTable, $row, $rowattrs, 'th', $attribute_maps ); |
| 276 | + qp_Renderer::addRow( $questionTable, $header->row, $rowattrs, 'th', $header->attribute_maps ); |
284 | 277 | } |
285 | 278 | } |
286 | 279 | # add proposal views to $questionTable |
Index: trunk/extensions/QPoll/view/question/qp_stubquestionview.php |
— | — | @@ -45,8 +45,9 @@ |
46 | 46 | */ |
47 | 47 | class qp_StubQuestionView extends qp_AbstractView { |
48 | 48 | |
49 | | - # error message which occured during the question header parsing that will be output later at rendering stage |
50 | | - var $headerErrorMessage = 'Unknown error'; |
| 49 | + # error message which occured during the question header parsing that will be |
| 50 | + # output later at rendering stage, empty string means there is no error |
| 51 | + var $headerErrorMessage = ''; |
51 | 52 | |
52 | 53 | # header views (list of tagarrays) |
53 | 54 | # tagarray is a primitive view without it's own methods |
— | — | @@ -86,19 +87,6 @@ |
87 | 88 | } |
88 | 89 | |
89 | 90 | /** |
90 | | - * @param $tagarray array / string row to add to the question's header |
91 | | - */ |
92 | | - function addHeader( $tagarray ) { |
93 | | - $this->hviews[] = $tagarray; |
94 | | - } |
95 | | - |
96 | | - function addHeaderError() { |
97 | | - $this->hviews[] = array( |
98 | | - array( '__tag' => 'td', 'class' => 'proposalerror', $this->headerErrorMessage ) |
99 | | - ); |
100 | | - } |
101 | | - |
102 | | - /** |
103 | 91 | * Adds table header row to question's view |
104 | 92 | * @param $row tagarray representation of row |
105 | 93 | * @param $className CSS class name of row |
— | — | @@ -113,6 +101,34 @@ |
114 | 102 | } |
115 | 103 | |
116 | 104 | /** |
| 105 | + * Outputs question body parser error/warning message. |
| 106 | + * Set new controller state. |
| 107 | + * @param $msg string |
| 108 | + * text of message |
| 109 | + * @param $state string |
| 110 | + * set new question controller state; |
| 111 | + * note that the 'error' state cannot be changed and '' state cannot be set |
| 112 | + * @param $rowClass mixed |
| 113 | + * string set rowClass value |
| 114 | + * boolean false (do not set) |
| 115 | + * |
| 116 | + * note: this method should be invoked directly only for header errors generation; |
| 117 | + * body errors should call wrappers defined in their proposal view classes. |
| 118 | + */ |
| 119 | + function bodyErrorMessage( $msg, $state, $rowClass = 'proposalerror' ) { |
| 120 | + $prev_state = $this->ctrl->getState(); |
| 121 | + # do not clear previous errors (do not call setState() when $state == '') |
| 122 | + if ( $state != '' ) { |
| 123 | + $this->ctrl->setState( $state, $msg ); |
| 124 | + } |
| 125 | + if ( is_string( $rowClass ) ) { |
| 126 | + $this->rowClass = $rowClass; |
| 127 | + } |
| 128 | + # will show only the first error, when the state is not clean (not '') |
| 129 | + return ( $prev_state == '' ) ? '<span class="proposalerror" title="' . qp_Setup::specialchars( $msg ) . '">???</span> ' : ''; |
| 130 | + } |
| 131 | + |
| 132 | + /** |
117 | 133 | * Render script-generated interpretation errors, when available (quiz mode) |
118 | 134 | */ |
119 | 135 | function renderInterpErrors() { |
— | — | @@ -125,17 +141,10 @@ |
126 | 142 | function renderTable() { |
127 | 143 | $questionTable = array(); |
128 | 144 | # add header views to $questionTable |
| 145 | + $rowattrs = array(); |
129 | 146 | foreach ( $this->hviews as $header ) { |
130 | | - $rowattrs = array(); |
131 | | - $attribute_maps = array(); |
132 | | - if ( is_object( $header ) ) { |
133 | | - $row = &$header->row; |
134 | | - $rowattrs['class'] = $header->className; |
135 | | - $attribute_maps = &$header->attribute_maps; |
136 | | - } else { |
137 | | - $row = &$header; |
138 | | - } |
139 | | - qp_Renderer::addRow( $questionTable, $row, $rowattrs, 'th', $attribute_maps ); |
| 147 | + $rowattrs['class'] = $header->className; |
| 148 | + qp_Renderer::addRow( $questionTable, $header->row, $rowattrs, 'th', $header->attribute_maps ); |
140 | 149 | } |
141 | 150 | return $questionTable; |
142 | 151 | } |
— | — | @@ -170,7 +179,17 @@ |
171 | 180 | array( '__tag' => 'span', 'class' => 'questionId', 0 => $this->ctrl->usedId ) |
172 | 181 | ); |
173 | 182 | } |
174 | | - $tags[] = array( '__tag' => 'div', 0 => $this->rtp( $this->ctrl->mCommonQuestion ) ); |
| 183 | + if ( $this->headerErrorMessage !== '' ) { |
| 184 | + # either fatal or proposal error occured |
| 185 | + $tags[] = array( |
| 186 | + '__tag' => 'div', |
| 187 | + 'class' => ( $this->ctrl->getState() === 'error' ) ? 'fatalerror' : 'proposalerror', |
| 188 | + qp_Setup::specialchars( $this->headerErrorMessage ) |
| 189 | + ); |
| 190 | + } |
| 191 | + $tags[] = array( '__tag' => 'div', $this->rtp( $this->ctrl->mCommonQuestion ) ); |
| 192 | + # class 'question_mod4_[0-3]' is used to prettify question table cells; |
| 193 | + # todo: at some later point, when HTML5/CSS3 will take over, this will not be needed. |
175 | 194 | $tags = array( '__tag' => 'div', '__end' => "\n", 'class' => 'question question_mod4_' . ( $this->ctrl->usedId % 4 ), $tags ); |
176 | 195 | $tags[] = &$output_table; |
177 | 196 | return qp_Renderer::renderTagArray( $tags ); |
Index: trunk/extensions/QPoll/view/question/qp_textquestionview.php |
— | — | @@ -102,7 +102,9 @@ |
103 | 103 | $value = $elem->options[0]; |
104 | 104 | if ( $tagName === 'textarea' ) { |
105 | 105 | # oversimplicated regexp, but it's enough for our needs |
106 | | - $value = preg_replace( '/<br[\sA-Z\d="]*\/{0,1}>/i', qp_Setup::TEXTAREA_LINES_SEPARATOR, $value, -1, $lines_count ); |
| 106 | + # todo: multiline proposals do not require to use <br> to separate lines |
| 107 | + # of pre-filled text, check out when question type="free" will be implemented. |
| 108 | + $value = preg_replace( '/<br[\sA-Z\d="]*\/?>/i', qp_Setup::TEXTAREA_LINES_SEPARATOR, $value, -1, $lines_count ); |
107 | 109 | $lines_count++; |
108 | 110 | } |
109 | 111 | $className .= ' cat_prefilled'; |