1<?php 2// search/st_review.php -- HotCRP helper class for searching for papers 3// Copyright (c) 2006-2018 Eddie Kohler; see LICENSE. 4 5class ReviewSearchMatcher extends ContactCountMatcher { 6 const COMPLETE = 1; 7 const INCOMPLETE = 2; 8 const INPROGRESS = 4; 9 const APPROVABLE = 8; 10 11 private $review_type = 0; 12 private $completeness = 0; 13 public $view_score; 14 public $round; 15 public $review_testable = true; 16 private $tokens; 17 private $wordcountexpr; 18 private $rfield; 19 private $rfield_score1; 20 private $rfield_score2; 21 private $rfield_scoret; 22 private $rfield_scorex; 23 private $rfield_text; 24 private $requester; 25 private $ratings; 26 private $frozen = false; 27 28 function __construct($countexpr = null, $contacts = null) { 29 parent::__construct($countexpr, $contacts); 30 } 31 function only_pc() { 32 return $this->review_type >= REVIEW_PC; 33 } 34 function review_type() { 35 return $this->review_type; 36 } 37 function has_wordcount() { 38 return !!$this->wordcountexpr; 39 } 40 function apply_review_type($word, $allow_pc = false) { 41 if ($word === "meta") 42 $this->review_type = REVIEW_META; 43 else if ($word === "pri" || $word === "primary") 44 $this->review_type = REVIEW_PRIMARY; 45 else if ($word === "sec" || $word === "secondary") 46 $this->review_type = REVIEW_SECONDARY; 47 else if ($word === "optional") 48 $this->review_type = REVIEW_PC; 49 else if ($allow_pc && ($word === "pc" || $word === "pcre" || $word === "pcrev")) 50 $this->review_type = REVIEW_PC; 51 else if ($word === "ext" || $word === "external") 52 $this->review_type = REVIEW_EXTERNAL; 53 else 54 return false; 55 return true; 56 } 57 function apply_completeness($word) { 58 if ($word === "complete" || $word === "done") 59 $this->completeness |= self::COMPLETE; 60 else if ($word === "incomplete") 61 $this->completeness |= self::INCOMPLETE; 62 else if ($word === "approvable") 63 $this->completeness |= self::APPROVABLE; 64 else if ($word === "draft" || $word === "inprogress" || $word === "in-progress" || $word === "partial") 65 $this->completeness |= self::INPROGRESS; 66 else 67 return false; 68 return true; 69 } 70 function apply_round($word, Conf $conf) { 71 $round = $conf->round_number($word, false); 72 if ($round !== false) { 73 $this->round[] = $round; 74 return true; 75 } else 76 return false; 77 } 78 function apply_countexpr($word, $default_op = "=") { 79 if (preg_match('/\A(?:(?:[=!<>]=?|≠|≤|≥|)\d+|any|none|yes|no)\z/', $word)) { 80 if (ctype_digit($word)) 81 $word = $default_op . $word; 82 $count = PaperSearch::unpack_comparison($word, false); 83 $this->set_countexpr($count[1]); 84 $this->review_testable = false; 85 return true; 86 } else 87 return false; 88 } 89 function adjust_round_list($rounds) { 90 if ($this->round === null) 91 $this->round = $rounds; 92 } 93 function apply_requester($cid) { 94 $this->requester = $cid; 95 } 96 function apply_wordcount($wordcount) { 97 assert($this->wordcountexpr === null); 98 if ($wordcount) { 99 $this->wordcountexpr = $wordcount; 100 if ($this->completeness === 0) 101 $this->apply_completeness("complete"); 102 } 103 } 104 function apply_tokens($tokens) { 105 assert($this->tokens === null); 106 $this->tokens = $tokens; 107 } 108 function adjust_ratings(ReviewRating_SearchAdjustment $rrsa) { 109 if ($this->ratings === null) 110 $this->ratings = $rrsa; 111 } 112 function apply_text_field(ReviewField $field, $value) { 113 assert(!$this->rfield && !$field->has_options); 114 if (!$this->completeness) 115 $this->completeness = self::COMPLETE; 116 $this->rfield = $field; 117 $this->rfield_text = $value; 118 } 119 function apply_score_field(ReviewField $field, $value1, $value2, $valuet) { 120 assert(!$this->rfield && $field->has_options); 121 if (!$this->completeness) 122 $this->completeness = self::COMPLETE; 123 $this->rfield = $field; 124 $this->rfield_score1 = $value1; 125 $this->rfield_score2 = $value2; 126 $this->rfield_scoret = $valuet; 127 } 128 function useful_sqlexpr($table_name) { 129 if ($this->test(0)) 130 return false; 131 $where = []; 132 if ($this->completeness & ReviewSearchMatcher::COMPLETE) 133 $where[] = "reviewSubmitted is not null"; 134 if ($this->completeness & ReviewSearchMatcher::APPROVABLE) 135 $where[] = "(reviewSubmitted is null and timeApprovalRequested>0)"; 136 if ($this->has_contacts()) { 137 $cm = $this->contact_match_sql("contactId"); 138 if ($this->tokens) 139 $cm = "($cm or reviewToken in (" . join(",", $this->tokens) . "))"; 140 $where[] = $cm; 141 } 142 if ($this->rfield) { 143 if ($this->rfield->has_options) { 144 if ($this->rfield->main_storage) { 145 if ($this->rfield_scoret >= 8) 146 $ce = ">="; 147 else 148 $ce = CountMatcher::$oparray[$this->rfield_scoret]; 149 $where[] = $this->rfield->main_storage . $ce . $this->rfield_score1; 150 } else { 151 if ($this->rfield_score1 != 0 152 || !($this->rfield_scoret & 2)) 153 $where[] = "sfields is not null"; 154 } 155 } else { 156 if ($this->rfield->main_storage) { 157 $where[] = $this->rfield->main_storage . "!=''"; 158 } else { 159 if ($this->rfield_text) 160 $where[] = "tfields is not null"; 161 } 162 } 163 } 164 if ($this->ratings && $this->ratings->must_exist()) 165 $where[] = "exists (select * from ReviewRating where paperId={$table_name}.paperId and reviewId={$table_name}.reviewId)"; 166 if ($this->requester) 167 $where[] = "requestedBy=" . $this->requester; 168 if (empty($where)) 169 return false; 170 else 171 return join(" and ", $where); 172 } 173 function prepare_reviews(PaperInfo $prow) { 174 if ($this->wordcountexpr) 175 $prow->ensure_review_word_counts(); 176 if (($this->rfield && !$this->rfield->has_options) 177 || $this->ratings) 178 $prow->ensure_full_reviews(); 179 else if ($this->rfield) 180 $prow->ensure_review_score($this->rfield); 181 $this->rfield_scorex = $this->rfield_scoret === 16 ? 0 : 3; 182 } 183 function test_review(Contact $user, PaperInfo $prow, ReviewInfo $rrow, PaperSearch $srch) { 184 if ($this->review_type 185 && $this->review_type !== $rrow->reviewType) 186 return false; 187 if ($this->completeness) { 188 if ((($this->completeness & self::COMPLETE) 189 && !$rrow->reviewSubmitted) 190 || (($this->completeness & self::INCOMPLETE) 191 && !$rrow->reviewNeedsSubmit) 192 || (($this->completeness & self::INPROGRESS) 193 && ($rrow->reviewSubmitted || !$rrow->reviewModified)) 194 || (($this->completeness & self::APPROVABLE) 195 && ($rrow->reviewSubmitted 196 || $rrow->timeApprovalRequested <= 0 197 || ($rrow->requestedBy != $user->contactId 198 && !$user->allow_administer($prow))))) 199 return false; 200 } 201 if ($this->round !== null 202 && !in_array($rrow->reviewRound, $this->round)) 203 // XXX can_view_review_round? 204 return false; 205 if ($this->rfield || $this->wordcountexpr || $this->ratings 206 ? !$user->can_view_review($prow, $rrow) 207 : !$user->can_view_review_assignment($prow, $rrow)) 208 return false; 209 if ($this->has_contacts()) { 210 if (!$this->test_contact($rrow->contactId) 211 && (!$this->tokens || !in_array($rrow->reviewToken, $this->tokens))) 212 return false; 213 if (!$user->can_view_review_identity($prow, $rrow)) 214 return false; 215 } else if ($rrow->reviewSubmitted <= 0 && $rrow->reviewNeedsSubmit <= 0) 216 // don't count delegated reviews unless contacts given 217 return false; 218 if ($this->wordcountexpr 219 && !$this->wordcountexpr->test($rrow->reviewWordCount)) 220 return false; 221 if ($this->requester !== null 222 && ($rrow->requestedBy != $this->requester 223 || !$user->can_view_review_requester($prow, $rrow))) 224 return false; 225 if ($this->ratings !== null 226 && !$this->ratings->test($user, $prow, $rrow)) 227 return false; 228 if ($this->view_score !== null 229 && $this->view_score <= $user->view_score_bound($prow, $rrow)) 230 return false; 231 if ($this->rfield) { 232 $fid = $this->rfield->id; 233 $val = isset($rrow->$fid) ? $rrow->$fid : null; 234 if ($this->rfield->has_options) { 235 if ($this->rfield_scoret >= 8) { 236 if (!$val || $this->rfield_scorex < 0) { 237 return false; 238 } else if ($val < $this->rfield_score1 || $val > $this->rfield_score2) { 239 $this->rfield_scorex = -1; 240 return false; 241 } else { 242 if ($val == $this->rfield_score1) 243 $this->rfield_scorex |= 1; 244 if ($val == $this->rfield_score2) 245 $this->rfield_scorex |= 2; 246 return true; 247 } 248 } else if ($val) { 249 return CountMatcher::compare($val, $this->rfield_scoret, $this->rfield_score1); 250 } else { 251 return $this->rfield_score1 == 0 && ($this->rfield_scoret & 2); 252 } 253 } else { 254 if ((string) $val === "") { 255 return false; 256 } else if ($this->rfield_text !== true) { 257 if (!$rrow->field_match_pregexes($this->rfield_text, $fid)) 258 return false; 259 } 260 } 261 } 262 return true; 263 } 264 function test_finish($n) { 265 return $this->test($n) && $this->rfield_scorex === 3; 266 } 267} 268 269class Review_SearchTerm extends SearchTerm { 270 private $rsm; 271 private static $recompleteness_map = [ 272 "c" => "complete", "i" => "incomplete", "p" => "partial" 273 ]; 274 275 function __construct(ReviewSearchMatcher $rsm) { 276 parent::__construct("re"); 277 $this->rsm = $rsm; 278 } 279 static function keyword_factory($keyword, Conf $conf, $kwfj, $m) { 280 $c = str_replace("-", "", $m[1]); 281 return (object) [ 282 "name" => $keyword, "parse_callback" => "Review_SearchTerm::parse", 283 "retype" => str_replace("-", "", $m[2]), 284 "recompleteness" => get(self::$recompleteness_map, $c, $c), 285 "has" => ">0" 286 ]; 287 } 288 static function parse($word, SearchWord $sword, PaperSearch $srch) { 289 $rsm = new ReviewSearchMatcher(">0"); 290 if ($sword->kwdef->retype) 291 $rsm->apply_review_type($sword->kwdef->retype); 292 if ($sword->kwdef->recompleteness) 293 $rsm->apply_completeness($sword->kwdef->recompleteness); 294 295 $qword = $sword->qword; 296 $quoted = false; 297 $contacts = null; 298 $wordcount = null; 299 $tailre = '(?:\z|:|(?=[=!<>]=?|≠|≤|≥))(.*)\z/s'; 300 while ($qword !== "") { 301 if (preg_match('/\A(.+?)' . $tailre, $qword, $m) 302 && ($rsm->apply_review_type($m[1]) 303 || $rsm->apply_completeness($m[1]) 304 || $rsm->apply_round($m[1], $srch->conf) 305 || $rsm->apply_countexpr($m[1]))) { 306 $qword = $m[2]; 307 } else if (preg_match('/\A(?:au)?words((?:[=!<>]=?|≠|≤|≥)\d+)(?:\z|:)(.*)\z/', $qword, $m)) { 308 $wordcount = new CountMatcher($m[1]); 309 $qword = $m[2]; 310 } else if (preg_match('/\A(..*?|"[^"]+(?:"|\z))' . $tailre, $qword, $m)) { 311 if (($quoted = $m[1][0] === "\"")) 312 $m[1] = str_replace(array('"', '*'), array('', '\*'), $m[1]); 313 $contacts = $m[1]; 314 $qword = $m[2]; 315 } else { 316 $rsm->set_countexpr("<0"); 317 break; 318 } 319 } 320 321 if (($qr = PaperSearch::check_tautology($rsm->countexpr()))) { 322 $qr->set_float("used_revadj", true); 323 return $qr; 324 } 325 326 $rsm->apply_wordcount($wordcount); 327 if ($contacts) { 328 $rsm->set_contacts($srch->matching_users($contacts, $quoted, $rsm->only_pc())); 329 if (strcasecmp($contacts, "me") == 0) 330 $rsm->apply_tokens($srch->user->review_tokens()); 331 } 332 return new Review_SearchTerm($rsm); 333 } 334 335 static function review_field_factory($keyword, Conf $conf, $kwfj, $m) { 336 $f = $conf->find_all_fields($keyword); 337 if (count($f) == 1 && $f[0] instanceof ReviewField) 338 return (object) [ 339 "name" => $keyword, "parse_callback" => "Review_SearchTerm::parse_review_field", 340 "review_field" => $f[0], "has" => "any" 341 ]; 342 else 343 return null; 344 } 345 static function parse_review_field($word, SearchWord $sword, PaperSearch $srch) { 346 $f = $sword->kwdef->review_field; 347 $rsm = new ReviewSearchMatcher(">0"); 348 $rsm->view_score = $f->view_score; 349 350 $contactword = ""; 351 while (preg_match('/\A([^<>].*?|[<>].+?)([:=!<>]|≠|≤|≥)(.*)\z/s', $word, $m)) { 352 if ($rsm->apply_review_type($m[1]) 353 || $rsm->apply_completeness($m[1]) 354 || $rsm->apply_round($m[1], $srch->conf) 355 || $rsm->apply_countexpr($m[1], ">=")) 356 /* OK */; 357 else 358 $rsm->set_contacts($srch->matching_users($m[1], $sword->quoted, false)); 359 $word = ($m[2] === ":" ? $m[3] : $m[2] . $m[3]); 360 $contactword .= $m[1] . ":"; 361 } 362 363 if ($f->has_options) { 364 return self::parse_score_field($rsm, $word, $f, $srch); 365 } else { 366 if ($word === "any" && !$sword->quoted) { 367 $val = true; 368 } else if ($word === "none" && !$sword->quoted) { 369 $val = true; 370 $rsm->set_countexpr("=0"); 371 } else { 372 $val = Text::star_text_pregexes($word, $sword->quoted); 373 } 374 $rsm->apply_text_field($f, $val); 375 return new Review_SearchTerm($rsm); 376 } 377 } 378 private static function impossible_score_match(ReviewField $f) { 379 $t = new False_SearchTerm; 380 $r = $f->full_score_range(); 381 $t->set_float("contradiction_warning", "$f->name_html scores range from $r[0] to $r[1]."); 382 $t->set_float("used_revadj", true); 383 return $t; 384 } 385 private static function parse_score($f, $str) { 386 if (strcasecmp($str, "none") == 0) 387 return 0; 388 else if ($f->option_letter != (ctype_digit($str) === false)) // `!=` matters 389 return false; 390 else if ($f->option_letter) { 391 $val = $f->option_letter - ord(strtoupper($str)); 392 return $val > 0 && $val <= count($f->options) ? $val : false; 393 } else { 394 $val = intval($str); 395 return $val >= 0 && $val <= count($f->options) ? $val : false; 396 } 397 } 398 private static function parse_score_field(ReviewSearchMatcher $rsm, $word, ReviewField $f, PaperSearch $srch) { 399 if ($word === "any") { 400 $rsm->apply_score_field($f, 0, 0, 4); 401 } else if ($word === "none" && $rsm->review_testable) { 402 $rsm->apply_countexpr("=0"); 403 $rsm->apply_score_field($f, 0, 0, 4); 404 } else if (preg_match('/\A([=!<>]=?|≠|≤|≥|)\s*([A-Z]|\d+|none)\z/si', $word, $m)) { 405 if ($f->option_letter && !$srch->conf->opt("smartScoreCompare")) 406 $m[1] = CountMatcher::flip_countexpr_string($m[1]); 407 $score = self::parse_score($f, $m[2]); 408 if ($score === false) 409 return self::impossible_score_match($f); 410 $rsm->apply_score_field($f, $score, 0, CountMatcher::$opmap[$m[1]]); 411 } else if (preg_match('/\A(\d+|[A-Z]|none)\s*(|-|–|—|\.\.\.?|…)\s*(\d+|[A-Z]|none)\s*\z/si', $word, $m)) { 412 $score1 = self::parse_score($f, $m[1]); 413 $score2 = self::parse_score($f, $m[3]); 414 if ($score1 === false || $score2 === false) 415 return self::impossible_score_match($f); 416 if ($score1 > $score2) 417 list($score1, $score2) = [$score2, $score1]; 418 $precise = $m[2] !== ".." && $m[2] !== "..." && $m[2] !== "…"; 419 $rsm->apply_score_field($f, $score1, $score2, $precise ? 16 : 8); 420 } else // XXX 421 return new False_SearchTerm; 422 return new Review_SearchTerm($rsm); 423 } 424 425 426 function adjust_reviews(ReviewAdjustment_SearchTerm $revadj = null, PaperSearch $srch) { 427 if ($revadj) 428 $revadj->promote_matcher($this->rsm); 429 return $this; 430 } 431 function sqlexpr(SearchQueryInfo $sqi) { 432 $sqi->add_review_signature_columns(); 433 if ($this->rsm->has_wordcount()) 434 $sqi->add_review_word_count_columns(); 435 436 // Make the database query conservative (so change equality 437 // constraints to >= constraints, and ignore <=/</!= constraints). 438 // We'll do the precise query later. 439 // ">=0" is a useless constraint in SQL-land. 440 $cexpr = $this->rsm->conservative_nonnegative_countexpr(); 441 if ($cexpr === ">=0" || $sqi->negated) 442 return "true"; 443 else { 444 $wheres = $this->rsm->useful_sqlexpr("r") ? : "true"; 445 if ($cexpr === ">0") 446 return "exists (select * from PaperReview r where paperId=Paper.paperId and $wheres)"; 447 else 448 return "(select count(*) from PaperReview r where paperId=Paper.paperId and $wheres)" . $cexpr; 449 } 450 } 451 function exec(PaperInfo $prow, PaperSearch $srch) { 452 $n = 0; 453 $this->rsm->prepare_reviews($prow); 454 if ($this->rsm->review_testable && $srch->test_review) 455 return $this->rsm->test_review($srch->user, $prow, $srch->test_review, $srch); 456 else { 457 foreach ($prow->reviews_by_id() as $rrow) 458 $n += $this->rsm->test_review($srch->user, $prow, $rrow, $srch); 459 return $this->rsm->test_finish($n); 460 } 461 } 462} 463