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