1<?php 2// src/settings/s_reviewform.php -- HotCRP review form definition page 3// Copyright (c) 2006-2018 Eddie Kohler; see LICENSE. 4 5class ReviewForm_SettingParser extends SettingParser { 6 private $nrfj; 7 private $option_error; 8 9 public static $setting_prefixes = ["shortName_", "description_", "order_", "authorView_", "options_", "option_class_prefix_"]; 10 11 private function check_options(SettingValues $sv, $fid, $fj) { 12 $text = cleannl($sv->req["options_$fid"]); 13 $letters = ($text && ord($text[0]) >= 65 && ord($text[0]) <= 90); 14 $expect = ($letters ? "[A-Z]" : "[1-9]"); 15 16 $opts = array(); 17 $lowonum = 10000; 18 $allow_empty = false; 19 20 foreach (explode("\n", $text) as $line) { 21 $line = trim($line); 22 if ($line != "") { 23 if ((preg_match("/^($expect)\\.\\s*(\\S.*)/", $line, $m) 24 || preg_match("/^($expect)\\s+(\\S.*)/", $line, $m)) 25 && !isset($opts[$m[1]])) { 26 $onum = ($letters ? ord($m[1]) : (int) $m[1]); 27 $lowonum = min($lowonum, $onum); 28 $opts[$onum] = $m[2]; 29 } else if (preg_match('/^(?:0\.\s*)?No entry$/i', $line)) 30 $allow_empty = true; 31 else 32 return false; 33 } 34 } 35 36 // numeric options must start from 1 37 if (!$letters && count($opts) > 0 && $lowonum != 1) 38 return false; 39 40 $text = ""; 41 $seqopts = array(); 42 for ($onum = $lowonum; $onum < $lowonum + count($opts); ++$onum) { 43 if (!isset($opts[$onum])) // options out of order 44 return false; 45 $seqopts[] = $opts[$onum]; 46 } 47 48 unset($fj->option_letter, $fj->allow_empty); 49 if ($letters) { 50 $seqopts = array_reverse($seqopts, true); 51 $fj->option_letter = chr($lowonum); 52 } 53 $fj->options = array_values($seqopts); 54 if ($allow_empty) 55 $fj->allow_empty = true; 56 return true; 57 } 58 59 private function populate_field($fj, ReviewField $f, SettingValues $sv, $fid) { 60 $sn = simplify_whitespace(get($sv->req, "shortName_$fid", "")); 61 if ($sn === "<None>" || $sn === "<New field>" || $sn === "Field name") 62 $sn = ""; 63 64 if (isset($sv->req["order_$fid"])) 65 $pos = cvtnum(get($sv->req, "order_$fid")); 66 else 67 $pos = get($fj, "position", -1); 68 if ($pos > 0 && $sn == "" 69 && isset($sv->req["description_$fid"]) 70 && trim($sv->req["description_$fid"]) === "" 71 && (!$f->has_options 72 || (isset($sv->req["options_$fid"]) 73 ? trim($sv->req["options_$fid"]) === "" 74 : empty($fj->options)))) 75 $pos = -1; 76 77 if ($sn !== "") 78 $fj->name = $sn; 79 else if ($pos > 0) 80 $sv->error_at("shortName_$fid", "Missing review field name."); 81 82 if (isset($sv->req["authorView_$fid"])) 83 $fj->visibility = $sv->req["authorView_$fid"]; 84 85 if (isset($sv->req["description_$fid"])) { 86 $x = CleanHTML::basic_clean($sv->req["description_$fid"], $err); 87 if ($x !== false) { 88 $fj->description = trim($x); 89 if ($fj->description === "") 90 unset($fj->description); 91 } else if ($pos > 0) 92 $sv->error_at("description_$fid", htmlspecialchars($sn) . " description: " . $err); 93 } 94 95 if ($pos > 0) 96 $fj->position = $pos; 97 else 98 unset($fj->position); 99 100 if ($f->has_options) { 101 $ok = true; 102 if (isset($sv->req["options_$fid"])) 103 $ok = $this->check_options($sv, $fid, $fj); 104 if ((!$ok || count($fj->options) < 2) && $pos > 0) { 105 $sv->error_at("options_$fid", htmlspecialchars($sn) . ": Invalid options."); 106 if ($this->option_error) 107 $sv->error_at(null, $this->option_error); 108 $this->option_error = false; 109 } 110 if (isset($sv->req["option_class_prefix_$fid"])) { 111 $prefixes = ["sv", "svr", "sv-blpu", "sv-publ", "sv-viridis", "sv-viridisr"]; 112 $pindex = array_search($sv->req["option_class_prefix_$fid"], $prefixes) ? : 0; 113 if (get($sv->req, "option_class_prefix_flipped_$fid")) 114 $pindex ^= 1; 115 $fj->option_class_prefix = $prefixes[$pindex]; 116 } 117 } 118 119 if (isset($sv->req["round_list_$fid"])) { 120 $fj->round_mask = 0; 121 foreach (explode(" ", trim($sv->req["round_list_$fid"])) as $round_name) 122 if ($round_name !== "") 123 $fj->round_mask |= 1 << (int) $sv->conf->round_number($round_name, false); 124 } 125 } 126 127 static function requested_fields(SettingValues $sv) { 128 $fs = []; 129 $max_fields = ["s" => "s00", "t" => "t00"]; 130 foreach ($sv->conf->review_form()->fmap as $fid => $f) { 131 $fs[$f->short_id] = true; 132 if (strcmp($f->short_id, $max_fields[$f->short_id[0]]) > 0) 133 $max_fields[$f->short_id[0]] = $f->short_id; 134 } 135 for ($i = 1; ; ++$i) { 136 $fid = sprintf("s%02d", $i); 137 if (isset($sv->req["shortName_$fid"]) || isset($sv->req["order_$fid"])) 138 $fs[$fid] = true; 139 else if (strcmp($fid, $max_fields["s"]) > 0) 140 break; 141 } 142 for ($i = 1; ; ++$i) { 143 $fid = sprintf("t%02d", $i); 144 if (isset($sv->req["shortName_$fid"]) || isset($sv->req["order_$fid"])) 145 $fs[$fid] = true; 146 else if (strcmp($fid, $max_fields["t"]) > 0) 147 break; 148 } 149 return $fs; 150 } 151 152 function parse(SettingValues $sv, Si $si) { 153 $this->nrfj = (object) array(); 154 $this->option_error = "Review fields with options must have at least two choices, numbered sequentially from 1 (higher numbers are better) or lettered with consecutive uppercase letters (lower letters are better). Example: <pre>1. Low quality 1552. Medium quality 1563. High quality</pre>"; 157 158 $rf = $sv->conf->review_form(); 159 foreach (self::requested_fields($sv) as $fid => $x) { 160 $finfo = ReviewInfo::field_info($fid, $sv->conf); 161 if (!$finfo) { 162 if (isset($sv->req["order_$fid"]) && $sv->req["order_$fid"] > 0) 163 $sv->error_at("shortName_$fid", htmlspecialchars($sv->req["shortName_$fid"]) . ": Too many review fields. You must delete some other fields before adding this one."); 164 continue; 165 } 166 if (isset($rf->fmap[$finfo->id])) 167 $f = $rf->fmap[$finfo->id]; 168 else 169 $f = new ReviewField($finfo, $sv->conf); 170 $fj = $f->unparse_json(true); 171 if (isset($sv->req["shortName_$fid"])) { 172 $this->populate_field($fj, $f, $sv, $fid); 173 $xf = clone $f; 174 $xf->assign($fj); 175 $fj = $xf->unparse_json(true); 176 } 177 $this->nrfj->{$finfo->id} = $fj; 178 } 179 180 $sv->need_lock["PaperReview"] = true; 181 return true; 182 } 183 184 private function clear_existing_fields($fields, Conf $conf) { 185 // clear fields from main storage 186 $clear_sfields = $clear_tfields = []; 187 foreach ($fields as $f) { 188 if ($f->main_storage) { 189 if ($f->has_options) 190 $result = $conf->qe("update PaperReview set {$f->main_storage}=0"); 191 else 192 $result = $conf->qe("update PaperReview set {$f->main_storage}=null"); 193 } 194 if ($f->json_storage) { 195 if ($f->has_options) 196 $clear_sfields[] = $f; 197 else 198 $clear_tfields[] = $f; 199 } 200 } 201 if (!$clear_sfields && !$clear_tfields) 202 return; 203 204 // clear fields from json storage 205 $clearf = Dbl::make_multi_qe_stager($conf->dblink); 206 $result = $conf->qe("select * from PaperReview where sfields is not null or tfields is not null"); 207 while (($rrow = ReviewInfo::fetch($result, $conf))) { 208 $cleared = false; 209 foreach ($clear_sfields as $f) 210 if (isset($rrow->{$f->id})) { 211 unset($rrow->{$f->id}, $rrow->{$f->short_id}); 212 $cleared = true; 213 } 214 if ($cleared) 215 $clearf("update PaperReview set sfields=? where paperId=? and reviewId=?", [$rrow->unparse_sfields(), $rrow->paperId, $rrow->reviewId]); 216 $cleared = false; 217 foreach ($clear_tfields as $f) 218 if (isset($rrow->{$f->id})) { 219 unset($rrow->{$f->id}, $rrow->{$f->short_id}); 220 $cleared = true; 221 } 222 if ($cleared) 223 $clearf("update PaperReview set tfields=? where paperId=? and reviewId=?", [$rrow->unparse_tfields(), $rrow->paperId, $rrow->reviewId]); 224 } 225 $clearf(null); 226 } 227 228 private function clear_nonexisting_options($fields, Conf $conf) { 229 $updates = []; 230 231 // clear options from main storage 232 $clear_sfields = []; 233 foreach ($fields as $f) { 234 if ($f->main_storage) { 235 $result = $conf->qe("update PaperReview set {$f->main_storage}=0 where {$f->main_storage}>" . count($f->options)); 236 if ($result && $result->affected_rows > 0) 237 $updates[$f->name] = true; 238 } 239 if ($f->json_storage) 240 $clear_sfields[] = $f; 241 } 242 243 if ($clear_sfields) { 244 // clear options from json storage 245 $clearf = Dbl::make_multi_qe_stager($conf->dblink); 246 $result = $conf->qe("select * from PaperReview where sfields is not null"); 247 while (($rrow = ReviewInfo::fetch($result, $conf))) { 248 $cleared = false; 249 foreach ($clear_sfields as $f) 250 if (isset($rrow->{$f->id}) && $rrow->{$f->id} > count($f->options)) { 251 unset($rrow->{$f->id}, $rrow->{$f->short_id}); 252 $cleared = $updates[$f->name] = true; 253 } 254 if ($cleared) 255 $clearf("update PaperReview set sfields=? where paperId=? and reviewId=?", [$rrow->unparse_sfields(), $rrow->paperId, $rrow->reviewId]); 256 } 257 $clearf(null); 258 } 259 260 return array_keys($updates); 261 } 262 263 function save(SettingValues $sv, Si $si) { 264 global $Now; 265 if (!$sv->update("review_form", json_encode_db($this->nrfj))) 266 return; 267 $oform = $sv->conf->review_form(); 268 $nform = new ReviewForm($this->nrfj, $sv->conf); 269 $clear_fields = $clear_options = []; 270 $reset_wordcount = $assign_ordinal = false; 271 foreach ($nform->all_fields() as $nf) { 272 $of = get($oform->fmap, $nf->id); 273 if ($nf->displayed && (!$of || !$of->displayed)) 274 $clear_fields[] = $nf; 275 else if ($nf->displayed && $nf->has_options 276 && count($nf->options) < count($of->options)) 277 $clear_options[] = $nf; 278 if ($of && $of->include_word_count() != $nf->include_word_count()) 279 $reset_wordcount = true; 280 if ($of && $of->displayed && $of->view_score < VIEWSCORE_AUTHORDEC 281 && $nf->displayed && $nf->view_score >= VIEWSCORE_AUTHORDEC) 282 $assign_ordinal = true; 283 foreach (self::$setting_prefixes as $fx) 284 unset($sv->req[$fx . $nf->short_id]); 285 } 286 $sv->conf->invalidate_caches(["rf" => true]); 287 // reset existing review values 288 if (!empty($clear_fields)) 289 $this->clear_existing_fields($clear_fields, $sv->conf); 290 // ensure no review has a nonexisting option 291 if (!empty($clear_options)) { 292 $updates = $this->clear_nonexisting_options($clear_options, $sv->conf); 293 if (!empty($updates)) { 294 sort($updates); 295 $sv->warning_at(null, "Your changes invalidated some existing review scores. The invalid scores have been reset to “Unknown”. The relevant fields were: " . join(", ", $updates) . "."); 296 } 297 } 298 // reset all word counts if author visibility changed 299 if ($reset_wordcount) 300 $sv->conf->qe("update PaperReview set reviewWordCount=null"); 301 // assign review ordinals if necessary 302 if ($assign_ordinal) { 303 $rrows = []; 304 $result = $sv->conf->qe("select * from PaperReview where reviewOrdinal=0 and reviewSubmitted>0"); 305 while (($rrow = ReviewInfo::fetch($result, $sv->conf))) 306 $rrows[] = $rrow; 307 Dbl::free($result); 308 $locked = false; 309 foreach ($rrows as $rrow) 310 if ($nform->nonempty_view_score($rrow) >= VIEWSCORE_AUTHORDEC) { 311 if (!$locked) { 312 $sv->conf->qe("lock tables PaperReview write"); 313 $locked = true; 314 } 315 $max_ordinal = $sv->conf->fetch_ivalue("select coalesce(max(reviewOrdinal), 0) from PaperReview where paperId=? group by paperId", $rrow->paperId); 316 if ($max_ordinal !== null) 317 $sv->conf->qe("update PaperReview set reviewOrdinal=?, timeDisplayed=? where paperId=? and reviewId=?", $max_ordinal + 1, $Now, $rrow->paperId, $rrow->reviewId); 318 } 319 if ($locked) 320 $sv->conf->qe("unlock tables"); 321 } 322 } 323} 324 325class ReviewForm_SettingRenderer { 326static function render(SettingValues $sv) { 327 global $ConfSitePATH; 328 329 $samples = json_decode(file_get_contents("$ConfSitePATH/etc/reviewformlibrary.json")); 330 331 $rf = $sv->conf->review_form(); 332 $req = []; 333 if ($sv->use_req()) 334 foreach (array_keys(ReviewForm_SettingParser::requested_fields($sv)) as $fid) { 335 foreach (ReviewForm_SettingParser::$setting_prefixes as $fx) 336 if (isset($sv->req["$fx$fid"])) 337 $req["$fx$fid"] = $sv->req["$fx$fid"]; 338 } 339 340 Ht::stash_html('<div id="review_form_caption_description" class="hidden">' 341 . '<p>Enter an HTML description for the review form. 342Include any guidance you’d like to provide for reviewers. 343Note that complex HTML will not appear on offline review forms.</p></div>' 344 . '<div id="review_form_caption_options" class="hidden">' 345 . '<p>Enter one option per line, numbered starting from 1 (higher numbers 346are better). For example:</p> 347<pre class="entryexample dark">1. Reject 3482. Weak reject 3493. Weak accept 3504. Accept</pre> 351<p>Or use consecutive capital letters (lower letters are better).</p> 352<p>Normally scores are mandatory: a review with a missing score cannot be 353submitted. Add a “<code>No entry</code>” line to make the score optional.</p></div>'); 354 355 $rfj = []; 356 foreach ($rf->fmap as $f) 357 $rfj[$f->short_id] = $f->unparse_json(); 358 359 // track whether fields have any nonempty values 360 $where = ["false", "false"]; 361 foreach ($rf->fmap as $f) { 362 $fj = $rfj[$f->short_id]; 363 $fj->internal_id = $f->id; 364 $fj->has_any_nonempty = false; 365 if ($f->json_storage) { 366 if ($f->has_options) 367 $where[0] = "sfields is not null"; 368 else 369 $where[1] = "tfields is not null"; 370 } else { 371 if ($f->has_options) 372 $where[] = "{$f->main_storage}!=0"; 373 else 374 $where[] = "coalesce({$f->main_storage},'')!=''"; 375 } 376 } 377 378 $unknown_nonempty = array_values($rfj); 379 $limit = 0; 380 while (!empty($unknown_nonempty)) { 381 $result = $sv->conf->qe("select * from PaperReview where " . join(" or ", $where) . " limit $limit,100"); 382 $expect_limit = $limit + 100; 383 while (($rrow = ReviewInfo::fetch($result, $sv->conf))) { 384 for ($i = 0; $i < count($unknown_nonempty); ++$i) { 385 $fj = $unknown_nonempty[$i]; 386 $fid = $fj->internal_id; 387 if (isset($rrow->$fid) 388 && (isset($fj->options) ? (int) $rrow->$fid !== 0 : $rrow->$fid !== "")) { 389 $fj->has_any_nonempty = true; 390 array_splice($unknown_nonempty, $i, 1); 391 } else 392 ++$i; 393 } 394 ++$limit; 395 } 396 Dbl::free($result); 397 if ($limit !== $expect_limit) // ran out of reviews 398 break; 399 } 400 401 // output settings json 402 Ht::stash_script("review_form_settings({" 403 . "fields:" . json_encode_browser($rfj) 404 . ", samples:" . json_encode_browser($samples) 405 . ", errf:" . json_encode_browser($sv->message_field_map()) 406 . ", req:" . json_encode_browser($req) 407 . ", stemplate:" . json_encode_browser(ReviewField::make_template(true, $sv->conf)) 408 . ", ttemplate:" . json_encode_browser(ReviewField::make_template(false, $sv->conf)) 409 . "})"); 410 411 echo Ht::hidden("has_review_form", 1), 412 "<div id=\"reviewform_container\"></div>", 413 "<div id=\"reviewform_removedcontainer\"></div>", 414 Ht::button("Add score field", ["class" => "btn settings-add-review-field score"]), 415 "<span class='sep'></span>", 416 Ht::button("Add text field", ["class" => "btn settings-add-review-field"]); 417 Ht::stash_script('$("button.settings-add-review-field").on("click", function () { review_form_settings.add(hasClass(this,"score")?1:0) })'); 418} 419} 420