1<?php
2// papersearch.php -- HotCRP helper class for searching for papers
3// Copyright (c) 2006-2018 Eddie Kohler; see LICENSE.
4
5class SearchWord {
6    public $qword;
7    public $word;
8    public $quoted;
9    public $keyword;
10    public $kwexplicit;
11    public $kwdef;
12    function __construct($qword) {
13        $this->qword = $this->word = $qword;
14        $this->quoted = $qword !== "" && $qword[0] === "\""
15            && strpos($qword, "\"", 1) === strlen($qword) - 1;
16        if ($this->quoted)
17            $this->word = substr($qword, 1, -1);
18    }
19}
20
21class SearchSplitter {
22    private $str;
23    private $utf8q;
24    public $pos;
25    public $strspan;
26    function __construct($str) {
27        $this->str = ltrim($str);
28        $this->utf8q = strpos($str, chr(0xE2)) !== false;
29        $this->pos = strlen($str) - strlen($this->str);
30    }
31    function is_empty() {
32        return $this->str === "";
33    }
34    function shift() {
35        if ($this->utf8q
36            && preg_match('/\A([-_.a-zA-Z0-9]+:|["“”][^"“”]+["“”]:|)\s*((?:["“”][^"“”]*(?:["“”]|\z)|[^"“”\s()]*)*)/su', $this->str, $m)) {
37            $result = preg_replace('/[“”]/u', "\"", $m[1] . $m[2]);
38        } else if (preg_match('/\A([-_.a-zA-Z0-9]+:|"[^"]+":|)\s*((?:"[^"]*(?:"|\z)|[^"\s()]*)*)/s', $this->str, $m)) {
39            $result = $m[1] . $m[2];
40        } else {
41            $this->pos += strlen($this->str);
42            $this->str = "";
43            $this->strspan = [$this->pos, $this->pos];
44            return "";
45        }
46        $this->set_span_and_pos($m[0]);
47        return $result;
48    }
49    function shift_past($str) {
50        assert(str_starts_with($this->str, $str));
51        $this->set_span_and_pos($str);
52    }
53    function shift_balanced_parens() {
54        $result = substr($this->str, 0, self::span_balanced_parens($this->str));
55        $this->set_span_and_pos($result);
56        return $result;
57    }
58    function match($re, &$m = null) {
59        return preg_match($re, $this->str, $m);
60    }
61    function starts_with($substr) {
62        return str_starts_with($this->str, $substr);
63    }
64    private function set_span_and_pos($prefix) {
65        $this->strspan = [$this->pos, $this->pos + strlen($prefix)];
66        $next = ltrim(substr($this->str, strlen($prefix)));
67        $this->pos += strlen($this->str) - strlen($next);
68        $this->str = $next;
69    }
70    static function span_balanced_parens($str) {
71        $pcount = $quote = 0;
72        $len = strlen($str);
73        for ($pos = 0; $pos < $len
74                 && (!ctype_space($str[$pos]) || $pcount || $quote); ++$pos) {
75            $ch = $str[$pos];
76            // translate “” -> "
77            if (ord($ch) === 0xE2 && $pos + 2 < $len && ord($str[$pos + 1]) === 0x80
78                && (ord($str[$pos + 2]) & 0xFE) === 0x9C)
79                $ch = "\"";
80            if ($quote) {
81                if ($ch === "\\" && $pos + 1 < strlen($str))
82                    ++$pos;
83                else if ($ch === "\"")
84                    $quote = 0;
85            } else if ($ch === "\"")
86                $quote = 1;
87            else if ($ch === "(" || $ch === "[" || $ch === "{")
88                ++$pcount;
89            else if ($ch === ")" || $ch === "]" || $ch === "}") {
90                if (!$pcount)
91                    break;
92                --$pcount;
93            }
94        }
95        return $pos;
96    }
97}
98
99class SearchOperator {
100    public $op;
101    public $unary;
102    public $precedence;
103    public $opinfo;
104
105    static private $list = null;
106
107    function __construct($what, $unary, $precedence, $opinfo = null) {
108        $this->op = $what;
109        $this->unary = $unary;
110        $this->precedence = $precedence;
111        $this->opinfo = $opinfo;
112    }
113    function unparse() {
114        $x = strtoupper($this->op);
115        return $this->opinfo === null ? $x : $x . ":" . $this->opinfo;
116    }
117
118    static function get($name) {
119        if (!self::$list) {
120            self::$list["("] = new SearchOperator("(", true, null);
121            self::$list[")"] = new SearchOperator(")", true, null);
122            self::$list["NOT"] = new SearchOperator("not", true, 7);
123            self::$list["-"] = new SearchOperator("not", true, 7);
124            self::$list["!"] = new SearchOperator("not", true, 7);
125            self::$list["+"] = new SearchOperator("+", true, 7);
126            self::$list["SPACE"] = new SearchOperator("space", false, 6);
127            self::$list["AND"] = new SearchOperator("and", false, 5);
128            self::$list["OR"] = new SearchOperator("or", false, 4);
129            self::$list["XOR"] = new SearchOperator("or", false, 3);
130            self::$list["THEN"] = new SearchOperator("then", false, 2);
131            self::$list["HIGHLIGHT"] = new SearchOperator("highlight", false, 1, "");
132        }
133        return get(self::$list, $name);
134    }
135}
136
137class SearchTerm {
138    public $type;
139    public $float = [];
140
141    function __construct($type) {
142        $this->type = $type;
143    }
144    static function make_op($op, $terms) {
145        $opstr = is_object($op) ? $op->op : $op;
146        if ($opstr === "not")
147            $qr = new Not_SearchTerm;
148        else if ($opstr === "and" || $opstr === "space")
149            $qr = new And_SearchTerm($opstr);
150        else if ($opstr === "or")
151            $qr = new Or_SearchTerm;
152        else
153            $qr = new Then_SearchTerm($op);
154        foreach (is_array($terms) ? $terms : [$terms] as $qt)
155            $qr->append($qt);
156        return $qr->finish();
157    }
158    static function make_not(SearchTerm $term) {
159        $qr = new Not_SearchTerm;
160        return $qr->append($term)->finish();
161    }
162    function negate_if($negate) {
163        return $negate ? self::make_not($this) : $this;
164    }
165    static function make_float($float) {
166        $qe = new True_SearchTerm;
167        $qe->float = $float;
168        return $qe;
169    }
170
171    function is_false() {
172        return false;
173    }
174    function is_true() {
175        return false;
176    }
177    function is_uninteresting() {
178        return false;
179    }
180    function set_float($k, $v) {
181        $this->float[$k] = $v;
182    }
183    function get_float($k, $defval = null) {
184        return get($this->float, $k, $defval);
185    }
186    function apply_strspan($span) {
187        $span1 = get($this->float, "strspan");
188        if ($span && $span1)
189            $span = [min($span[0], $span1[0]), max($span[1], $span1[1])];
190        $this->set_float("strspan", $span ? : $span1);
191    }
192    function set_strspan_owner($str) {
193        if (!isset($this->float["strspan_owner"]))
194            $this->set_float("strspan_owner", $str);
195    }
196
197
198    function debug_json() {
199        return $this->type;
200    }
201
202
203    // apply rounds to reviewer searches
204    function adjust_reviews(ReviewAdjustment_SearchTerm $revadj = null, PaperSearch $srch) {
205        if ($this->get_float("used_revadj") && $revadj)
206            $revadj->used_revadj = true;
207        return $this;
208    }
209
210
211    function trivial_rights(Contact $user, PaperSearch $srch) {
212        return false;
213    }
214
215
216    static function andjoin_sqlexpr($q, $default = "false") {
217        if (empty($q))
218            return $default;
219        else if (in_array("false", $q))
220            return "false";
221        else
222            return "(" . join(" and ", $q) . ")";
223    }
224    static function orjoin_sqlexpr($q, $default = "false") {
225        if (empty($q))
226            return $default;
227        else if (in_array("true", $q))
228            return "true";
229        else
230            return "(" . join(" or ", $q) . ")";
231    }
232
233    function sqlexpr(SearchQueryInfo $sqi) {
234        assert(false);
235        return "false";
236    }
237
238    function exec(PaperInfo $row, PaperSearch $srch) {
239        assert(false);
240        return false;
241    }
242
243    function compile_edit_condition(PaperInfo $row, PaperSearch $srch) {
244        return null;
245    }
246
247
248    function extract_metadata($top, PaperSearch $srch) {
249        if ($top && ($x = $this->get_float("contradiction_warning")))
250            $srch->contradictions[$x] = true;
251    }
252    function default_sorter($top, $thenmap, PaperSearch $srch) {
253        return false;
254    }
255}
256
257class False_SearchTerm extends SearchTerm {
258    function __construct() {
259        parent::__construct("f");
260    }
261    function is_false() {
262        return true;
263    }
264    function trivial_rights(Contact $user, PaperSearch $srch) {
265        return true;
266    }
267    function sqlexpr(SearchQueryInfo $sqi) {
268        return "false";
269    }
270    function exec(PaperInfo $row, PaperSearch $srch) {
271        return false;
272    }
273    function compile_edit_condition(PaperInfo $row, PaperSearch $srch) {
274        return false;
275    }
276}
277
278class True_SearchTerm extends SearchTerm {
279    function __construct() {
280        parent::__construct("t");
281    }
282    function is_true() {
283        return true;
284    }
285    function is_uninteresting() {
286        return count($this->float) === 1 && isset($this->float["view"]);
287    }
288    function trivial_rights(Contact $user, PaperSearch $srch) {
289        return true;
290    }
291    function sqlexpr(SearchQueryInfo $sqi) {
292        return "true";
293    }
294    function exec(PaperInfo $row, PaperSearch $srch) {
295        return true;
296    }
297    function compile_edit_condition(PaperInfo $row, PaperSearch $srch) {
298        return true;
299    }
300}
301
302class Op_SearchTerm extends SearchTerm {
303    public $child = [];
304
305    function __construct($type) {
306        parent::__construct($type);
307    }
308    protected function append($term) {
309        if ($term) {
310            foreach ($term->float as $k => $v) {
311                $v1 = get($this->float, $k);
312                if ($k === "sort" && $v1)
313                    array_splice($this->float[$k], count($v1), 0, $v);
314                else if ($k === "strspan" && $v1)
315                    $this->apply_strspan($v);
316                else if (is_array($v1) && is_array($v))
317                    $this->float[$k] = array_replace_recursive($v1, $v);
318                else if ($k !== "opinfo" || $v1 === null)
319                    $this->float[$k] = $v;
320            }
321            $this->child[] = $term;
322        }
323        return $this;
324    }
325    protected function finish() {
326        assert(false);
327    }
328    protected function _flatten_children() {
329        $qvs = array();
330        foreach ($this->child ? : array() as $qv)
331            if ($qv->type === $this->type)
332                $qvs = array_merge($qvs, $qv->child);
333            else
334                $qvs[] = $qv;
335        return $qvs;
336    }
337    protected function _finish_combine($newchild, $any) {
338        $qr = null;
339        if (!$newchild)
340            $qr = $any ? new True_SearchTerm : new False_SearchTerm;
341        else if (count($newchild) == 1)
342            $qr = clone $newchild[0];
343        if ($qr) {
344            $qr->float = $this->float;
345            return $qr;
346        } else {
347            $this->child = $newchild;
348            return $this;
349        }
350    }
351
352    function set_strspan_owner($str) {
353        if (!isset($this->float["strspan_owner"])) {
354            parent::set_strspan_owner($str);
355            foreach ($this->child as $qv)
356                $qv->set_strspan_owner($str);
357        }
358    }
359    function debug_json() {
360        $a = [$this->type];
361        foreach ($this->child as $qv)
362            $a[] = $qv->debug_json();
363        return $a;
364    }
365    function adjust_reviews(ReviewAdjustment_SearchTerm $revadj = null, PaperSearch $srch) {
366        foreach ($this->child as &$qv)
367            $qv = $qv->adjust_reviews($revadj, $srch);
368        return $this;
369    }
370    function trivial_rights(Contact $user, PaperSearch $srch) {
371        foreach ($this->child as $ch)
372            if (!$ch->trivial_rights($user, $srch))
373                return false;
374        return true;
375    }
376}
377
378class Not_SearchTerm extends Op_SearchTerm {
379    function __construct() {
380        parent::__construct("not");
381    }
382    protected function finish() {
383        $qv = $this->child ? $this->child[0] : null;
384        $qr = null;
385        if (!$qv || $qv->is_false())
386            $qr = new True_SearchTerm;
387        else if ($qv->is_true())
388            $qr = new False_SearchTerm;
389        else if ($qv->type === "not")
390            $qr = clone $qv->child[0];
391        else if ($qv->type === "revadj") {
392            $qr = clone $qv;
393            $qr->negated = !$qr->negated;
394        }
395        if ($qr) {
396            $qr->float = $this->float;
397            return $qr;
398        } else
399            return $this;
400    }
401
402    function sqlexpr(SearchQueryInfo $sqi) {
403        $sqi->negated = !$sqi->negated;
404        $ff = $this->child[0]->sqlexpr($sqi);
405        if ($sqi->negated
406            && !$this->child[0]->trivial_rights($sqi->user, $sqi->srch))
407            $ff = "false";
408        $sqi->negated = !$sqi->negated;
409        if ($ff === "false")
410            return "true";
411        else if ($ff === "true")
412            return "false";
413        else
414            return "not ($ff)";
415    }
416    function exec(PaperInfo $row, PaperSearch $srch) {
417        return !$this->child[0]->exec($row, $srch);
418    }
419    function compile_edit_condition(PaperInfo $row, PaperSearch $srch) {
420        $x = $this->child[0]->compile_edit_condition($row, $srch);
421        if ($x === null)
422            return null;
423        else if ($x === false || $x === true)
424            return !$x;
425        else
426            return (object) ["type" => "not", "child" => [$x]];
427    }
428}
429
430class And_SearchTerm extends Op_SearchTerm {
431    function __construct($type) {
432        parent::__construct($type);
433    }
434    protected function finish() {
435        $pn = $revadj = null;
436        $newchild = [];
437        $any = false;
438        foreach ($this->_flatten_children() as $qv) {
439            if ($qv->is_false()) {
440                $qr = new False_SearchTerm;
441                $qr->float = $this->float;
442                return $qr;
443            } else if ($qv->is_true())
444                $any = true;
445            else if ($qv->type === "revadj")
446                $revadj = $qv->apply($revadj, false);
447            else if ($qv->type === "pn" && $this->type === "space") {
448                if (!$pn)
449                    $newchild[] = $pn = $qv;
450                else
451                    $pn->pids = array_merge($pn->pids, $qv->pids);
452            } else
453                $newchild[] = $qv;
454        }
455        if ($revadj) // must come first
456            array_unshift($newchild, $revadj);
457        return $this->_finish_combine($newchild, $any);
458    }
459
460    function adjust_reviews(ReviewAdjustment_SearchTerm $revadj = null, PaperSearch $srch) {
461        $myrevadj = null;
462        if ($this->child[0] instanceof ReviewAdjustment_SearchTerm) {
463            $myrevadj = $this->child[0];
464            $used_revadj = $myrevadj->merge($revadj);
465        }
466        foreach ($this->child as &$qv)
467            if (!($qv instanceof ReviewAdjustment_SearchTerm))
468                $qv = $qv->adjust_reviews($myrevadj ? : $revadj, $srch);
469        if ($myrevadj && !$myrevadj->used_revadj) {
470            $this->child[0] = $myrevadj->promote($srch);
471            if ($used_revadj)
472                $revadj->used_revadj = true;
473        }
474        return $this;
475    }
476    function sqlexpr(SearchQueryInfo $sqi) {
477        $ff = array();
478        foreach ($this->child as $subt)
479            $ff[] = $subt->sqlexpr($sqi);
480        return self::andjoin_sqlexpr($ff);
481    }
482    function exec(PaperInfo $row, PaperSearch $srch) {
483        foreach ($this->child as $subt)
484            if (!$subt->exec($row, $srch))
485                return false;
486        return true;
487    }
488    function compile_edit_condition(PaperInfo $row, PaperSearch $srch) {
489        $ch = [];
490        $ok = true;
491        foreach ($this->child as $subt) {
492            $x = $subt->compile_edit_condition($row, $srch);
493            if ($x === null)
494                return null;
495            else if ($x === false)
496                $ok = false;
497            else if ($x !== true)
498                $ch[] = $x;
499        }
500        if (!$ok || empty($ch))
501            return $ok;
502        else
503            return (object) ["type" => "and", "child" => $ch];
504    }
505    function extract_metadata($top, PaperSearch $srch) {
506        parent::extract_metadata($top, $srch);
507        foreach ($this->child as $qv)
508            $qv->extract_metadata($top, $srch);
509    }
510    function default_sorter($top, $thenmap, PaperSearch $srch) {
511        $s = false;
512        foreach ($this->child as $qv) {
513            $s1 = $qv->default_sorter($top, $thenmap, $srch);
514            if ($s && $s1)
515                return false;
516            $s = $s ? : $s1;
517        }
518        return $s;
519    }
520}
521
522class Or_SearchTerm extends Op_SearchTerm {
523    function __construct() {
524        parent::__construct("or");
525    }
526    protected function finish() {
527        $pn = $revadj = null;
528        $newchild = [];
529        foreach ($this->_flatten_children() as $qv) {
530            if ($qv->is_true())
531                return self::make_float($this->float);
532            else if ($qv->is_false())
533                /* skip */;
534            else if ($qv->type === "revadj")
535                $revadj = $qv->apply($revadj, true);
536            else if ($qv->type === "pn") {
537                if (!$pn)
538                    $newchild[] = $pn = $qv;
539                else
540                    $pn->pids = array_merge($pn->pids, $qv->pids);
541            } else
542                $newchild[] = $qv;
543        }
544        if ($revadj)
545            array_unshift($newchild, $revadj);
546        return $this->_finish_combine($newchild, false);
547    }
548
549    function sqlexpr(SearchQueryInfo $sqi) {
550        $ff = array();
551        foreach ($this->child as $subt)
552            $ff[] = $subt->sqlexpr($sqi);
553        return self::orjoin_sqlexpr($ff);
554    }
555    function exec(PaperInfo $row, PaperSearch $srch) {
556        foreach ($this->child as $subt)
557            if ($subt->exec($row, $srch))
558                return true;
559        return false;
560    }
561    static function compile_or_edit_condition($child, PaperInfo $row, PaperSearch $srch) {
562        $ch = [];
563        $ok = false;
564        foreach ($child as $subt) {
565            $x = $subt->compile_edit_condition($row, $srch);
566            if ($x === null)
567                return null;
568            else if ($x === true)
569                $ok = true;
570            else if ($x !== false)
571                $ch[] = $x;
572        }
573        if ($ok || empty($ch))
574            return $ok;
575        else
576            return (object) ["type" => "or", "child" => $ch];
577    }
578    function compile_edit_condition(PaperInfo $row, PaperSearch $srch) {
579        return self::compile_or_edit_condition($this->child, $row, $srch);
580    }
581    function extract_metadata($top, PaperSearch $srch) {
582        parent::extract_metadata($top, $srch);
583        foreach ($this->child as $qv)
584            $qv->extract_metadata(false, $srch);
585    }
586}
587
588class Then_SearchTerm extends Op_SearchTerm {
589    private $is_highlight;
590    public $nthen;
591    public $highlights;
592    public $highlight_types;
593
594    function __construct(SearchOperator $op) {
595        assert($op->op === "then" || $op->op === "highlight");
596        parent::__construct("then");
597        $this->is_highlight = $op->op === "highlight";
598        if (isset($op->opinfo))
599            $this->set_float("opinfo", $op->opinfo);
600    }
601    protected function finish() {
602        $opinfo = strtolower($this->get_float("opinfo", ""));
603        $newvalues = $newhvalues = $newhmasks = $newhtypes = [];
604
605        foreach ($this->child as $qvidx => $qv) {
606            if ($qv && $qvidx && $this->is_highlight) {
607                if ($qv->type === "then") {
608                    for ($i = 0; $i < $qv->nthen; ++$i) {
609                        $newhvalues[] = $qv->child[$i];
610                        $newhmasks[] = (1 << count($newvalues)) - 1;
611                        $newhtypes[] = $opinfo;
612                    }
613                } else {
614                    $newhvalues[] = $qv;
615                    $newhmasks[] = (1 << count($newvalues)) - 1;
616                    $newhtypes[] = $opinfo;
617                }
618            } else if ($qv && $qv->type === "then") {
619                $pos = count($newvalues);
620                for ($i = 0; $i < $qv->nthen; ++$i)
621                    $newvalues[] = $qv->child[$i];
622                for ($i = $qv->nthen; $i < count($qv->child); ++$i)
623                    $newhvalues[] = $qv->child[$i];
624                foreach ($qv->highlights ? : array() as $hlmask)
625                    $newhmasks[] = $hlmask << $pos;
626                foreach ($qv->highlight_types ? : array() as $hltype)
627                    $newhtypes[] = $hltype;
628            } else if ($qv)
629                $newvalues[] = $qv;
630        }
631
632        $this->nthen = count($newvalues);
633        $this->highlights = $newhmasks;
634        $this->highlight_types = $newhtypes;
635        array_splice($newvalues, $this->nthen, 0, $newhvalues);
636        $this->child = $newvalues;
637        $this->set_float("sort", []);
638        return $this;
639    }
640
641    function sqlexpr(SearchQueryInfo $sqi) {
642        $ff = array();
643        foreach ($this->child as $subt)
644            $ff[] = $subt->sqlexpr($sqi);
645        return self::orjoin_sqlexpr(array_slice($ff, 0, $this->nthen), "true");
646    }
647    function exec(PaperInfo $row, PaperSearch $srch) {
648        for ($i = 0; $i < $this->nthen; ++$i)
649            if ($this->child[$i]->exec($row, $srch))
650                return true;
651        return false;
652    }
653    function compile_edit_condition(PaperInfo $row, PaperSearch $srch) {
654        return Or_SearchTerm::compile_or_edit_condition(array_slice($this->child, 0, $this->nthen), $row, $srch);
655    }
656    function extract_metadata($top, PaperSearch $srch) {
657        parent::extract_metadata($top, $srch);
658        foreach ($this->child as $qv)
659            $qv->extract_metadata(false, $srch);
660    }
661}
662
663class TextMatch_SearchTerm extends SearchTerm {
664    private $field;
665    private $authorish;
666    private $trivial = null;
667    public $regex;
668    static public $map = [ // NB see field_highlighters()
669        "ti" => "title", "ab" => "abstract",
670        "au" => "authorInformation", "co" => "collaborators"
671    ];
672
673    function __construct($t, $text, $quoted) {
674        parent::__construct($t);
675        $this->field = self::$map[$t];
676        $this->authorish = $t === "au" || $t === "co";
677        if (is_bool($text))
678            $this->trivial = $text;
679        else
680            $this->regex = Text::star_text_pregexes($text, $quoted);
681    }
682    static function parse($word, SearchWord $sword) {
683        if ($sword->kwexplicit && !$sword->quoted) {
684            if ($word === "any")
685                $word = true;
686            else if ($word === "none")
687                $word = false;
688        }
689        return new TextMatch_SearchTerm($sword->kwdef->name, $word, $sword->quoted);
690    }
691
692    function trivial_rights(Contact $user, PaperSearch $srch) {
693        return $this->trivial && !$this->authorish;
694    }
695    function sqlexpr(SearchQueryInfo $sqi) {
696        $sqi->add_column($this->field, "Paper.{$this->field}");
697        if ($this->trivial && !$this->authorish)
698            return "Paper.{$this->field}!=''";
699        else
700            return "true";
701    }
702    function exec(PaperInfo $row, PaperSearch $srch) {
703        $data = $row->{$this->field};
704        if ($this->authorish && !$srch->user->allow_view_authors($row))
705            $data = "";
706        if ($data === "")
707            return $this->trivial === false;
708        else if ($this->trivial !== null)
709            return $this->trivial;
710        else
711            return $row->field_match_pregexes($this->regex, $this->field);
712    }
713    function extract_metadata($top, PaperSearch $srch) {
714        parent::extract_metadata($top, $srch);
715        if ($this->regex)
716            $srch->regex[$this->type][] = $this->regex;
717    }
718}
719
720class ReviewRating_SearchAdjustment {
721    private $type;
722    private $arg;
723
724    function __construct($type, $arg) {
725        $this->type = $type;
726        $this->arg = $arg;
727    }
728    function must_exist() {
729        if ($this->type === "and")
730            return $this->arg[0]->must_exist() || $this->arg[1]->must_exist();
731        else if ($this->type === "or")
732            return $this->arg[0]->must_exist() && $this->arg[1]->must_exist();
733        else if ($this->type === "not")
734            return false;
735        else
736            return !$this->arg->test(0);
737    }
738    private function _test($ratings) {
739        if ($this->type === "and")
740            return $this->arg[0]->_test($ratings) && $this->arg[1]->_test($ratings);
741        else if ($this->type === "or")
742            return $this->arg[0]->_test($ratings) || $this->arg[1]->_test($ratings);
743        else if ($this->type === "not")
744            return !$this->arg->_test($ratings);
745        else {
746            $n = count(array_filter($ratings, function ($r) { return ($r & $this->type) !== 0; }));
747            return $this->arg->test($n);
748        }
749    }
750    function test(Contact $user, PaperInfo $prow, ReviewInfo $rrow) {
751        if ($user->can_view_review_ratings($prow, $rrow, $user->privChair))
752            $ratings = $rrow->ratings();
753        else
754            $ratings = [];
755        return $this->_test($ratings);
756    }
757}
758
759class ReviewAdjustment_SearchTerm extends SearchTerm {
760    private $conf;
761    private $round;
762    private $ratings;
763    public $negated = false;
764    public $used_revadj = false;
765
766    function __construct(Conf $conf) {
767        parent::__construct("revadj");
768        $this->conf = $conf;
769    }
770    static function parse_round($word, SearchWord $sword, PaperSearch $srch) {
771        $srch->_has_review_adjustment = true;
772        if (!$srch->user->isPC)
773            $rounds = null;
774        else if (strcasecmp($word, "none") == 0 || strcasecmp($word, "unnamed") == 0)
775            $rounds = [0];
776        else if (strcasecmp($word, "any") == 0)
777            $rounds = range(1, count($srch->conf->round_list()) - 1);
778        else {
779            $x = simplify_whitespace($word);
780            $rounds = array_keys(Text::simple_search($x, $srch->conf->round_list()));
781            if (empty($rounds)) {
782                $srch->warn("“" . htmlspecialchars($x) . "” doesn’t match a review round.");
783                return new False_SearchTerm;
784            }
785        }
786        $qv = new ReviewAdjustment_SearchTerm($srch->conf);
787        $qv->round = $rounds;
788        return $qv;
789    }
790    static function parse_rate($word, SearchWord $sword, PaperSearch $srch) {
791        if (!$srch->user->can_view_some_review_ratings()) {
792            if ($srch->user->isPC && $srch->conf->setting("rev_ratings") == REV_RATINGS_NONE)
793                $srch->warn("Review ratings are disabled.");
794            return new False_SearchTerm;
795        }
796        $rate = null;
797        if (strcasecmp($word, "none") == 0) {
798            $rate = "any";
799            $compar = "=0";
800        } else if (preg_match('/\A(.+?)\s*(:?|[=!<>]=?|≠|≤|≥)\s*(\d*)\z/', $word, $m)
801                   && ($m[3] !== "" || $m[2] === "")) {
802            if ($m[3] === "")
803                $compar = ">0";
804            else if ($m[2] === "" || $m[2] === ":")
805                $compar = ($m[3] == 0 ? "=0" : ">=" . $m[3]);
806            else
807                $compar = $m[2] . $m[3];
808            $rate = self::parse_rate_name($m[1]);
809        }
810        if ($rate === null) {
811            $srch->warn("Bad review rating query “" . htmlspecialchars($word) . "”.");
812            return new False_SearchTerm;
813        } else {
814            $srch->_has_review_adjustment = true;
815            $qv = new ReviewAdjustment_SearchTerm($srch->conf);
816            $qv->ratings = new ReviewRating_SearchAdjustment($rate, new CountMatcher($compar));
817            return $qv;
818        }
819    }
820    static private function parse_rate_name($s) {
821        if (strcasecmp($s, "any") == 0)
822            return ReviewInfo::RATING_GOODMASK | ReviewInfo::RATING_BADMASK;
823        if ($s === "+" || strcasecmp($s, "good") == 0 || strcasecmp($s, "yes") == 0)
824            return ReviewInfo::RATING_GOODMASK;
825        if ($s === "-" || strcasecmp($s, "bad") == 0 || strcasecmp($s, "no") == 0
826            || $s === "\xE2\x88\x92" /* unicode MINUS */)
827            return ReviewInfo::RATING_BADMASK;
828        foreach (ReviewInfo::$rating_bits as $bit => $name) {
829            if (strcasecmp($s, $name) === 0)
830                return $bit;
831        }
832        $x = Text::simple_search($s, ReviewInfo::$rating_options);
833        unset($x[0]); // can't search for “average”
834        if (count($x) == 1) {
835            reset($x);
836            return key($x);
837        } else
838            return null;
839    }
840
841    function merge(ReviewAdjustment_SearchTerm $x = null) {
842        $changed = null;
843        if ($x && $this->round === null && $x->round !== null)
844            $changed = $this->round = $x->round;
845        if ($x && $this->ratings === null && $x->ratings !== null)
846            $changed = $this->ratings = $x->ratings;
847        return $changed !== null;
848    }
849    function promote(PaperSearch $srch) {
850        $rsm = new ReviewSearchMatcher(">0");
851        if (in_array($srch->limit(), ["r", "rout", "rable"]))
852            $rsm->add_contact($srch->cid);
853        else if ($srch->limit() === "req") {
854            $rsm->apply_requester($srch->cid);
855            $rsm->apply_review_type("external"); // XXX optional PC reviews?
856        }
857        $this->promote_matcher($rsm);
858        $term = new Review_SearchTerm($rsm);
859        return $term->negate_if($this->negated);
860    }
861    function promote_matcher(ReviewSearchMatcher $rsm) {
862        if ($this->round !== null)
863            $rsm->adjust_round_list($this->round);
864        if ($this->ratings !== null)
865            $rsm->adjust_ratings($this->ratings);
866        $this->used_revadj = true;
867    }
868    function adjust_reviews(ReviewAdjustment_SearchTerm $revadj = null, PaperSearch $srch) {
869        if ($revadj || $this->get_float("used_revadj"))
870            return $this;
871        else
872            return $this->promote($srch);
873    }
874    function apply_negation() {
875        if ($this->negated) {
876            if ($this->round !== null)
877                $this->round = array_diff(array_keys($this->conf->round_list()), $this->round);
878            if ($this->ratings !== null)
879                $this->ratings = new ReviewRating_SearchAdjustment("not", $this->ratings);
880            $this->negated = false;
881        }
882    }
883    function apply(ReviewAdjustment_SearchTerm $revadj = null, $is_or = false) {
884        // XXX this is probably not right in fully general cases
885        if (!$revadj)
886            return $this;
887        if ($revadj->negated !== $this->negated || ($revadj->negated && $is_or)) {
888            $revadj->apply_negation();
889            $this->apply_negation();
890        }
891        if ($is_or || $revadj->negated) {
892            if ($this->round !== null)
893                $revadj->round = array_unique(array_merge($revadj->round, $this->round));
894            if ($this->ratings !== null && $revadj->ratings !== null)
895                $revadj->ratings = new ReviewRating_SearchAdjustment("or", [$this->ratings, $revadj->ratings]);
896            else if ($this->ratings !== null)
897                $revadj->ratings = $this->ratings;
898        } else {
899            if ($revadj->round !== null && $this->round !== null)
900                $revadj->round = array_intersect($revadj->round, $this->round);
901            else if ($this->round !== null)
902                $revadj->round = $this->round;
903            if ($this->ratings !== null && $revadj->ratings !== null)
904                $revadj->ratings = new ReviewRating_SearchAdjustment("and", [$this->ratings, $revadj->ratings]);
905            else
906                $revadj->ratings = $this->ratings;
907        }
908        return $revadj;
909    }
910
911    function sqlexpr(SearchQueryInfo $sqi) {
912        return "true";
913    }
914    function exec(PaperInfo $prow, PaperSearch $srch) {
915        return true;
916    }
917}
918
919class Show_SearchTerm {
920    static function parse($word, SearchWord $sword, PaperSearch $srch) {
921        $word = simplify_whitespace($word);
922        $action = $sword->kwdef->showtype;
923        if (str_starts_with($word, "-") && !$sword->kwdef->sorting) {
924            $action = false;
925            $word = substr($word, 1);
926        }
927        $f = [];
928        $viewfield = $word;
929        if ($word !== "" && $sword->kwdef->sorting) {
930            $f["sort"] = [$word];
931            $sort = PaperSearch::parse_sorter($viewfield);
932            $viewfield = $sort->type;
933        }
934        if ($viewfield !== "" && $action !== null)
935            $f["view"] = [$viewfield => $action];
936        return SearchTerm::make_float($f);
937    }
938    static function parse_heading($word, SearchWord $sword) {
939        return SearchTerm::make_float(["heading" => simplify_whitespace($word)]);
940    }
941}
942
943class PaperID_SearchTerm extends SearchTerm {
944    public $pids;
945
946    function __construct($pns) {
947        parent::__construct("pn");
948        $this->pids = $pns;
949    }
950    function trivial_rights(Contact $user, PaperSearch $srch) {
951        return true;
952    }
953    function sqlexpr(SearchQueryInfo $sqi) {
954        if (empty($this->pids))
955            return "false";
956        else
957            return "Paper.paperId in (" . join(",", $this->pids) . ")";
958    }
959    function exec(PaperInfo $row, PaperSearch $srch) {
960        return in_array($row->paperId, $this->pids);
961    }
962    function in_order() {
963        $pods = $this->pids;
964        sort($pods, SORT_NUMERIC);
965        return $pods == $this->pids;
966    }
967    function default_sorter($top, $thenmap, PaperSearch $srch) {
968        if ($top && !$this->in_order()) {
969            $s = ListSorter::make_field(new NumericOrderPaperColumn($srch->conf, array_flip($this->pids)));
970            $s->thenmap = $thenmap;
971            return $s;
972        } else
973            return false;
974    }
975}
976
977
978class ContactCountMatcher extends CountMatcher {
979    private $_contacts = null;
980
981    function __construct($countexpr, $contacts) {
982        parent::__construct($countexpr);
983        $this->set_contacts($contacts);
984    }
985    function contact_set() {
986        return $this->_contacts;
987    }
988    function has_contacts() {
989        return $this->_contacts !== null;
990    }
991    function has_sole_contact($cid) {
992        return $this->_contacts !== null
993            && count($this->_contacts) == 1
994            && $this->_contacts[0] == $cid;
995    }
996    function contact_match_sql($fieldname) {
997        if ($this->_contacts === null)
998            return "true";
999        else
1000            return $fieldname . sql_in_numeric_set($this->_contacts);
1001    }
1002    function test_contact($cid) {
1003        return $this->_contacts === null || in_array($cid, $this->_contacts);
1004    }
1005    function add_contact($cid) {
1006        if ($this->_contacts === null)
1007            $this->_contacts = array();
1008        if (!in_array($cid, $this->_contacts))
1009            $this->_contacts[] = $cid;
1010    }
1011    function set_contacts($contacts) {
1012        assert($contacts === null || is_array($contacts) || is_int($contacts));
1013        $this->_contacts = is_int($contacts) ? array($contacts) : $contacts;
1014    }
1015}
1016
1017class SearchQueryInfo {
1018    public $conf;
1019    public $srch;
1020    public $user;
1021    public $tables = array();
1022    public $columns = array();
1023    public $negated = false;
1024    private $_has_my_review = false;
1025
1026    function __construct(PaperSearch $srch) {
1027        $this->conf = $srch->conf;
1028        $this->srch = $srch;
1029        $this->user = $srch->user;
1030    }
1031    function add_table($table, $joiner = false) {
1032        // * All added tables must match at most one Paper row each,
1033        //   except MyReviews and Limiter.
1034        assert($joiner || !count($this->tables));
1035        $this->tables[$table] = $joiner;
1036    }
1037    function add_column($name, $expr) {
1038        assert(!isset($this->columns[$name]) || $this->columns[$name] === $expr);
1039        $this->columns[$name] = $expr;
1040    }
1041    function add_conflict_table() {
1042        if (!isset($this->tables["PaperConflict"]))
1043            $this->add_table("PaperConflict", ["left join", "PaperConflict", "PaperConflict.contactId=" . ($this->user->contactId ? : -100)]);
1044    }
1045    function add_conflict_columns() {
1046        $this->add_conflict_table();
1047        $this->columns["conflictType"] = "PaperConflict.conflictType";
1048    }
1049    function add_reviewer_columns() {
1050        $this->_has_my_review = true;
1051    }
1052    function finish_reviewer_columns() {
1053        if ($this->_has_my_review) {
1054            $this->add_conflict_columns();
1055            if (isset($this->columns["reviewSignatures"])) {
1056                /* use that */
1057            } else if (isset($this->tables["MyReviews"])) {
1058                $this->add_column("myReviewPermissions", PaperInfo::my_review_permissions_sql("MyReviews."));
1059            } else if (!isset($this->tables["Limiter"])) {
1060                $this->add_table("MyReviews", ["left join", "PaperReview", $this->user->act_reviewer_sql("MyReviews")]);
1061                $this->add_column("myReviewPermissions", PaperInfo::my_review_permissions_sql("MyReviews."));
1062            } else {
1063                $this->add_column("myReviewPermissions", "(select " . PaperInfo::my_review_permissions_sql() . " from PaperReview where PaperReview.paperId=Paper.paperId and " . $this->user->act_reviewer_sql("PaperReview") . " group by paperId)");
1064            }
1065        }
1066    }
1067    function add_review_signature_columns() {
1068        if (!isset($this->columns["reviewSignatures"]))
1069            $this->add_column("reviewSignatures", "(select " . ReviewInfo::review_signature_sql() . " from PaperReview r where r.paperId=Paper.paperId)");
1070    }
1071    function add_score_columns($fid) {
1072        $this->add_review_signature_columns();
1073        if (!isset($this->columns["{$fid}Signature"])
1074            && ($f = $this->conf->review_field($fid))
1075            && $f->main_storage)
1076            $this->add_column("{$fid}Signature", "(select group_concat({$f->main_storage} order by reviewId) from PaperReview where PaperReview.paperId=Paper.paperId)");
1077    }
1078    function add_review_word_count_columns() {
1079        $this->add_review_signature_columns();
1080        if (!isset($this->columns["reviewWordCountSignature"]))
1081            $this->add_column("reviewWordCountSignature", "(select group_concat(coalesce(reviewWordCount,'.') order by reviewId) from PaperReview where PaperReview.paperId=Paper.paperId)");
1082    }
1083    function add_rights_columns() {
1084        if (!isset($this->columns["managerContactId"]))
1085            $this->columns["managerContactId"] = "Paper.managerContactId";
1086        if (!isset($this->columns["leadContactId"]))
1087            $this->columns["leadContactId"] = "Paper.leadContactId";
1088        // XXX could avoid the following if user is privChair for everything:
1089        $this->add_conflict_columns();
1090        $this->add_reviewer_columns();
1091    }
1092    function add_allConflictType_column() {
1093        if (!isset($this->columns["allConflictType"]))
1094            $this->add_column("allConflictType", "(select group_concat(contactId, ' ', conflictType) from PaperConflict where PaperConflict.paperId=Paper.paperId)");
1095    }
1096}
1097
1098class PaperSearch {
1099    public $conf;
1100    public $user;
1101    private $contact;
1102    public $cid;
1103    public $privChair;
1104    private $amPC;
1105
1106    private $limitName;
1107    private $fields;
1108    private $_reviewer_user = false;
1109    private $_active_limit;
1110    private $urlbase;
1111    public $warnings = array();
1112    private $_quiet_count = 0;
1113
1114    public $q;
1115    private $_qe;
1116    public $test_review;
1117
1118    public $regex = [];
1119    public $contradictions = [];
1120    private $_match_preg;
1121    private $_match_preg_query;
1122
1123    private $contact_match = array();
1124    public $_query_options = array();
1125    public $_has_review_adjustment = false;
1126    private $_ssRecursion = array();
1127    private $_allow_deleted = false;
1128    public $thenmap;
1129    public $groupmap;
1130    public $is_order_anno = false;
1131    public $highlightmap;
1132    public $viewmap;
1133    public $sorters = [];
1134    private $_default_sort; // XXX should be used more often
1135    private $_highlight_tags;
1136
1137    private $_matches; // list of ints
1138
1139    static private $_sort_keywords = ["by" => "by", "up" => "up", "down" => "down",
1140                 "reverse" => "down", "reversed" => "down", "score" => ""];
1141
1142    static public $search_type_names = [
1143        "a" => "Your submissions",
1144        "acc" => "Accepted papers",
1145        "act" => "Active papers",
1146        "all" => "All papers",
1147        "editpref" => "Reviewable papers",
1148        "lead" => "Your discussion leads",
1149        "manager" => "Papers you administer",
1150        "r" => "Your reviews",
1151        "rable" => "Reviewable papers",
1152        "req" => "Your review requests",
1153        "reqrevs" => "Your review requests",
1154        "rout" => "Your incomplete reviews",
1155        "s" => "Submitted papers",
1156        "und" => "Undecided papers",
1157        "unm" => "Unmanaged submissions"
1158    ];
1159
1160
1161    // NB: `$options` can come from an unsanitized user request.
1162    function __construct(Contact $user, $options) {
1163        if (is_string($options))
1164            $options = array("q" => $options);
1165
1166        // contact facts
1167        $this->conf = $user->conf;
1168        $this->user = $user;
1169        $this->privChair = $user->privChair;
1170        $this->amPC = $user->isPC;
1171        $this->cid = $user->contactId;
1172
1173        // paper selection
1174        $ptype = (string) get($options, "t");
1175        if ($ptype === "0")
1176            $ptype = "";
1177        if ($ptype === "vis")
1178            $this->limitName = "vis";
1179        else if ($this->privChair && !$ptype && $this->conf->timeUpdatePaper())
1180            $this->limitName = "all";
1181        else if (($user->privChair && $ptype === "act")
1182                 || ($user->isPC
1183                     && (!$ptype || $ptype === "act" || $ptype === "all")
1184                     && $this->conf->can_pc_see_all_submissions()))
1185            $this->limitName = "act";
1186        else if ($user->privChair && $ptype === "unm")
1187            $this->limitName = "unm";
1188        else if ($user->isPC && (!$ptype || $ptype === "s" || $ptype === "unm"))
1189            $this->limitName = "s";
1190        else if ($user->isPC && ($ptype === "und" || $ptype === "undec"))
1191            $this->limitName = "und";
1192        else if ($user->isPC && ($ptype === "acc"
1193                                 || $ptype === "reqrevs" || $ptype === "req"
1194                                 || $ptype === "lead" || $ptype === "rable"
1195                                 || $ptype === "manager" || $ptype === "editpref"))
1196            $this->limitName = $ptype;
1197        else if ($this->privChair && ($ptype === "all" || $ptype === "unsub"))
1198            $this->limitName = $ptype;
1199        else if ($ptype === "r" || $ptype === "rout" || $ptype === "a")
1200            $this->limitName = $ptype;
1201        else if ($ptype === "rable")
1202            $this->limitName = "r";
1203        else if (!$user->is_reviewer())
1204            $this->limitName = "a";
1205        else if (!$user->is_author())
1206            $this->limitName = "r";
1207        else
1208            $this->limitName = "ar";
1209
1210        // default query fields
1211        // NB: If a complex query field, e.g., "re", "tag", or "option", is
1212        // default, then it must be the only default or query construction
1213        // will break.
1214        $this->fields = array();
1215        $qtype = get($options, "qt", "n");
1216        if ($qtype === "n" || $qtype === "ti")
1217            $this->fields["ti"] = 1;
1218        if ($qtype === "n" || $qtype === "ab")
1219            $this->fields["ab"] = 1;
1220        if ($user->can_view_some_authors()
1221            && ($qtype === "n" || $qtype === "au" || $qtype === "ac"))
1222            $this->fields["au"] = 1;
1223        if ($this->privChair && $qtype === "ac")
1224            $this->fields["co"] = 1;
1225        if ($this->amPC && $qtype === "re")
1226            $this->fields["re"] = 1;
1227        if ($this->amPC && $qtype === "tag")
1228            $this->fields["tag"] = 1;
1229
1230        // the query itself
1231        $this->q = trim(get_s($options, "q"));
1232        $this->_default_sort = get($options, "sort");
1233
1234        // reviewer
1235        if (($reviewer = get($options, "reviewer"))) {
1236            if (is_string($reviewer)) {
1237                if (strcasecmp($reviewer, $user->email) == 0)
1238                    $reviewer = $user;
1239                else if ($user->can_view_pc())
1240                    $reviewer = $this->conf->pc_member_by_email($reviewer);
1241                else
1242                    $reviewer = null;
1243            } else if (!is_object($reviewer) || !($reviewer instanceof Contact))
1244                $reviewer = null;
1245            if ($reviewer)
1246                $this->_reviewer_user = $reviewer;
1247        }
1248
1249        // URL base
1250        if (isset($options["urlbase"]))
1251            $this->urlbase = $options["urlbase"];
1252        else
1253            $this->urlbase = $this->conf->hoturl_site_relative_raw("search", "t=" . urlencode($this->limitName));
1254        if ($qtype !== "n")
1255            $this->urlbase = hoturl_add_raw($this->urlbase, "qt=" . urlencode($qtype));
1256        if ($this->_reviewer_user
1257            && $this->_reviewer_user->contactId !== $user->contactId
1258            && strpos($this->urlbase, "reviewer=") === false)
1259            $this->urlbase = hoturl_add_raw($this->urlbase, "reviewer=" . urlencode($this->_reviewer_user->email));
1260        if (strpos($this->urlbase, "&amp;") !== false)
1261            trigger_error(caller_landmark() . " PaperSearch::urlbase should be a raw URL", E_USER_NOTICE);
1262
1263        $this->set_limit($this->limitName);
1264    }
1265
1266    private function set_limit($limit) {
1267        assert($this->_qe === null);
1268        $this->_active_limit = $limit;
1269        if ($this->_active_limit === "editpref")
1270            $this->_active_limit = "rable";
1271        else if ($this->_active_limit === "reqrevs")
1272            $this->_active_limit = "req";
1273        if ($this->_active_limit === "rable") {
1274            $u = $this->reviewer_user();
1275            if ($this->privChair || $this->user === $u) {
1276                if ($u->can_accept_review_assignment_ignore_conflict(null)) {
1277                    if ($this->conf->can_pc_see_all_submissions())
1278                        $this->_active_limit = "act";
1279                    else
1280                        $this->_active_limit = "s";
1281                } else if (!$u->isPC)
1282                    $this->_active_limit = "r";
1283            }
1284        }
1285    }
1286
1287    function set_allow_deleted($x) {
1288        assert($this->_qe === null);
1289        $this->_allow_deleted = $x;
1290    }
1291
1292    function __get($name) {
1293        error_log("PaperSearch::$name " . json_encode(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)));
1294        return $name === "contact" ? $this->user : null;
1295    }
1296
1297    function limit() {
1298        return $this->_active_limit;
1299    }
1300    function limit_submitted() {
1301        return !in_array($this->_active_limit, ["a", "ar", "act", "all", "unsub"]);
1302    }
1303    function limit_author() {
1304        return $this->_active_limit === "a";
1305    }
1306
1307    function reviewer_user() {
1308        return $this->_reviewer_user ? : $this->user;
1309    }
1310
1311    function warn($text) {
1312        if (!$this->_quiet_count)
1313            $this->warnings[] = $text;
1314    }
1315
1316
1317    // PARSING
1318    // Transforms a search string into an expression object, possibly
1319    // including "and", "or", and "not" expressions (which point at other
1320    // expressions).
1321
1322    static function unpack_comparison($text, $quoted) {
1323        $text = trim($text);
1324        $compar = null;
1325        if (preg_match('/\A(.*?)([=!<>]=?|≠|≤|≥)\s*(\d+)\z/s', $text, $m)) {
1326            $text = $m[1];
1327            $compar = $m[2] . $m[3];
1328        }
1329        if (($text === "any" || $text === "" || $text === "yes") && !$quoted)
1330            return array("", $compar ? : ">0");
1331        else if (($text === "none" || $text === "no") && !$quoted)
1332            return array("", "=0");
1333        else if (!$compar && ctype_digit($text))
1334            return array("", "=" . $text);
1335        else
1336            return array($text, $compar ? : ">0");
1337    }
1338
1339    static function check_tautology($compar) {
1340        if ($compar === "<0")
1341            return new False_SearchTerm;
1342        else if ($compar === ">=0")
1343            return new True_SearchTerm;
1344        else
1345            return null;
1346    }
1347
1348    private function make_contact_match($type, $text) {
1349        foreach ($this->contact_match as $i => $cm)
1350            if ($cm->type === $type && $cm->text === $text)
1351                return $cm;
1352        return $this->contact_match[] = new ContactSearch($type, $text, $this->user);
1353    }
1354
1355    private function matching_contacts_base($type, $word, $quoted, $pc_only) {
1356        if ($pc_only)
1357            $type |= ContactSearch::F_PC;
1358        if ($quoted)
1359            $type |= ContactSearch::F_QUOTED;
1360        if (!$quoted && $this->amPC)
1361            $type |= ContactSearch::F_TAG;
1362        $scm = $this->make_contact_match($type, $word);
1363        if ($scm->warn_html)
1364            $this->warn($scm->warn_html);
1365        return $scm->ids;
1366    }
1367    function matching_users($word, $quoted, $pc_only) {
1368        $cids = $this->matching_contacts_base(ContactSearch::F_USER, $word, $quoted, $pc_only);
1369        return empty($cids) ? [] : $cids;
1370    }
1371    function matching_special_contacts($word, $quoted, $pc_only) {
1372        $cids = $this->matching_contacts_base(0, $word, $quoted, $pc_only);
1373        return $cids === false ? null : (empty($cids) ? [] : $cids);
1374    }
1375
1376    static function decision_matchexpr(Conf $conf, $word, $flag) {
1377        $lword = strtolower($word);
1378        if (!($flag & Text::SEARCH_NO_SPECIAL)) {
1379            if ($lword === "yes")
1380                return ">0";
1381            else if ($lword === "no")
1382                return "<0";
1383            else if ($lword === "?" || $lword === "none"
1384                     || $lword === "unknown" || $lword === "unspecified"
1385                     || $lword === "undecided")
1386                return [0];
1387            else if ($lword === "any")
1388                return "!=0";
1389        }
1390        return array_keys(Text::simple_search($word, $conf->decision_map(), $flag));
1391    }
1392
1393    static function status_field_matcher(Conf $conf, $word, $quoted = null) {
1394        if (strlen($word) >= 3
1395            && ($k = Text::simple_search($word, ["w0" => "withdrawn", "s0" => "submitted", "s1" => "ready", "u0" => "in progress", "u1" => "unsubmitted", "u2" => "not ready", "a0" => "active", "x0" => "no submission"]))) {
1396            $k = array_map(function ($x) { return $x[0]; }, array_keys($k));
1397            $k = array_unique($k);
1398            if (count($k) === 1) {
1399                if ($k[0] === "w")
1400                    return ["timeWithdrawn", ">0"];
1401                else if ($k[0] === "s")
1402                    return ["timeSubmitted", ">0"];
1403                else if ($k[0] === "u")
1404                    return ["timeSubmitted", "<=0", "timeWithdrawn", "<=0"];
1405                else if ($k[0] === "x")
1406                    return ["timeSubmitted", "<=0", "timeWithdrawn", "<=0", "paperStorageId", "<=1"];
1407                else
1408                    return ["timeWithdrawn", "<=0"];
1409            }
1410        }
1411        $flag = $quoted ? Text::SEARCH_NO_SPECIAL : Text::SEARCH_UNPRIVILEGE_EXACT;
1412        return ["outcome", self::decision_matchexpr($conf, $word, $flag)];
1413    }
1414
1415    static function parse_reconflict($word, SearchWord $sword, PaperSearch $srch) {
1416        // `reconf:` keyword, defined in `etc/searchkeywords.json`
1417        $args = array();
1418        while (preg_match('/\A\s*#?(\d+)(?:-#?(\d+))?\s*,?\s*(.*)\z/s', $word, $m)) {
1419            $m[2] = (isset($m[2]) && $m[2] ? $m[2] : $m[1]);
1420            foreach (range($m[1], $m[2]) as $p)
1421                $args[$p] = true;
1422            $word = $m[3];
1423        }
1424        if ($word !== "" || empty($args)) {
1425            $srch->warn("The <code>reconflict</code> keyword expects a list of paper numbers.");
1426            return new False_SearchTerm;
1427        } else if (!$srch->user->privChair)
1428            return new False_SearchTerm;
1429        else {
1430            $result = $srch->conf->qe("select distinct contactId from PaperReview where paperId in (" . join(", ", array_keys($args)) . ")");
1431            $contacts = array_map("intval", Dbl::fetch_first_columns($result));
1432            return new Conflict_SearchTerm(">0", $contacts, $srch->user);
1433        }
1434    }
1435
1436    static function parse_has($word, SearchWord $sword, PaperSearch $srch) {
1437        $lword = strtolower($word);
1438        if (($kwdef = $srch->conf->search_keyword($lword, $srch->user))) {
1439            if (get($kwdef, "parse_has_callback"))
1440                $qe = call_user_func($kwdef->parse_has_callback, $word, $sword, $srch);
1441            else if (get($kwdef, "has")) {
1442                $sword2 = new SearchWord($kwdef->has);
1443                $sword2->kwexplicit = true;
1444                $sword2->keyword = $lword;
1445                $sword2->kwdef = $kwdef;
1446                $qe = call_user_func($kwdef->parse_callback, $kwdef->has, $sword2, $srch);
1447            } else
1448                $qe = null;
1449            if ($qe && $sword->keyword === "no") {
1450                if (is_array($qe))
1451                    $qe = SearchTerm::make_op("or", $qe);
1452                $qe = SearchTerm::make_not($qe);
1453            }
1454            if ($qe)
1455                return $qe;
1456        }
1457        $srch->warn("Unknown search “" . $sword->keyword . ":" . htmlspecialchars($word) . "”.");
1458        return new False_SearchTerm;
1459    }
1460
1461    static function parse_sorter($text) {
1462        $text = simplify_whitespace($text);
1463        $sort = ListSorter::make_empty($text === "");
1464        if (($ch1 = substr($text, 0, 1)) === "-" || $ch1 === "+") {
1465            $sort->reverse = $ch1 === "-";
1466            $text = ltrim(substr($text, 1));
1467        }
1468
1469        // separate text into words
1470        $words = array();
1471        $bypos = false;
1472        while (true) {
1473            preg_match('{\A[,\s]*([^\s\(,]*)(.*)\z}s', $text, $m);
1474            if ($m[1] === "" && $m[2] === "")
1475                break;
1476            if (substr($m[2], 0, 1) === "(") {
1477                $pos = SearchSplitter::span_balanced_parens($m[2]);
1478                $m[1] .= substr($m[2], 0, $pos);
1479                $m[2] = substr($m[2], $pos);
1480            }
1481            $words[] = $m[1];
1482            $text = ltrim($m[2]);
1483            if ($m[1] === "by" && $bypos === false)
1484                $bypos = count($words) - 1;
1485        }
1486
1487        // go over words
1488        $next_words = array();
1489        for ($i = 0; $i != count($words); ++$i) {
1490            $w = $words[$i];
1491            if ($bypos === false || $i > $bypos) {
1492                if (($x = get(self::$_sort_keywords, $w)) !== null) {
1493                    if ($x === "up")
1494                        $sort->reverse = false;
1495                    else if ($x === "down")
1496                        $sort->reverse = true;
1497                    continue;
1498                } else if (($x = ListSorter::canonical_short_score_sort($w))) {
1499                    $sort->score = $x;
1500                    continue;
1501                }
1502            }
1503            if ($bypos === false || $i < $bypos)
1504                $next_words[] = $w;
1505        }
1506
1507        if (!empty($next_words))
1508            $sort->type = join(" ", $next_words);
1509        return $sort;
1510    }
1511
1512    private function _expand_saved_search($word, $recursion) {
1513        if (isset($recursion[$word]))
1514            return false;
1515        $t = $this->conf->setting_data("ss:" . $word, "");
1516        $search = json_decode($t);
1517        if ($search && is_object($search) && isset($search->q))
1518            return $search->q;
1519        else
1520            return null;
1521    }
1522
1523    static function parse_saved_search($word, SearchWord $sword, PaperSearch $srch) {
1524        if (!$srch->user->isPC)
1525            return null;
1526        if (($nextq = $srch->_expand_saved_search($word, $srch->_ssRecursion))) {
1527            $srch->_ssRecursion[$word] = true;
1528            $qe = $srch->_search_expression($nextq);
1529            unset($srch->_ssRecursion[$word]);
1530        } else
1531            $qe = null;
1532        if (!$qe && $nextq === false)
1533            $srch->warn("Saved search “" . htmlspecialchars($word) . "” is defined in terms of itself.");
1534        else if (!$qe && !$srch->conf->setting_data("ss:$word"))
1535            $srch->warn("There is no “" . htmlspecialchars($word) . "” saved search.");
1536        else if (!$qe)
1537            $srch->warn("The “" . htmlspecialchars($word) . "” saved search is defined incorrectly.");
1538        $qe = $qe ? : new False_SearchTerm;
1539        if ($nextq)
1540            $qe->set_strspan_owner($nextq);
1541        return $qe;
1542    }
1543
1544    private function _search_keyword(&$qt, SearchWord $sword, $keyword, $kwexplicit) {
1545        $word = $sword->word;
1546        $sword->keyword = $keyword;
1547        $sword->kwexplicit = $kwexplicit;
1548        $sword->kwdef = $this->conf->search_keyword($keyword, $this->user);
1549        if ($sword->kwdef && get($sword->kwdef, "parse_callback")) {
1550            $qx = call_user_func($sword->kwdef->parse_callback, $word, $sword, $this);
1551            if ($qx && !is_array($qx))
1552                $qt[] = $qx;
1553            else if ($qx)
1554                $qt = array_merge($qt, $qx);
1555        } else
1556            $this->warn("Unrecognized keyword “" . htmlspecialchars($keyword) . "”.");
1557    }
1558
1559    private function _search_word($word, $defkw) {
1560        // check for paper numbers
1561        if (preg_match('/\A(?:#?\d+(?:(?:-|–|—)#?\d+)?(?:\s*,\s*|\z))+\z/', $word)) {
1562            $range = [];
1563            while (preg_match('/\A#?(\d+)(?:(?:-|–|—)#?(\d+))?\s*,?\s*(.*)\z/', $word, $m)) {
1564                $m[2] = (isset($m[2]) && $m[2] ? $m[2] : $m[1]);
1565                $range = array_merge($range, range(intval($m[1]), intval($m[2])));
1566                $word = $m[3];
1567            }
1568            return new PaperID_SearchTerm($range);
1569        }
1570
1571        // check for `#TAG`
1572        if (substr($word, 0, 1) === "#") {
1573            ++$this->_quiet_count;
1574            $qe = $this->_search_word("hashtag:" . substr($word, 1), $defkw);
1575            --$this->_quiet_count;
1576            if (!$qe->is_false())
1577                return $qe;
1578        }
1579
1580        $keyword = $defkw;
1581        if (preg_match('/\A([-_.a-zA-Z0-9]+|"[^"]+")((?:[=!<>]=?|≠|≤|≥)[^:]+|:.*)\z/', $word, $m)) {
1582            if ($m[2][0] === ":") {
1583                $keyword = $m[1];
1584                $word = ltrim((string) substr($m[2], 1));
1585            } else {
1586                // Allow searches like "ovemer>2"; parse as "ovemer:>2".
1587                ++$this->_quiet_count;
1588                $qe = $this->_search_word($m[1] . ":" . $m[2], $defkw);
1589                --$this->_quiet_count;
1590                if (!$qe->is_false())
1591                    return $qe;
1592            }
1593        }
1594        if ($keyword && $keyword[0] === '"')
1595            $keyword = trim(substr($keyword, 1, strlen($keyword) - 2));
1596
1597        $qt = [];
1598        $sword = new SearchWord($word);
1599        if ($keyword)
1600            $this->_search_keyword($qt, $sword, $keyword, true);
1601        else {
1602            // Special-case unquoted "*", "ANY", "ALL", "NONE", "".
1603            if ($word === "*" || $word === "ANY" || $word === "ALL"
1604                || $word === "")
1605                return new True_SearchTerm;
1606            else if ($word === "NONE")
1607                return new False_SearchTerm;
1608            // Otherwise check known keywords.
1609            foreach ($this->fields as $kw => $x)
1610                $this->_search_keyword($qt, $sword, $kw, false);
1611        }
1612        return SearchTerm::make_op("or", $qt);
1613    }
1614
1615    static function escape_word($str) {
1616        $pos = SearchSplitter::span_balanced_parens($str);
1617        if ($pos === strlen($str))
1618            return $str;
1619        else
1620            return "\"" . str_replace("\"", "\\\"", $str) . "\"";
1621    }
1622
1623    static private function _shift_keyword($splitter, $curqe) {
1624        if (!$splitter->match('/\A(?:[-+!()]|(?:AND|and|OR|or|NOT|not|THEN|then|HIGHLIGHT(?::\w+)?)(?=[\s\(]))/s', $m))
1625            return null;
1626        $op = SearchOperator::get(strtoupper($m[0]));
1627        if (!$op) {
1628            $colon = strpos($m[0], ":");
1629            $op = clone SearchOperator::get(strtoupper(substr($m[0], 0, $colon)));
1630            $op->opinfo = substr($m[0], $colon + 1);
1631        }
1632        if ($curqe && $op->unary)
1633            return null;
1634        $splitter->shift_past($m[0]);
1635        return $op;
1636    }
1637
1638    static private function _shift_word($splitter, Conf $conf) {
1639        if (($x = $splitter->shift()) === "")
1640            return $x;
1641        // `HEADING x` parsed as `HEADING:x`
1642        if ($x === "HEADING") {
1643            $lspan = $splitter->strspan[0];
1644            $x .= ":" . $splitter->shift();
1645            $splitter->strspan[0] = $lspan;
1646            return $x;
1647        }
1648        // some keywords may be followed by parentheses
1649        if (strpos($x, ":")
1650            && preg_match('/\A([-_.a-zA-Z0-9]+:|"[^"]+":)(?=[^"]|\z)/', $x, $m)) {
1651            if ($m[1][0] === "\"")
1652                $kw = substr($m[1], 1, strlen($m[1]) - 2);
1653            else
1654                $kw = substr($m[1], 0, strlen($m[1]) - 1);
1655            if (($kwdef = $conf->search_keyword($kw))
1656                && $splitter->starts_with("(")
1657                && get($kwdef, "allow_parens")) {
1658                $lspan = $splitter->strspan[0];
1659                $x .= $splitter->shift_balanced_parens();
1660                $splitter->strspan[0] = $lspan;
1661            }
1662        }
1663        return $x;
1664    }
1665
1666    static private function _pop_expression_stack($curqe, &$stack) {
1667        $x = array_pop($stack);
1668        if (!$curqe)
1669            return $x->leftqe;
1670        if ($x->leftqe)
1671            $curqe = SearchTerm::make_op($x->op, [$x->leftqe, $curqe]);
1672        else if ($x->op->op !== "+" && $x->op->op !== "(")
1673            $curqe = SearchTerm::make_op($x->op, [$curqe]);
1674        $curqe->apply_strspan($x->strspan);
1675        return $curqe;
1676    }
1677
1678    private function _search_expression($str) {
1679        $stack = array();
1680        $defkwstack = array();
1681        $defkw = $next_defkw = null;
1682        $parens = 0;
1683        $curqe = null;
1684        $splitter = new SearchSplitter($str);
1685
1686        while (!$splitter->is_empty()) {
1687            $op = self::_shift_keyword($splitter, $curqe);
1688            if ($curqe && !$op)
1689                $op = SearchOperator::get("SPACE");
1690            if (!$curqe && $op && $op->op === "highlight") {
1691                $curqe = new True_SearchTerm;
1692                $curqe->set_float("strspan", [$splitter->strspan[0], $splitter->strspan[0]]);
1693            }
1694
1695            if (!$op) {
1696                $word = self::_shift_word($splitter, $this->conf);
1697                // Bare any-case "all", "any", "none" are treated as keywords.
1698                if (!$curqe
1699                    && (empty($stack) || $stack[count($stack) - 1]->op->precedence <= 2)
1700                    && ($uword = strtoupper($word))
1701                    && ($uword === "ALL" || $uword === "ANY" || $uword === "NONE")
1702                    && $splitter->match('/\A(?:|(?:THEN|then|HIGHLIGHT(?::\w+)?)(?:\s|\().*)\z/'))
1703                    $word = $uword;
1704                // Search like "ti:(foo OR bar)" adds a default keyword.
1705                if ($word[strlen($word) - 1] === ":"
1706                    && preg_match('/\A(?:[-_.a-zA-Z0-9]+:|"[^"]+":)\z/', $word)
1707                    && $splitter->starts_with("("))
1708                    $next_defkw = [substr($word, 0, strlen($word) - 1), $splitter->strspan[0]];
1709                else {
1710                    // The heart of the matter.
1711                    $curqe = $this->_search_word($word, $defkw);
1712                    if (!$curqe->is_uninteresting())
1713                        $curqe->set_float("strspan", $splitter->strspan);
1714                }
1715            } else if ($op->op === ")") {
1716                while (!empty($stack)
1717                       && $stack[count($stack) - 1]->op->op !== "(")
1718                    $curqe = self::_pop_expression_stack($curqe, $stack);
1719                if (!empty($stack)) {
1720                    $stack[count($stack) - 1]->strspan[1] = $splitter->strspan[1];
1721                    $curqe = self::_pop_expression_stack($curqe, $stack);
1722                    --$parens;
1723                    $defkw = array_pop($defkwstack);
1724                }
1725            } else if ($op->op === "(") {
1726                assert(!$curqe);
1727                $stkelem = (object) ["op" => $op, "leftqe" => null, "strspan" => $splitter->strspan];
1728                $defkwstack[] = $defkw;
1729                if ($next_defkw) {
1730                    $defkw = $next_defkw[0];
1731                    $stkelem->strspan[0] = $next_defkw[1];
1732                    $next_defkw = null;
1733                }
1734                $stack[] = $stkelem;
1735                ++$parens;
1736            } else if ($op->unary || $curqe) {
1737                $end_precedence = $op->precedence - ($op->precedence <= 1);
1738                while (!empty($stack)
1739                       && $stack[count($stack) - 1]->op->precedence > $end_precedence)
1740                    $curqe = self::_pop_expression_stack($curqe, $stack);
1741                $stack[] = (object) ["op" => $op, "leftqe" => $curqe, "strspan" => $splitter->strspan];
1742                $curqe = null;
1743            }
1744        }
1745
1746        while (!empty($stack))
1747            $curqe = self::_pop_expression_stack($curqe, $stack);
1748        return $curqe;
1749    }
1750
1751
1752    static private function _pop_canonicalize_stack($curqe, &$stack) {
1753        $x = array_pop($stack);
1754        if ($curqe)
1755            $x->qe[] = $curqe;
1756        if (!count($x->qe))
1757            return null;
1758        if ($x->op->unary) {
1759            $qe = $x->qe[0];
1760            if ($x->op->op === "not") {
1761                if (preg_match('/\A(?:[(-]|NOT )/i', $qe))
1762                    $qe = "NOT $qe";
1763                else
1764                    $qe = "-$qe";
1765            }
1766            return $qe;
1767        } else if (count($x->qe) == 1)
1768            return $x->qe[0];
1769        else if ($x->op->op === "space")
1770            return "(" . join(" ", $x->qe) . ")";
1771        else
1772            return "(" . join(" " . $x->op->unparse() . " ", $x->qe) . ")";
1773    }
1774
1775    static private function _canonical_expression($str, $type, Conf $conf) {
1776        $str = trim((string) $str);
1777        if ($str === "")
1778            return "";
1779
1780        $stack = array();
1781        $parens = 0;
1782        $defaultop = $type === "all" ? "SPACE" : "XOR";
1783        $curqe = null;
1784        $t = "";
1785        $splitter = new SearchSplitter($str);
1786
1787        while (!$splitter->is_empty()) {
1788            $op = self::_shift_keyword($splitter, $curqe);
1789            if ($curqe && !$op)
1790                $op = SearchOperator::get($parens ? "SPACE" : $defaultop);
1791            if (!$op) {
1792                $curqe = self::_shift_word($splitter, $conf);
1793            } else if ($op->op === ")") {
1794                while (count($stack)
1795                       && $stack[count($stack) - 1]->op->op !== "(")
1796                    $curqe = self::_pop_canonicalize_stack($curqe, $stack);
1797                if (count($stack)) {
1798                    array_pop($stack);
1799                    --$parens;
1800                }
1801            } else if ($op->op === "(") {
1802                assert(!$curqe);
1803                $stack[] = (object) array("op" => $op, "qe" => array());
1804                ++$parens;
1805            } else {
1806                $end_precedence = $op->precedence - ($op->precedence <= 1);
1807                while (count($stack)
1808                       && $stack[count($stack) - 1]->op->precedence > $end_precedence)
1809                    $curqe = self::_pop_canonicalize_stack($curqe, $stack);
1810                $top = count($stack) ? $stack[count($stack) - 1] : null;
1811                if ($top && !$op->unary && $top->op->op === $op->op)
1812                    $top->qe[] = $curqe;
1813                else
1814                    $stack[] = (object) array("op" => $op, "qe" => array($curqe));
1815                $curqe = null;
1816            }
1817        }
1818
1819        if ($type === "none")
1820            array_unshift($stack, (object) array("op" => SearchOperator::get("NOT"), "qe" => array()));
1821        while (count($stack))
1822            $curqe = self::_pop_canonicalize_stack($curqe, $stack);
1823        return $curqe;
1824    }
1825
1826    static function canonical_query($qa, $qo, $qx, Conf $conf) {
1827        $x = array();
1828        if (($qa = self::_canonical_expression($qa, "all", $conf)) !== "")
1829            $x[] = $qa;
1830        if (($qo = self::_canonical_expression($qo, "any", $conf)) !== "")
1831            $x[] = $qo;
1832        if (($qx = self::_canonical_expression($qx, "none", $conf)) !== "")
1833            $x[] = $qx;
1834        if (count($x) == 1)
1835            return preg_replace('/\A\((.*)\)\z/', '$1', $x[0]);
1836        else
1837            return join(" AND ", $x);
1838    }
1839
1840
1841    // CLEANING
1842    // Clean an input expression series into clauses.  The basic purpose of
1843    // this step is to combine all paper numbers into a single group, and to
1844    // assign review adjustments (rates & rounds).
1845
1846
1847    // QUERY CONSTRUCTION
1848    // Build a database query corresponding to an expression.
1849    // The query may be liberal (returning more papers than actually match);
1850    // QUERY EVALUATION makes it precise.
1851
1852    static function unusableRatings(Contact $user) {
1853        if ($user->privChair || $user->conf->setting("pc_seeallrev"))
1854            return array();
1855        $noratings = array();
1856        $rateset = $user->conf->setting("rev_rating");
1857        if ($rateset == REV_RATINGS_PC)
1858            $npr_constraint = "reviewType>" . REVIEW_EXTERNAL;
1859        else
1860            $npr_constraint = "true";
1861        // This query supposedly returns those reviewIds whose ratings
1862        // are not visible to the current querier
1863        $result = $user->conf->qe("select MPR.reviewId
1864        from PaperReview as MPR
1865        left join (select paperId, count(reviewId) as numReviews from PaperReview where $npr_constraint and reviewNeedsSubmit=0 group by paperId) as NPR on (NPR.paperId=MPR.paperId)
1866        left join (select paperId, count(rating) as numRatings from PaperReview join ReviewRating using (paperId,reviewId) group by paperId) as NRR on (NRR.paperId=MPR.paperId)
1867        where MPR.contactId={$user->contactId}
1868        and numReviews<=2
1869        and numRatings<=2");
1870        return Dbl::fetch_first_columns($result);
1871    }
1872
1873
1874    // QUERY EVALUATION
1875    // Check the results of the query, reducing the possibly conservative
1876    // overestimate produced by the database to a precise result.
1877
1878    private function _add_deleted_papers($qe) {
1879        if ($qe->type === "or" || $qe->type === "then") {
1880            foreach ($qe->child as $subt)
1881                $this->_add_deleted_papers($subt);
1882        } else if ($qe->type === "pn") {
1883            foreach ($qe->pids as $p)
1884                if (array_search($p, $this->_matches) === false)
1885                    $this->_matches[] = (int) $p;
1886        }
1887    }
1888
1889
1890    // BASIC QUERY FUNCTION
1891
1892    private function _add_sorters($qe, $thenmap) {
1893        if (($sorters = $qe->get_float("sort"))) {
1894            foreach ($sorters as $s)
1895                if (($s = self::parse_sorter($s))) {
1896                    $s->thenmap = $thenmap;
1897                    $this->sorters[] = $s;
1898                }
1899        } else if (($s = $qe->default_sorter(true, $thenmap, $this))) {
1900            $this->sorters[] = $s;
1901        }
1902    }
1903
1904    private function _assign_order_anno_group($g, $dt, $anno_index) {
1905        if (($ta = $dt->order_anno_entry($anno_index)))
1906            $this->groupmap[$g] = $ta;
1907        else if (!isset($this->groupmap[$g])) {
1908            $ta = new TagAnno;
1909            $ta->tag = $dt->tag;
1910            $ta->heading = "";
1911            $this->groupmap[$g] = $ta;
1912        }
1913    }
1914
1915    private function _find_order_anno_tag($qe) {
1916        $thetag = null;
1917        foreach ($this->sorters as $sorter) {
1918            $tag = $sorter->type ? Tagger::check_tag_keyword($sorter->type, $this->user, Tagger::NOVALUE | Tagger::ALLOWCONTACTID) : false;
1919            $ok = $tag && ($thetag === null || $thetag === $tag);
1920            $thetag = $ok ? $tag : false;
1921        }
1922        if (!$thetag)
1923            return false;
1924        $dt = $this->conf->tags()->add(TagInfo::base($tag));
1925        if ($dt->has_order_anno())
1926            return $dt;
1927        foreach ($qe->get_float("view", []) as $vk => $action) {
1928            if ($action === "edit"
1929                && ($t = Tagger::check_tag_keyword($vk, $this->user, Tagger::NOVALUE | Tagger::ALLOWCONTACTID | Tagger::NOTAGKEYWORD))
1930                && strcasecmp($t, $dt->tag) == 0)
1931                return $dt;
1932        }
1933        return false;
1934    }
1935
1936    private function _check_order_anno($qe, $rowset) {
1937        if (!($dt = $this->_find_order_anno_tag($qe)))
1938            return false;
1939        $this->is_order_anno = $dt->tag;
1940
1941        $tag_order = [];
1942        $old_overrides = $this->user->add_overrides(Contact::OVERRIDE_CONFLICT);
1943        foreach ($this->_matches as $pid) {
1944            $row = $rowset->get($pid);
1945            if ($row->has_viewable_tag($dt->tag, $this->user))
1946                $tag_order[] = [$row->paperId, $row->tag_value($dt->tag)];
1947            else
1948                $tag_order[] = [$row->paperId, TAG_INDEXBOUND];
1949        }
1950        $this->user->set_overrides($old_overrides);
1951        usort($tag_order, "TagInfo::id_index_compar");
1952
1953        $this->thenmap = [];
1954        $this->_assign_order_anno_group(0, $dt, -1);
1955        $this->groupmap[0]->heading = "none";
1956        $cur_then = $aidx = $tidx = 0;
1957        $alist = $dt->order_anno_list();
1958        while ($aidx < count($alist) || $tidx < count($tag_order)) {
1959            if ($tidx == count($tag_order)
1960                || ($aidx < count($alist) && $alist[$aidx]->tagIndex <= $tag_order[$tidx][1])) {
1961                if ($cur_then != 0 || $tidx != 0 || $aidx != 0)
1962                    ++$cur_then;
1963                $this->_assign_order_anno_group($cur_then, $dt, $aidx);
1964                ++$aidx;
1965            } else {
1966                $this->thenmap[$tag_order[$tidx][0]] = $cur_then;
1967                ++$tidx;
1968            }
1969        }
1970    }
1971
1972    function term() {
1973        if ($this->_qe !== null)
1974            return $this->_qe;
1975
1976        // parse and clean the query
1977        $qe = $this->_search_expression($this->q);
1978        //Conf::msg_debugt(json_encode($qe->debug_json()));
1979        if (!$qe)
1980            $qe = new True_SearchTerm;
1981
1982        // apply review rounds (top down, needs separate step)
1983        if ($this->_has_review_adjustment)
1984            $qe = $qe->adjust_reviews(null, $this);
1985
1986        // extract regular expressions and set _reviewer if the query is
1987        // about exactly one reviewer, and warn about contradictions
1988        $qe->extract_metadata(true, $this);
1989        foreach ($this->contradictions as $contradiction => $garbage)
1990            $this->warn($contradiction);
1991
1992        return ($this->_qe = $qe);
1993    }
1994
1995    private function _prepare_result($qe) {
1996        $sqi = new SearchQueryInfo($this);
1997        $sqi->add_table("Paper");
1998        $sqi->add_column("paperId", "Paper.paperId");
1999        // always include columns needed by rights machinery
2000        $sqi->add_column("timeSubmitted", "Paper.timeSubmitted");
2001        $sqi->add_column("timeWithdrawn", "Paper.timeWithdrawn");
2002        $sqi->add_column("outcome", "Paper.outcome");
2003        if ($this->conf->has_any_lead_or_shepherd())
2004            $sqi->add_column("leadContactId", "Paper.leadContactId");
2005
2006        $filters = [$qe->sqlexpr($sqi)];
2007        //Conf::msg_debugt(var_export($filters, true));
2008        if ($filters[0] === "false")
2009            return [null, false];
2010
2011        // status limitation parts
2012        $limit = $this->limit();
2013        if ($limit === "s"
2014            || $limit === "req"
2015            || $limit === "acc"
2016            || $limit === "und"
2017            || $limit === "unm"
2018            || ($limit === "rable" && !$this->conf->can_pc_see_all_submissions()))
2019            $filters[] = "Paper.timeSubmitted>0";
2020        else if ($limit === "act"
2021                 || $limit === "r"
2022                 || $limit === "rable")
2023            $filters[] = "Paper.timeWithdrawn<=0";
2024        else if ($limit === "unsub")
2025            $filters[] = "(Paper.timeSubmitted<=0 and Paper.timeWithdrawn<=0)";
2026        else if ($limit === "lead")
2027            $filters[] = "Paper.leadContactId=" . $this->cid;
2028        else if ($limit === "manager") {
2029            if ($this->user->is_track_manager())
2030                $filters[] = "(Paper.managerContactId=" . $this->cid . " or Paper.managerContactId=0)";
2031            else
2032                $filters[] = "Paper.managerContactId=" . $this->cid;
2033            $filters[] = "Paper.timeSubmitted>0";
2034            $sqi->add_conflict_table();
2035        }
2036
2037        // decision limitation parts
2038        if ($limit === "acc")
2039            $filters[] = "Paper.outcome>0";
2040        else if ($limit === "und")
2041            $filters[] = "Paper.outcome=0";
2042
2043        // other search limiters
2044        if ($limit === "a")
2045            $filters[] = $this->user->act_author_view_sql("PaperConflict");
2046        else if ($limit === "r")
2047            $sqi->add_table("MyReviews", ["join", "PaperReview", $this->user->act_reviewer_sql("MyReviews")]);
2048        else if ($limit === "ar") {
2049            $sqi->add_table("MyReviews", ["left join", "PaperReview", $this->user->act_reviewer_sql("MyReviews")]);
2050            $filters[] = "(" . $this->user->act_author_view_sql("PaperConflict") . " or (Paper.timeWithdrawn<=0 and MyReviews.reviewType is not null))";
2051        } else if ($limit === "rout")
2052            $sqi->add_table("Limiter", ["join", "PaperReview", $this->user->act_reviewer_sql("Limiter") . " and reviewNeedsSubmit!=0"]);
2053        else if ($limit === "req")
2054            $sqi->add_table("Limiter", ["join", "PaperReview", "Limiter.requestedBy=$this->cid and Limiter.reviewType=" . REVIEW_EXTERNAL]);
2055        else if ($limit === "unm")
2056            $filters[] = "Paper.managerContactId=0";
2057        else if ($this->q === "re:me")
2058            $sqi->add_table("MyReviews", ["join", "PaperReview", $this->user->act_reviewer_sql("MyReviews")]);
2059
2060        if ($limit === "a" || $limit === "ar")
2061            $sqi->add_conflict_table();
2062        if ($limit === "r" || $limit === "ar" || $limit === "rout" || $this->q === "re:me")
2063            $sqi->add_reviewer_columns();
2064
2065        // add permissions tables if we will filter the results
2066        $need_filter = !$qe->trivial_rights($this->user, $this)
2067            || !$this->trivial_limit()
2068            || $this->conf->has_tracks() /* XXX probably only need check_track_view_sensitivity */
2069            || $qe->type === "then"
2070            || $qe->get_float("heading");
2071
2072        if ($need_filter)
2073            $sqi->add_rights_columns();
2074        // XXX some of this should be shared with paperQuery
2075        if (($need_filter && $this->conf->has_track_tags())
2076            || get($this->_query_options, "tags")
2077            || ($this->user->privChair
2078                && $this->conf->has_any_manager()
2079                && $this->conf->tags()->has_sitewide))
2080            $sqi->add_column("paperTags", "(select group_concat(' ', tag, '#', tagIndex separator '') from PaperTag where PaperTag.paperId=Paper.paperId)");
2081        if (get($this->_query_options, "reviewSignatures"))
2082            $sqi->add_review_signature_columns();
2083        foreach (get($this->_query_options, "scores", []) as $f)
2084            $sqi->add_score_columns($f);
2085        if (get($this->_query_options, "reviewWordCounts"))
2086            $sqi->add_review_word_count_columns();
2087        if ($this->conf->submission_blindness() == Conf::BLIND_OPTIONAL)
2088            $sqi->add_column("blind", "Paper.blind");
2089
2090        // create query
2091        $sqi->finish_reviewer_columns();
2092        $q = "select ";
2093        foreach ($sqi->columns as $colname => $value)
2094            $q .= $value . " " . $colname . ", ";
2095        $q = substr($q, 0, strlen($q) - 2) . "\n    from ";
2096        foreach ($sqi->tables as $tabname => $value)
2097            if (!$value)
2098                $q .= $tabname;
2099            else {
2100                $joiners = array("$tabname.paperId=Paper.paperId");
2101                for ($i = 2; $i < count($value); ++$i)
2102                    if ($value[$i])
2103                        $joiners[] = "(" . $value[$i] . ")";
2104                $q .= "\n    " . $value[0] . " " . $value[1] . " as " . $tabname
2105                    . " on (" . join("\n        and ", $joiners) . ")";
2106            }
2107        if (!empty($filters))
2108            $q .= "\n    where " . join("\n        and ", $filters);
2109        $q .= "\n    group by Paper.paperId";
2110
2111        //Conf::msg_debugt($q);
2112        //error_log($q);
2113
2114        // actually perform query
2115        return [$this->conf->qe_raw($q), $need_filter];
2116    }
2117
2118    private function _prepare() {
2119        if ($this->_matches !== null)
2120            return;
2121
2122        if ($this->limit() === "x") {
2123            $this->_matches = [];
2124            return true;
2125        }
2126
2127        $qe = $this->term();
2128        //Conf::msg_debugt(json_encode($qe->debug_json()));
2129
2130        // collect papers
2131        list($result, $need_filter) = $this->_prepare_result($qe);
2132        $rowset = new PaperInfoSet;
2133        while (($row = PaperInfo::fetch($result, $this->user)))
2134            $rowset->add($row);
2135        Dbl::free($result);
2136
2137        // correct query, create thenmap, groupmap, highlightmap
2138        $need_then = $qe->type === "then";
2139        $this->thenmap = null;
2140        if ($need_then && $qe->nthen > 1)
2141            $this->thenmap = array();
2142        $this->highlightmap = array();
2143        $this->_matches = array();
2144        if ($need_filter) {
2145            $old_overrides = $this->user->add_overrides(Contact::OVERRIDE_CONFLICT);
2146            foreach ($rowset->all() as $row) {
2147                if (!$this->test_limit($row))
2148                    $x = false;
2149                else if ($need_then) {
2150                    $x = false;
2151                    for ($i = 0; $i < $qe->nthen && $x === false; ++$i)
2152                        if ($qe->child[$i]->exec($row, $this))
2153                            $x = $i;
2154                } else
2155                    $x = !!$qe->exec($row, $this);
2156                if ($x === false)
2157                    continue;
2158                $this->_matches[] = $row->paperId;
2159                if ($this->thenmap !== null)
2160                    $this->thenmap[$row->paperId] = $x;
2161                if ($need_then) {
2162                    for ($j = $qe->nthen; $j < count($qe->child); ++$j)
2163                        if ($qe->child[$j]->exec($row, $this)
2164                            && ($qe->highlights[$j - $qe->nthen] & (1 << $x)))
2165                            $this->highlightmap[$row->paperId][] = $qe->highlight_types[$j - $qe->nthen];
2166                }
2167            }
2168            $this->user->set_overrides($old_overrides);
2169        } else {
2170            $this->_matches = $rowset->paper_ids();
2171        }
2172
2173        // add deleted papers explicitly listed by number (e.g. action log)
2174        if ($this->_allow_deleted)
2175            $this->_add_deleted_papers($qe);
2176
2177        // view and sort information
2178        $this->viewmap = $qe->get_float("view", array());
2179        $this->_add_sorters($qe, null);
2180        if ($qe->type === "then")
2181            for ($i = 0; $i < $qe->nthen; ++$i)
2182                $this->_add_sorters($qe->child[$i], $this->thenmap ? $i : null);
2183
2184        // group information
2185        $this->groupmap = [];
2186        $sole_qe = $qe;
2187        if ($qe->type === "then")
2188            $sole_qe = $qe->nthen == 1 ? $qe->child[0] : null;
2189        if (!$sole_qe) {
2190            for ($i = 0; $i < $qe->nthen; ++$i) {
2191                $h = $qe->child[$i]->get_float("heading");
2192                if ($h === null) {
2193                    $span = $qe->child[$i]->get_float("strspan");
2194                    $spanstr = $qe->child[$i]->get_float("strspan_owner", $this->q);
2195                    $h = rtrim(substr($spanstr, $span[0], $span[1] - $span[0]));
2196                }
2197                $this->groupmap[$i] = TagAnno::make_heading($h);
2198            }
2199        } else if (($h = $sole_qe->get_float("heading")))
2200            $this->groupmap[0] = TagAnno::make_heading($h);
2201        else
2202            $this->_check_order_anno($sole_qe, $rowset);
2203    }
2204
2205    function paper_ids() {
2206        $this->_prepare();
2207        return $this->_matches ? : [];
2208    }
2209
2210    function sorted_paper_ids() {
2211        $this->_prepare();
2212        if ($this->_default_sort || $this->sorters) {
2213            $pl = new PaperList($this, ["sort" => $this->_default_sort]);
2214            return $pl->paper_ids();
2215        } else
2216            return $this->paper_ids();
2217    }
2218
2219    function restrict_match($callback) {
2220        $m = [];
2221        foreach ($this->paper_ids() as $pid)
2222            if (call_user_func($callback, $pid))
2223                $m[] = $pid;
2224        if ($this->_matches !== false)
2225            $this->_matches = $m;
2226    }
2227
2228    private function trivial_limit() {
2229        $limit = $this->limit();
2230        if ($this->user->has_hidden_papers())
2231            return false;
2232        else if ($limit === "und" || $limit === "acc" || $limit === "vis")
2233            return $this->privChair;
2234        else if ($limit === "rable" || $limit === "manager")
2235            return false;
2236        else
2237            return true;
2238    }
2239
2240    function test_limit(PaperInfo $prow) {
2241        if (!$this->user->can_view_paper($prow))
2242            return false;
2243        switch ($this->limit()) {
2244        case "s":
2245            return $prow->timeSubmitted > 0;
2246        case "acc":
2247            return $prow->timeSubmitted > 0
2248                && $this->user->can_view_decision($prow)
2249                && $prow->outcome > 0;
2250        case "und":
2251            return $prow->timeSubmitted > 0
2252                && ($prow->outcome == 0
2253                    || !$this->user->can_view_decision($prow));
2254        case "unm":
2255            return $prow->timeSubmitted > 0 && $prow->managerContactId == 0;
2256        case "rable":
2257            $user = $this->reviewer_user();
2258            return $user->can_accept_review_assignment_ignore_conflict($prow)
2259                && ($this->conf->can_pc_see_all_submissions()
2260                    ? $prow->timeWithdrawn <= 0
2261                    : $prow->timeSubmitted > 0)
2262                && ($this->privChair
2263                    || $this->user === $user
2264                    || $this->user->can_administer($prow));
2265        case "act":
2266            return $prow->timeWithdrawn <= 0;
2267        case "r":
2268            return $prow->timeWithdrawn <= 0 && $prow->has_reviewer($this->user);
2269        case "unsub":
2270            return $prow->timeSubmitted <= 0 && $prow->timeWithdrawn <= 0;
2271        case "lead":
2272            return $prow->leadContactId == $this->cid;
2273        case "manager":
2274            return $prow->timeSubmitted > 0 && $this->user->allow_administer($prow);
2275        case "a":
2276            return $this->user->act_author_view($prow);
2277        case "ar":
2278            return $this->user->act_author_view($prow)
2279                || ($prow->timeWithdrawn <= 0 && $prow->has_reviewer($this->user));
2280        case "rout":
2281            foreach ($prow->reviews_of_user($this->user, $this->user->review_tokens()) as $rrow)
2282                if ($rrow->reviewNeedsSubmit != 0)
2283                    return true;
2284            return false;
2285        case "req":
2286            if ($prow->timeSubmitted <= 0)
2287                return false;
2288            foreach ($prow->reviews_by_id() as $rrow)
2289                if ($rrow->reviewType == REVIEW_EXTERNAL && $rrow->requestedBy == $this->cid)
2290                    return true;
2291            return false;
2292        case "all":
2293        case "vis":
2294            return true;
2295        default:
2296            return false;
2297        }
2298    }
2299
2300    function test(PaperInfo $prow) {
2301        $old_overrides = $this->user->add_overrides(Contact::OVERRIDE_CONFLICT);
2302        $qe = $this->term();
2303        $x = $this->test_limit($prow) && $qe->exec($prow, $this);
2304        $this->user->set_overrides($old_overrides);
2305        return $x;
2306    }
2307
2308    function filter($prows) {
2309        $old_overrides = $this->user->add_overrides(Contact::OVERRIDE_CONFLICT);
2310        $qe = $this->term();
2311        $results = [];
2312        foreach ($prows as $prow)
2313            if ($this->test_limit($prow) && $qe->exec($prow, $this))
2314                $results[] = $prow;
2315        $this->user->set_overrides($old_overrides);
2316        return $results;
2317    }
2318
2319    function test_review(PaperInfo $prow, ReviewInfo $rrow) {
2320        $old_overrides = $this->user->add_overrides(Contact::OVERRIDE_CONFLICT);
2321        $qe = $this->term();
2322        $this->test_review = $rrow;
2323        $x = $this->test_limit($prow) && $qe->exec($prow, $this);
2324        $this->test_review = null;
2325        $this->user->set_overrides($old_overrides);
2326        return $x;
2327    }
2328
2329    function simple_search_options() {
2330        $limit = $xlimit = $this->limit();
2331        if ($this->q === "re:me"
2332            && ($xlimit === "s" || $xlimit === "act" || $xlimit === "rout" || $xlimit === "rable"))
2333            $xlimit = "r";
2334        if ($this->_matches !== null
2335            || ($this->q !== ""
2336                && ($this->q !== "re:me" || $xlimit !== "r"))
2337            || (!$this->privChair
2338                && $this->reviewer_user() !== $this->user)
2339            || ($this->conf->has_tracks()
2340                && !$this->privChair
2341                && !in_array($xlimit, ["a", "r", "ar"]))
2342            || ($this->conf->has_tracks()
2343                && $limit === "rable")
2344            || $this->user->has_hidden_papers())
2345            return false;
2346        if ($limit === "rable") {
2347            if ($this->reviewer_user()->isPC)
2348                $limit = $this->conf->can_pc_see_all_submissions() ? "act" : "s";
2349            else
2350                $limit = "r";
2351        }
2352        $queryOptions = [];
2353        if ($limit === "s")
2354            $queryOptions["finalized"] = 1;
2355        else if ($limit === "unsub") {
2356            $queryOptions["unsub"] = 1;
2357            $queryOptions["active"] = 1;
2358        } else if ($limit === "acc") {
2359            if ($this->privChair || $this->conf->can_all_author_view_decision()) {
2360                $queryOptions["accepted"] = 1;
2361                $queryOptions["finalized"] = 1;
2362            } else
2363                return false;
2364        } else if ($limit === "und") {
2365            $queryOptions["undecided"] = 1;
2366            $queryOptions["finalized"] = 1;
2367        } else if ($limit === "r")
2368            $queryOptions["myReviews"] = 1;
2369        else if ($limit === "rout")
2370            $queryOptions["myOutstandingReviews"] = 1;
2371        else if ($limit === "a") {
2372            // If complex author SQL, always do search the long way
2373            if ($this->user->act_author_view_sql("%", true))
2374                return false;
2375            $queryOptions["author"] = 1;
2376        } else if ($limit === "req" || $limit === "reqrevs")
2377            $queryOptions["myReviewRequests"] = 1;
2378        else if ($limit === "act")
2379            $queryOptions["active"] = 1;
2380        else if ($limit === "lead")
2381            $queryOptions["myLead"] = 1;
2382        else if ($limit === "unm")
2383            $queryOptions["finalized"] = $queryOptions["unmanaged"] = 1;
2384        else if ($limit !== "all"
2385                 && ($limit !== "vis" || !$this->privChair))
2386            return false; /* don't understand limit */
2387        if ($this->q === "re:me" && $limit !== "rout")
2388            $queryOptions["myReviews"] = 1;
2389        return $queryOptions;
2390    }
2391
2392    function alternate_query() {
2393        if ($this->q !== ""
2394            && $this->q[0] !== "#"
2395            && preg_match('/\A' . TAG_REGEX . '\z/', $this->q)
2396            && $this->user->can_view_tags(null)
2397            && in_array($this->limit(), ["s", "all", "r"])) {
2398            if ($this->q[0] === "~")
2399                return "#" . $this->q;
2400            $result = $this->conf->qe("select paperId from PaperTag where tag=? limit 1", $this->q);
2401            if (count(Dbl::fetch_first_columns($result)))
2402                return "#" . $this->q;
2403        }
2404        return false;
2405    }
2406
2407    function url_site_relative_raw($q = null) {
2408        $url = $this->urlbase;
2409        if ($q === null)
2410            $q = $this->q;
2411        if ($q !== "" || substr($this->urlbase, 0, 6) === "search")
2412            $url .= (strpos($url, "?") === false ? "?" : "&")
2413                . "q=" . urlencode($q);
2414        return $url;
2415    }
2416
2417    private function _tag_description() {
2418        if ($this->q === "")
2419            return false;
2420        else if (strlen($this->q) <= 24)
2421            return htmlspecialchars($this->q);
2422        else if (!preg_match(',\A(#|-#|tag:|-tag:|notag:|order:|rorder:)(.*)\z,', $this->q, $m))
2423            return false;
2424        $tagger = new Tagger($this->user);
2425        if (!$tagger->check($m[2]))
2426            return false;
2427        else if ($m[1] === "-tag:")
2428            return "no" . substr($this->q, 1);
2429        else
2430            return $this->q;
2431    }
2432
2433    function description($listname) {
2434        if ($listname)
2435            $lx = $this->conf->_($listname);
2436        else {
2437            $limit = $this->limit();
2438            if ($this->q === "re:me" && ($limit === "s" || $limit === "act"))
2439                $limit = "r";
2440            $lx = $this->conf->_c("search_types", get(self::$search_type_names, $limit, "Papers"));
2441        }
2442        if ($this->q === ""
2443            || ($this->q === "re:me" && $this->limit() === "s")
2444            || ($this->q === "re:me" && $this->limit() === "act"))
2445            return $lx;
2446        else if (($td = $this->_tag_description()))
2447            return "$td in $lx";
2448        else
2449            return "$lx search";
2450    }
2451
2452    function listid($sort = null) {
2453        $rest = [];
2454        if ($this->_reviewer_user && $this->_reviewer_user->contactId !== $this->cid)
2455            $rest[] = "reviewer=" . urlencode($this->_reviewer_user->email);
2456        if ((string) $sort !== "")
2457            $rest[] = "sort=" . urlencode($sort);
2458        return "p/" . $this->limitName . "/" . urlencode($this->q)
2459            . ($rest ? "/" . join("&", $rest) : "");
2460    }
2461
2462    static function unparse_listid($listid) {
2463        if (preg_match('{\Ap/([^/]+)/([^/]*)(?:|/([^/]*))\z}', $listid, $m)) {
2464            $args = ["t" => $m[1], "q" => urldecode($m[2])];
2465            if (isset($m[3]) && $m[3] !== "") {
2466                foreach (explode("&", $m[3]) as $arg) {
2467                    if (str_starts_with($arg, "sort="))
2468                        $args["sort"] = urldecode(substr($arg, 5));
2469                    else
2470                        // XXX `reviewer`
2471                        error_log(caller_landmark() . ": listid includes $arg");
2472                }
2473            }
2474            return $args;
2475        } else
2476            return null;
2477    }
2478
2479    function create_session_list_object($ids, $listname, $sort = null) {
2480        $sort = $sort !== null ? $sort : $this->_default_sort;
2481        $l = new SessionList($this->listid($sort), $ids,
2482                             $this->description($listname), $this->urlbase);
2483        if ($this->field_highlighters())
2484            $l->highlight = $this->_match_preg_query ? : true;
2485        return $l;
2486    }
2487
2488    function session_list_object() {
2489        return $this->create_session_list_object($this->sorted_paper_ids(), null);
2490    }
2491
2492    function highlight_tags() {
2493        if ($this->_highlight_tags === null) {
2494            $this->_prepare();
2495            $this->_highlight_tags = array();
2496            foreach ($this->sorters ? : array() as $s)
2497                if ($s->type[0] === "#")
2498                    $this->_highlight_tags[] = substr($s->type, 1);
2499        }
2500        return $this->_highlight_tags;
2501    }
2502
2503
2504    function set_field_highlighter_query($q) {
2505        $ps = new PaperSearch($this->user, ["q" => $q]);
2506        $this->_match_preg = $ps->field_highlighters();
2507        $this->_match_preg_query = $q;
2508    }
2509
2510    function field_highlighters() {
2511        if ($this->_match_preg === null) {
2512            $this->_match_preg = [];
2513            $this->term();
2514            if (!empty($this->regex)) {
2515                foreach (TextMatch_SearchTerm::$map as $k => $v)
2516                    if (isset($this->regex[$k]) && !empty($this->regex[$k]))
2517                        $this->_match_preg[$v] = Text::merge_pregexes($this->regex[$k]);
2518            }
2519        }
2520        return $this->_match_preg;
2521    }
2522
2523    function field_highlighter($field) {
2524        return get($this->field_highlighters(), $field, "");
2525    }
2526
2527
2528    static function search_types(Contact $user, $reqtype = null) {
2529        $ts = [];
2530        if ($user->isPC) {
2531            if ($user->conf->can_pc_see_all_submissions())
2532                $ts[] = "act";
2533            $ts[] = "s";
2534            if ($user->conf->timePCViewDecision(false) && $user->conf->has_any_accepted())
2535                $ts[] = "acc";
2536        }
2537        if ($user->privChair) {
2538            $ts[] = "all";
2539            if (!$user->conf->can_pc_see_all_submissions() && $reqtype === "act")
2540                $ts[] = "act";
2541        }
2542        if ($user->is_reviewer())
2543            $ts[] = "r";
2544        if ($user->has_outstanding_review()
2545            || ($user->is_reviewer() && $reqtype === "rout"))
2546            $ts[] = "rout";
2547        if ($user->isPC) {
2548            if ($user->is_requester() || $reqtype === "req")
2549                $ts[] = "req";
2550            if (($user->conf->has_any_lead_or_shepherd() && $user->is_discussion_lead())
2551                || $reqtype === "lead")
2552                $ts[] = "lead";
2553            if (($user->privChair ? $user->conf->has_any_manager() : $user->is_manager())
2554                || $reqtype === "manager")
2555                $ts[] = "manager";
2556        }
2557        if ($user->is_author() || $reqtype === "a")
2558            $ts[] = "a";
2559        return self::expand_search_types($user, $ts);
2560    }
2561
2562    static function manager_search_types(Contact $user) {
2563        if ($user->privChair) {
2564            if ($user->conf->has_any_manager())
2565                $ts = ["manager", "unm", "s"];
2566            else
2567                $ts = ["s"];
2568            array_push($ts, "acc", "und", "all");
2569        } else
2570            $ts = ["manager"];
2571        return self::expand_search_types($user, $ts);
2572    }
2573
2574    static private function expand_search_types(Contact $user, $ts) {
2575        $topt = [];
2576        foreach ($ts as $t)
2577            $topt[$t] = $user->conf->_c("search_type", self::$search_type_names[$t]);
2578        return $topt;
2579    }
2580
2581    static function searchTypeSelector($tOpt, $type, $tabindex) {
2582        if (count($tOpt) > 1) {
2583            $sel_opt = array();
2584            foreach ($tOpt as $k => $v) {
2585                if (count($sel_opt) && $k === "a")
2586                    $sel_opt["xxxa"] = null;
2587                if (count($sel_opt) > 2 && ($k === "lead" || $k === "r") && !isset($sel_opt["xxxa"]))
2588                    $sel_opt["xxxb"] = null;
2589                $sel_opt[$k] = $v;
2590            }
2591            $sel_extra = array();
2592            if ($tabindex)
2593                $sel_extra["tabindex"] = $tabindex;
2594            return Ht::select("t", $sel_opt, $type, $sel_extra);
2595        } else
2596            return current($tOpt);
2597    }
2598
2599    private static function simple_search_completion($prefix, $map, $flags = 0) {
2600        $x = array();
2601        foreach ($map as $id => $str) {
2602            $match = null;
2603            foreach (preg_split(',[^a-z0-9_]+,', strtolower($str)) as $word)
2604                if ($word !== ""
2605                    && ($m = Text::simple_search($word, $map, $flags))
2606                    && isset($m[$id]) && count($m) == 1
2607                    && !Text::is_boring_word($word)) {
2608                    $match = $word;
2609                    break;
2610                }
2611            $x[] = $prefix . ($match ? : "\"$str\"");
2612        }
2613        return $x;
2614    }
2615
2616    function search_completion($category = "") {
2617        $res = [];
2618        $old_overrides = $this->user->add_overrides(Contact::OVERRIDE_CONFLICT);
2619
2620        if ($this->amPC && (!$category || $category === "ss")) {
2621            foreach ($this->conf->saved_searches() as $k => $v)
2622                $res[] = "ss:" . $k;
2623        }
2624
2625        array_push($res, "has:submission", "has:abstract");
2626        if ($this->amPC && $this->conf->has_any_manager())
2627            $res[] = "has:admin";
2628        if ($this->conf->has_any_lead_or_shepherd()
2629            && $this->user->can_view_lead(null))
2630            $res[] = "has:lead";
2631        if ($this->user->can_view_some_decision()) {
2632            $res[] = "has:decision";
2633            if (!$category || $category === "dec") {
2634                $res[] = array("pri" => -1, "nosort" => true, "i" => array("dec:any", "dec:none", "dec:yes", "dec:no"));
2635                $dm = $this->conf->decision_map();
2636                unset($dm[0]);
2637                $res = array_merge($res, self::simple_search_completion("dec:", $dm, Text::SEARCH_UNPRIVILEGE_EXACT));
2638            }
2639            if ($this->conf->setting("final_open"))
2640                $res[] = "has:final";
2641        }
2642        if ($this->conf->has_any_lead_or_shepherd()
2643            && $this->user->can_view_shepherd(null))
2644            $res[] = "has:shepherd";
2645        if ($this->user->is_reviewer())
2646            array_push($res, "has:review", "has:creview", "has:ireview", "has:preview", "has:primary", "has:secondary", "has:external", "has:comment", "has:aucomment");
2647        else if ($this->user->can_view_some_review())
2648            array_push($res, "has:review", "has:comment");
2649        if ($this->amPC && $this->conf->setting("extrev_approve") && $this->conf->setting("pcrev_editdelegate")
2650            && $this->user->is_requester())
2651            array_push($res, "has:approvable");
2652        foreach ($this->conf->resp_rounds() as $rrd) {
2653            if (!in_array("has:response", $res))
2654                $res[] = "has:response";
2655            if ($rrd->number)
2656                $res[] = "has:{$rrd->name}response";
2657        }
2658        if ($this->user->can_view_some_draft_response())
2659            foreach ($this->conf->resp_rounds() as $rrd) {
2660                if (!in_array("has:draftresponse", $res))
2661                    $res[] = "has:draftresponse";
2662                if ($rrd->number)
2663                    $res[] = "has:draft{$rrd->name}response";
2664            }
2665        if ($this->user->can_view_tags()) {
2666            array_push($res, "has:color", "has:style");
2667            if ($this->conf->tags()->has_badges)
2668                $res[] = "has:badge";
2669        }
2670        foreach ($this->user->user_option_list() as $o)
2671            if ($this->user->can_view_some_paper_option($o))
2672                $o->add_search_completion($res);
2673        if ($this->user->is_reviewer() && $this->conf->has_rounds()
2674            && (!$category || $category === "round")) {
2675            $res[] = array("pri" => -1, "nosort" => true, "i" => array("round:any", "round:none"));
2676            $rlist = array();
2677            foreach ($this->conf->round_list() as $rnum => $round)
2678                if ($rnum && $round !== ";")
2679                    $rlist[$rnum] = $round;
2680            $res = array_merge($res, self::simple_search_completion("round:", $rlist));
2681        }
2682        if ($this->conf->has_topics() && (!$category || $category === "topic")) {
2683            foreach ($this->conf->topic_map() as $tname)
2684                $res[] = "topic:\"{$tname}\"";
2685        }
2686        if ((!$category || $category === "style") && $this->user->can_view_tags()) {
2687            $res[] = array("pri" => -1, "nosort" => true, "i" => array("style:any", "style:none", "color:any", "color:none"));
2688            foreach ($this->conf->tags()->canonical_colors() as $t) {
2689                $res[] = "style:$t";
2690                if ($this->conf->tags()->is_known_style($t, TagMap::STYLE_BG))
2691                    $res[] = "color:$t";
2692            }
2693        }
2694        if (!$category || $category === "show" || $category === "hide") {
2695            $cats = array();
2696            $pl = new PaperList($this);
2697            foreach ($this->conf->paper_column_map() as $cname => $cj) {
2698                $cj = $this->conf->basic_paper_column($cname, $this->user);
2699                if ($cj && isset($cj->completion) && $cj->completion
2700                    && ($c = PaperColumn::make($this->conf, $cj))
2701                    && ($cat = $c->completion_name())
2702                    && $c->prepare($pl, 0)) {
2703                    $cats[$cat] = true;
2704                }
2705            }
2706            foreach ($this->conf->paper_column_factories() as $fxj) {
2707                if (!$this->conf->xt_allowed($fxj, $this->user)
2708                    || Conf::xt_disabled($fxj))
2709                    continue;
2710                if (isset($fxj->completion_callback)) {
2711                    Conf::xt_resolve_require($fxj);
2712                    foreach (call_user_func($fxj->completion_callback, $this->user, $fxj) as $c)
2713                        $cats[$c] = true;
2714                } else if (isset($fxj->completion) && is_string($fxj->completion))
2715                    $cats[$fxj->completion] = true;
2716            }
2717            foreach (array_keys($cats) as $cat)
2718                array_push($res, "show:$cat", "hide:$cat");
2719            array_push($res, "show:compact", "show:statistics", "show:rownumbers");
2720        }
2721
2722        $this->user->set_overrides($old_overrides);
2723        return $res;
2724    }
2725}
2726