1<?php
2// assignmentset.php -- HotCRP helper classes for assignments
3// Copyright (c) 2006-2018 Eddie Kohler; see LICENSE.
4
5class AssignmentItem implements ArrayAccess {
6    public $before;
7    public $after = null;
8    public $lineno = null;
9    function __construct($before) {
10        $this->before = $before;
11    }
12    function offsetExists($offset) {
13        $x = $this->after ? : $this->before;
14        return isset($x[$offset]);
15    }
16    function offsetGet($offset) {
17        $x = $this->after ? : $this->before;
18        return isset($x[$offset]) ? $x[$offset] : null;
19    }
20    function offsetSet($offset, $value) {
21    }
22    function offsetUnset($offset) {
23    }
24    function existed() {
25        return !!$this->before;
26    }
27    function deleted() {
28        return $this->after === false;
29    }
30    function modified() {
31        return $this->after !== null;
32    }
33    function get($before, $offset = null) {
34        if ($offset === null)
35            return $this->offsetGet($before);
36        if ($before || $this->after === null)
37            $x = $this->before;
38        else
39            $x = $this->after;
40        return $x && isset($x[$offset]) ? $x[$offset] : null;
41    }
42    function get_before($offset) {
43        return $this->get(true, $offset);
44    }
45    function differs($offset) {
46        return $this->get(true, $offset) !== $this->get(false, $offset);
47    }
48    function realize(AssignmentState $astate) {
49        return call_user_func($astate->realizer($this->offsetGet("type")), $this, $astate);
50    }
51}
52
53class AssignmentState {
54    private $st = array();
55    private $types = array();
56    private $realizers = [];
57    public $conf;
58    public $user;     // executor
59    public $reviewer; // default contact
60    public $overrides = 0;
61    private $cmap;
62    private $reviewer_users = null;
63    public $lineno = null;
64    public $defaults = array();
65    private $prows = array();
66    private $pid_attempts = array();
67    public $finishers = array();
68    public $paper_exact_match = true;
69    public $errors = [];
70
71    function __construct(Contact $user) {
72        $this->conf = $user->conf;
73        $this->user = $this->reviewer = $user;
74        $this->cmap = new AssignerContacts($this->conf, $this->user);
75    }
76
77    function mark_type($type, $keys, $realizer) {
78        if (!isset($this->types[$type])) {
79            $this->types[$type] = $keys;
80            $this->realizers[$type] = $realizer;
81            return true;
82        } else
83            return false;
84    }
85    function realizer($type) {
86        return $this->realizers[$type];
87    }
88    private function pidstate($pid) {
89        if (!isset($this->st[$pid]))
90            $this->st[$pid] = (object) array("items" => array());
91        return $this->st[$pid];
92    }
93    private function extract_key($x, $pid = null) {
94        $tkeys = $this->types[$x["type"]];
95        assert($tkeys);
96        $t = $x["type"];
97        foreach ($tkeys as $k)
98            if (isset($x[$k]))
99                $t .= "`" . $x[$k];
100            else if ($pid !== null && $k === "pid")
101                $t .= "`" . $pid;
102            else
103                return false;
104        return $t;
105    }
106    function load($x) {
107        $st = $this->pidstate($x["pid"]);
108        $k = $this->extract_key($x);
109        assert($k && !isset($st->items[$k]));
110        $st->items[$k] = new AssignmentItem($x);
111        $st->sorted = false;
112    }
113
114    private function pid_keys($q) {
115        if (isset($q["pid"]))
116            return array($q["pid"]);
117        else
118            return array_keys($this->st);
119    }
120    static private function match($x, $q) {
121        foreach ($q as $k => $v) {
122            if ($v !== null && get($x, $k) !== $v)
123                return false;
124        }
125        return true;
126    }
127    function query_items($q) {
128        $res = [];
129        foreach ($this->pid_keys($q) as $pid) {
130            $st = $this->pidstate($pid);
131            $k = $this->extract_key($q, $pid);
132            foreach ($k ? [get($st->items, $k)] : $st->items as $item)
133                if ($item && !$item->deleted()
134                    && self::match($item->after ? : $item->before, $q))
135                    $res[] = $item;
136        }
137        return $res;
138    }
139    function query($q) {
140        $res = [];
141        foreach ($this->query_items($q) as $item)
142            $res[] = $item->after ? : $item->before;
143        return $res;
144    }
145    function query_unmodified($q) {
146        $res = [];
147        foreach ($this->query_items($q) as $item)
148            if (!$item->modified())
149                $res[] = $item->before;
150        return $res;
151    }
152    function make_filter($key, $q) {
153        $cf = [];
154        foreach ($this->query($q) as $m)
155            $cf[$m[$key]] = true;
156        return $cf;
157    }
158
159    function remove($q) {
160        $res = [];
161        foreach ($this->query_items($q) as $item) {
162            $res[] = $item->after ? : $item->before;
163            $item->after = false;
164            $item->lineno = $this->lineno;
165        }
166        return $res;
167    }
168    function add($x) {
169        $k = $this->extract_key($x);
170        assert(!!$k);
171        $st = $this->pidstate($x["pid"]);
172        if (!($item = get($st->items, $k)))
173            $item = $st->items[$k] = new AssignmentItem(false);
174        $item->after = $x;
175        $item->lineno = $this->lineno;
176        return $item;
177    }
178
179    function diff() {
180        $diff = array();
181        foreach ($this->st as $pid => $st) {
182            foreach ($st->items as $item)
183                if ((!$item->before && $item->after)
184                    || ($item->before && $item->after === false)
185                    || ($item->before && $item->after && !self::match($item->before, $item->after)))
186                    $diff[$pid][] = $item;
187        }
188        return $diff;
189    }
190
191    function paper_ids() {
192        return array_keys($this->prows);
193    }
194    function prow($pid) {
195        $p = get($this->prows, $pid);
196        if (!$p && !isset($this->pid_attempts[$pid])) {
197            $this->fetch_prows($pid);
198            $p = get($this->prows, $pid);
199        }
200        return $p;
201    }
202    function add_prow(PaperInfo $prow) {
203        $this->prows[$prow->paperId] = $prow;
204    }
205    function prows() {
206        return $this->prows;
207    }
208    function fetch_prows($pids, $initial_load = false) {
209        $pids = is_array($pids) ? $pids : array($pids);
210        $fetch_pids = array();
211        foreach ($pids as $p)
212            if (!isset($this->prows[$p]) && !isset($this->pid_attempts[$p]))
213                $fetch_pids[] = $p;
214        assert($initial_load || empty($fetch_pids));
215        if (!empty($fetch_pids)) {
216            foreach ($this->user->paper_set($fetch_pids) as $prow)
217                $this->prows[$prow->paperId] = $prow;
218            foreach ($fetch_pids as $pid)
219                if (!isset($this->prows[$pid]))
220                    $this->pid_attempts[$pid] = true;
221        }
222    }
223
224    function user_by_id($cid) {
225        return $this->cmap->user_by_id($cid);
226    }
227    function users_by_id($cids) {
228        return array_map(function ($cid) { return $this->user_by_id($cid); }, $cids);
229    }
230    function user_by_email($email, $create = false, $req = null) {
231        return $this->cmap->user_by_email($email, $create, $req);
232    }
233    function none_user() {
234        return $this->cmap->none_user();
235    }
236    function pc_users() {
237        return $this->cmap->pc_users();
238    }
239    function reviewer_users() {
240        if ($this->reviewer_users === null)
241            $this->reviewer_users = $this->cmap->reviewer_users($this->paper_ids());
242        return $this->reviewer_users;
243    }
244    function register_user(Contact $c) {
245        return $this->cmap->register_user($c);
246    }
247
248    function error($message) {
249        $this->errors[] = [$message, true, false];
250    }
251    function paper_error($message) {
252        $this->errors[] = [$message, $this->paper_exact_match, false];
253    }
254    function user_error($message) {
255        $this->errors[] = [$message, true, true];
256    }
257}
258
259class AssignerContacts {
260    private $conf;
261    private $viewer;
262    private $by_id = array();
263    private $by_lemail = array();
264    private $has_pc = false;
265    private $none_user;
266    static private $next_fake_id = -10;
267    static public $query = "ContactInfo.contactId, firstName, lastName, unaccentedName, email, roles, contactTags";
268    function __construct(Conf $conf, Contact $viewer) {
269        global $Me;
270        $this->conf = $conf;
271        $this->viewer = $viewer;
272        if ($Me && $Me->contactId > 0 && $Me->conf === $conf)
273            $this->store($Me);
274    }
275    private function store(Contact $c) {
276        if ($c->contactId != 0) {
277            if (isset($this->by_id[$c->contactId]))
278                return $this->by_id[$c->contactId];
279            $this->by_id[$c->contactId] = $c;
280        }
281        if ($c->email)
282            $this->by_lemail[strtolower($c->email)] = $c;
283        return $c;
284    }
285    private function ensure_pc() {
286        if (!$this->has_pc) {
287            foreach ($this->conf->pc_members() as $p)
288                $this->store($p);
289            $this->has_pc = true;
290        }
291    }
292    function none_user() {
293        if (!$this->none_user)
294            $this->none_user = new Contact(["contactId" => 0, "roles" => 0, "email" => "", "sorter" => ""], $this->conf);
295        return $this->none_user;
296    }
297    function user_by_id($cid) {
298        if (!$cid)
299            return $this->none_user();
300        if (($c = get($this->by_id, $cid)))
301            return $c;
302        $this->ensure_pc();
303        if (($c = get($this->by_id, $cid)))
304            return $c;
305        $result = $this->conf->qe("select " . self::$query . " from ContactInfo where contactId=?", $cid);
306        $c = Contact::fetch($result, $this->conf);
307        if (!$c)
308            $c = new Contact(["contactId" => $cid, "roles" => 0, "email" => "unknown contact $cid", "sorter" => ""], $this->conf);
309        Dbl::free($result);
310        return $this->store($c);
311    }
312    function user_by_email($email, $create = false, $req = null) {
313        if (!$email)
314            return $this->none_user();
315        $lemail = strtolower($email);
316        if (($c = get($this->by_lemail, $lemail)))
317            return $c;
318        $this->ensure_pc();
319        if (($c = get($this->by_lemail, $lemail)))
320            return $c;
321        $result = $this->conf->qe("select " . self::$query . " from ContactInfo where email=?", $lemail);
322        $c = Contact::fetch($result, $this->conf);
323        Dbl::free($result);
324        if (!$c && $create) {
325            assert(validate_email($email) || preg_match('/\Aanonymous\d*\z/', $email));
326            $cargs = ["contactId" => self::$next_fake_id, "roles" => 0, "email" => $email];
327            foreach (["firstName", "lastName", "affiliation"] as $k)
328                if ($req && get($req, $k))
329                    $cargs[$k] = $req[$k];
330            if (preg_match('/\Aanonymous\d*\z/', $email)) {
331                $cargs["firstName"] = "Jane Q.";
332                $cargs["lastName"] = "Public";
333                $cargs["affiliation"] = "Unaffiliated";
334                $cargs["disabled"] = 1;
335            }
336            $c = new Contact($cargs, $this->conf);
337            self::$next_fake_id -= 1;
338        }
339        return $c ? $this->store($c) : null;
340    }
341    function pc_users() {
342        $this->ensure_pc();
343        return $this->conf->pc_members();
344    }
345    function reviewer_users($pids) {
346        $rset = $this->pc_users();
347        $result = $this->conf->qe("select " . AssignerContacts::$query . " from ContactInfo join PaperReview using (contactId) where (roles&" . Contact::ROLE_PC . ")=0 and paperId?a group by ContactInfo.contactId", $pids);
348        while ($result && ($c = Contact::fetch($result, $this->conf)))
349            $rset[$c->contactId] = $this->store($c);
350        Dbl::free($result);
351        return $rset;
352    }
353    function register_user(Contact $c) {
354        if ($c->contactId >= 0)
355            return $c;
356        assert($this->by_id[$c->contactId] === $c);
357        $cx = $this->by_lemail[strtolower($c->email)];
358        if ($cx === $c) {
359            // XXX assume that never fails:
360            $cargs = [];
361            foreach (["email", "firstName", "lastName", "affiliation", "disabled"] as $k)
362                if ($c->$k !== null)
363                    $cargs[$k] = $c->$k;
364            $cx = Contact::create($this->conf, $this->viewer, $cargs, $cx->is_anonymous_user() ? Contact::SAVE_ANY_EMAIL : 0);
365            $cx = $this->store($cx);
366        }
367        return $cx;
368    }
369}
370
371class AssignmentCount {
372    public $ass = 0;
373    public $rev = 0;
374    public $meta = 0;
375    public $pri = 0;
376    public $sec = 0;
377    public $lead = 0;
378    public $shepherd = 0;
379    function add(AssignmentCount $ct) {
380        $xct = new AssignmentCount;
381        foreach (["rev", "meta", "pri", "sec", "ass", "lead", "shepherd"] as $k)
382            $xct->$k = $this->$k + $ct->$k;
383        return $xct;
384    }
385}
386
387class AssignmentCountSet {
388    public $conf;
389    public $bypc = [];
390    public $rev = false;
391    public $lead = false;
392    public $shepherd = false;
393    function __construct(Conf $conf) {
394        $this->conf = $conf;
395    }
396    function get($offset) {
397        return get($this->bypc, $offset) ? : new AssignmentCount;
398    }
399    function ensure($offset) {
400        if (!isset($this->bypc[$offset]))
401            $this->bypc[$offset] = new AssignmentCount;
402        return $this->bypc[$offset];
403    }
404    function load_rev() {
405        $result = $this->conf->qe("select u.contactId, group_concat(r.reviewType separator '')
406                from ContactInfo u
407                left join PaperReview r on (r.contactId=u.contactId)
408                left join Paper p on (p.paperId=r.paperId)
409                where p.timeWithdrawn<=0 and p.timeSubmitted>0
410                and u.roles!=0 and (u.roles&" . Contact::ROLE_PC . ")!=0
411                group by u.contactId");
412        while (($row = edb_row($result))) {
413            $ct = $this->ensure($row[0]);
414            $ct->rev = strlen($row[1]);
415            $ct->meta = substr_count($row[1], REVIEW_META);
416            $ct->pri = substr_count($row[1], REVIEW_PRIMARY);
417            $ct->sec = substr_count($row[1], REVIEW_SECONDARY);
418        }
419        Dbl::free($result);
420    }
421    private function load_paperpc($type) {
422        $result = $this->conf->qe("select {$type}ContactId, count(paperId)
423                from Paper where timeWithdrawn<=0 and timeSubmitted>0
424                group by {$type}ContactId");
425        while (($row = edb_row($result))) {
426            $ct = $this->ensure($row[0]);
427            $ct->$type = +$row[1];
428        }
429        Dbl::free($result);
430    }
431    function load_lead() {
432        $this->load_paperpc("lead");
433    }
434    function load_shepherd() {
435        $this->load_paperpc("shepherd");
436    }
437}
438
439class AssignmentCsv {
440    public $header = [];
441    public $data = [];
442    function add($row) {
443        foreach ($row as $k => $v)
444            if ($v !== null)
445                $this->header[$k] = true;
446        $this->data[] = $row;
447    }
448    function unparse() {
449        $csvg = new CsvGenerator;
450        return $csvg->select($this->header)->add($this->data)->unparse();
451    }
452}
453
454class AssignmentParser {
455    public $type;
456    function __construct($type) {
457        $this->type = $type;
458    }
459    function expand_papers(&$req, AssignmentState $state) {
460        return false;
461    }
462    function load_state(AssignmentState $state) {
463    }
464    function allow_paper(PaperInfo $prow, AssignmentState $state) {
465        if (!$state->user->can_administer($prow)
466            && !$state->user->privChair)
467            return "You can’t administer #{$prow->paperId}.";
468        else if ($prow->timeWithdrawn > 0)
469            return "#$prow->paperId has been withdrawn.";
470        else if ($prow->timeSubmitted <= 0)
471            return "#$prow->paperId is not submitted.";
472        else
473            return true;
474    }
475    function contact_set(&$req, AssignmentState $state) {
476        return "pc";
477    }
478    static function unconflicted(PaperInfo $prow, Contact $contact, AssignmentState $state) {
479        return ($state->overrides & Contact::OVERRIDE_CONFLICT)
480            || !$prow->has_conflict($contact);
481    }
482    function paper_filter($contact, &$req, AssignmentState $state) {
483        return false;
484    }
485    function expand_any_user(PaperInfo $prow, &$req, AssignmentState $state) {
486        return false;
487    }
488    function expand_missing_user(PaperInfo $prow, &$req, AssignmentState $state) {
489        return false;
490    }
491    function expand_anonymous_user(PaperInfo $prow, &$req, $user, AssignmentState $state) {
492        return false;
493    }
494    function allow_contact(PaperInfo $prow, Contact $contact, &$req, AssignmentState $state) {
495        return false;
496    }
497    function apply(PaperInfo $prow, Contact $contact, &$req, AssignmentState $state) {
498        return true;
499    }
500}
501
502class UserlessAssignmentParser extends AssignmentParser {
503    function __construct($type) {
504        parent::__construct($type);
505    }
506    function contact_set(&$req, AssignmentState $state) {
507        return false;
508    }
509    function expand_any_user(PaperInfo $prow, &$req, AssignmentState $state) {
510        return [$state->none_user()];
511    }
512    function expand_missing_user(PaperInfo $prow, &$req, AssignmentState $state) {
513        return [$state->none_user()];
514    }
515    function allow_contact(PaperInfo $prow, Contact $contact, &$req, AssignmentState $state) {
516        return true;
517    }
518}
519
520class Assigner {
521    public $item;
522    public $type;
523    public $pid;
524    public $contact;
525    public $cid;
526    public $next_index;
527    function __construct(AssignmentItem $item, AssignmentState $state) {
528        $this->item = $item;
529        $this->type = $item["type"];
530        $this->pid = $item["pid"];
531        $this->cid = $item["cid"] ? : $item["_cid"];
532        if ($this->cid)
533            $this->contact = $state->user_by_id($this->cid);
534    }
535    function unparse_description() {
536        return "";
537    }
538    function unparse_display(AssignmentSet $aset) {
539        return "";
540    }
541    function unparse_csv(AssignmentSet $aset, AssignmentCsv $acsv) {
542        return null;
543    }
544    function account(AssignmentSet $aset, AssignmentCountSet $delta) {
545    }
546    function add_locks(AssignmentSet $aset, &$locks) {
547    }
548    function execute(AssignmentSet $aset) {
549    }
550    function cleanup(AssignmentSet $aset) {
551    }
552}
553
554class Null_AssignmentParser extends UserlessAssignmentParser {
555    function __construct() {
556        parent::__construct("none");
557    }
558    function allow_paper(PaperInfo $prow, AssignmentState $state) {
559        return true;
560    }
561    function apply(PaperInfo $prow, Contact $contact, &$req, AssignmentState $state) {
562        return true;
563    }
564}
565
566class ReviewAssigner_Data {
567    public $oldround = null;
568    public $newround = null;
569    public $explicitround = false;
570    public $oldtype = null;
571    public $newtype = null;
572    public $creator = true;
573    public $error = false;
574    static function separate($key, $req, $state, $rtype) {
575        $a0 = $a1 = trim(get_s($req, $key));
576        $require_match = $rtype ? false : $a0 !== "";
577        if ($a0 === "" && $rtype != 0)
578            $a0 = $a1 = get($state->defaults, $key);
579        if ($a0 !== null && ($colon = strpos($a0, ":")) !== false) {
580            $a1 = (string) substr($a0, $colon + 1);
581            $a0 = (string) substr($a0, 0, $colon);
582            $require_match = true;
583        }
584        $a0 = is_string($a0) ? trim($a0) : $a0;
585        $a1 = is_string($a1) ? trim($a1) : $a1;
586        if (strcasecmp($a0, "any") == 0) {
587            $a0 = null;
588            $require_match = true;
589        }
590        if (strcasecmp($a1, "any") == 0) {
591            $a1 = null;
592            $require_match = true;
593        }
594        return [$a0, $a1, $require_match];
595    }
596    function __construct($req, AssignmentState $state, $rtype) {
597        list($targ0, $targ1, $tmatch) = self::separate("reviewtype", $req, $state, $rtype);
598        if ($targ0 !== null && $targ0 !== "" && $tmatch
599            && ($this->oldtype = ReviewInfo::parse_type($targ0)) === false)
600            $this->error = "Invalid reviewtype.";
601        if ($targ1 !== null && $targ1 !== "" && $rtype != 0
602            && ($this->newtype = ReviewInfo::parse_type($targ1)) === false)
603            $this->error = "Invalid reviewtype.";
604        if ($this->newtype === null)
605            $this->newtype = $rtype;
606
607        list($rarg0, $rarg1, $rmatch) = self::separate("round", $req, $state, $this->newtype);
608        if ($rarg0 !== null && $rarg0 !== "" && $rmatch
609            && ($this->oldround = $state->conf->sanitize_round_name($rarg0)) === false)
610            $this->error = Conf::round_name_error($rarg0);
611        if ($rarg1 !== null && $rarg1 !== "" && $this->newtype != 0
612            && ($this->newround = $state->conf->sanitize_round_name($rarg1)) === false)
613            $this->error = Conf::round_name_error($rarg1);
614        if ($rarg0 !== "" && $rarg1 !== null)
615            $this->explicitround = (string) get($req, "round") !== "";
616        if ($rarg0 === "")
617            $rmatch = false;
618        if ($this->oldtype === null && $rtype > 0 && $rmatch)
619            $this->oldtype = $rtype;
620
621        $this->creator = !$tmatch && !$rmatch && $this->newtype != 0;
622    }
623    static function make(&$req, AssignmentState $state, $rtype) {
624        if (!isset($req["_review_data"]) || !is_object($req["_review_data"]))
625            $req["_review_data"] = new ReviewAssigner_Data($req, $state, $rtype);
626        return $req["_review_data"];
627    }
628    function can_create_review() {
629        return $this->creator;
630    }
631}
632
633class Review_AssignmentParser extends AssignmentParser {
634    private $rtype;
635    function __construct(Conf $conf, $aj) {
636        parent::__construct($aj->name);
637        if ($aj->review_type)
638            $this->rtype = (int) ReviewInfo::parse_type($aj->review_type);
639        else
640            $this->rtype = -1;
641    }
642    function load_state(AssignmentState $state) {
643        if ($state->mark_type("review", ["pid", "cid"], "Review_Assigner::make"))
644            self::load_review_state($state);
645    }
646    private function make_rdata(&$req, AssignmentState $state) {
647        return ReviewAssigner_Data::make($req, $state, $this->rtype);
648    }
649    function contact_set(&$req, AssignmentState $state) {
650        if ($this->rtype > REVIEW_EXTERNAL)
651            return "pc";
652        else if ($this->rtype == 0
653                 || (($rdata = $this->make_rdata($req, $state))
654                     && !$rdata->can_create_review()))
655            return "reviewers";
656        else
657            return false;
658    }
659    static function load_review_state(AssignmentState $state) {
660        $result = $state->conf->qe("select paperId, contactId, reviewType, reviewRound, reviewSubmitted from PaperReview where paperId?a", $state->paper_ids());
661        while (($row = edb_row($result))) {
662            $round = $state->conf->round_name($row[3]);
663            $state->load(["type" => "review", "pid" => +$row[0], "cid" => +$row[1],
664                          "_rtype" => +$row[2], "_round" => $round,
665                          "_rsubmitted" => $row[4] > 0 ? 1 : 0]);
666        }
667        Dbl::free($result);
668    }
669    private function make_filter($fkey, $key, $value, &$req, AssignmentState $state) {
670        $rdata = $this->make_rdata($req, $state);
671        if ($rdata->can_create_review())
672            return null;
673        return $state->make_filter($fkey, [
674                "type" => "review", $key => $value,
675                "_rtype" => $rdata->oldtype, "_round" => $rdata->oldround
676            ]);
677    }
678    function paper_filter($contact, &$req, AssignmentState $state) {
679        return $this->make_filter("pid", "cid", $contact->contactId, $req, $state);
680    }
681    function expand_any_user(PaperInfo $prow, &$req, AssignmentState $state) {
682        $cf = $this->make_filter("cid", "pid", $prow->paperId, $req, $state);
683        return $cf !== null ? $state->users_by_id(array_keys($cf)) : false;
684    }
685    function expand_missing_user(PaperInfo $prow, &$req, AssignmentState $state) {
686        return $this->expand_any_user($prow, $req, $state);
687    }
688    function expand_anonymous_user(PaperInfo $prow, &$req, $user, AssignmentState $state) {
689        if (preg_match('/\A(?:new-?anonymous|anonymous-?new)\z/', $user)) {
690            $suf = "";
691            while (($u = $state->user_by_email("anonymous" . $suf))
692                   && $state->query(["type" => "review", "pid" => $prow->paperId,
693                                     "cid" => $u->contactId]))
694                $suf = $suf === "" ? 2 : $suf + 1;
695            $user = "anonymous" . $suf;
696        }
697        if (preg_match('/\Aanonymous\d*\z/', $user)
698            && $c = $state->user_by_email($user, true, []))
699            return [$c];
700        else
701            return false;
702    }
703    function allow_contact(PaperInfo $prow, Contact $contact, &$req, AssignmentState $state) {
704        // User “none” is never allowed
705        if (!$contact->contactId)
706            return false;
707        // PC reviews must be PC members
708        $rdata = $this->make_rdata($req, $state);
709        if ($rdata->newtype >= REVIEW_PC && !$contact->is_pc_member())
710            return Text::user_html_nolink($contact) . " is not a PC member and cannot be assigned a PC review.";
711        // Conflict allowed if we're not going to assign a new review
712        if ($this->rtype == 0
713            || $prow->has_reviewer($contact)
714            || !$rdata->can_create_review())
715            return true;
716        // Check whether review assignments are acceptable
717        if ($contact->is_pc_member()
718            && !$contact->can_accept_review_assignment_ignore_conflict($prow))
719            return Text::user_html_nolink($contact) . " cannot be assigned to review #{$prow->paperId}.";
720        // Check conflicts
721        return AssignmentParser::unconflicted($prow, $contact, $state);
722    }
723    function apply(PaperInfo $prow, Contact $contact, &$req, AssignmentState $state) {
724        $rdata = $this->make_rdata($req, $state);
725        if ($rdata->error)
726            return $rdata->error;
727
728        $revmatch = ["type" => "review", "pid" => $prow->paperId,
729                     "cid" => $contact->contactId,
730                     "_rtype" => $rdata->oldtype, "_round" => $rdata->oldround];
731        $res = $state->remove($revmatch);
732        assert(count($res) <= 1);
733
734        if ($rdata->can_create_review() && empty($res)) {
735            $revmatch["_round"] = $rdata->newround;
736            $res[] = $revmatch;
737        }
738        if ($rdata->newtype && !empty($res)) {
739            $m = $res[0];
740            if (!$m["_rtype"] || $rdata->newtype > 0)
741                $m["_rtype"] = $rdata->newtype;
742            if (!$m["_rtype"] || $m["_rtype"] < 0)
743                $m["_rtype"] = REVIEW_EXTERNAL;
744            if ($m["_rtype"] == REVIEW_EXTERNAL
745                && $state->conf->pc_member_by_id($m["cid"]))
746                $m["_rtype"] = REVIEW_PC;
747            if ($rdata->newround !== null && $rdata->explicitround)
748                $m["_round"] = $rdata->newround;
749            $state->add($m);
750        } else if (!$rdata->newtype && !empty($res) && $res[0]["_rsubmitted"])
751            // do not remove submitted reviews
752            $state->add($res[0]);
753        return true;
754    }
755}
756
757class Review_Assigner extends Assigner {
758    private $rtype;
759    private $notify = false;
760    private $unsubmit = false;
761    private $token = false;
762    static public $prefinfo = null;
763    function __construct(AssignmentItem $item, AssignmentState $state) {
764        parent::__construct($item, $state);
765        $this->rtype = $item->get(false, "_rtype");
766        $this->unsubmit = $item->get(true, "_rsubmitted") && !$item->get(false, "_rsubmitted");
767        if (!$item->existed() && $this->rtype == REVIEW_EXTERNAL
768            && !$this->contact->is_anonymous_user()
769            && ($notify = get($state->defaults, "extrev_notify"))
770            && Mailer::is_template($notify))
771            $this->notify = $notify;
772    }
773    static function make(AssignmentItem $item, AssignmentState $state) {
774        return new Review_Assigner($item, $state);
775    }
776    function unparse_description() {
777        return "review";
778    }
779    private function unparse_item(AssignmentSet $aset, $before) {
780        if (!$this->item->get($before, "_rtype"))
781            return "";
782        $t = $aset->user->reviewer_html_for($this->contact) . ' '
783            . review_type_icon($this->item->get($before, "_rtype"),
784                               !$this->item->get($before, "_rsubmitted"));
785        if (($round = $this->item->get($before, "_round")))
786            $t .= ' <span class="revround" title="Review round">'
787                . htmlspecialchars($round) . '</span>';
788        if (self::$prefinfo
789            && ($cpref = get(self::$prefinfo, $this->cid))
790            && ($pref = get($cpref, $this->pid)))
791            $t .= unparse_preference_span($pref);
792        return $t;
793    }
794    private function icon($before) {
795        return review_type_icon($this->item->get($before, "_rtype"),
796                                !$this->item->get($before, "_rsubmitted"));
797    }
798    function unparse_display(AssignmentSet $aset) {
799        $t = $aset->user->reviewer_html_for($this->contact);
800        if ($this->item->deleted())
801            $t = '<del>' . $t . '</del>';
802        if ($this->item->differs("_rtype") || $this->item->differs("_rsubmitted")) {
803            if ($this->item->get(true, "_rtype"))
804                $t .= ' <del>' . $this->icon(true) . '</del>';
805            if ($this->item->get(false, "_rtype"))
806                $t .= ' <ins>' . $this->icon(false) . '</ins>';
807        } else if ($this->item["_rtype"])
808            $t .= ' ' . $this->icon(false);
809        if ($this->item->differs("_round")) {
810            if (($round = $this->item->get(true, "_round")))
811                $t .= ' <del><span class="revround" title="Review round">' . htmlspecialchars($round) . '</span></del>';
812            if (($round = $this->item->get(false, "_round")))
813                $t .= ' <ins><span class="revround" title="Review round">' . htmlspecialchars($round) . '</span></ins>';
814        } else if (($round = $this->item["_round"]))
815            $t .= ' <span class="revround" title="Review round">' . htmlspecialchars($round) . '</span>';
816        if (!$this->item->existed() && self::$prefinfo
817            && ($cpref = get(self::$prefinfo, $this->cid))
818            && ($pref = get($cpref, $this->pid)))
819            $t .= unparse_preference_span($pref);
820        return $t;
821    }
822    function unparse_csv(AssignmentSet $aset, AssignmentCsv $acsv) {
823        $x = ["pid" => $this->pid, "action" => ReviewInfo::unparse_assigner_action($this->rtype),
824              "email" => $this->contact->email, "name" => $this->contact->name_text()];
825        if (($round = $this->item["_round"]))
826            $x["round"] = $this->item["_round"];
827        if ($this->token)
828            $x["review_token"] = encode_token($this->token);
829        $acsv->add($x);
830        if ($this->unsubmit)
831            $acsv->add(["action" => "unsubmitreview", "pid" => $this->pid,
832                        "email" => $this->contact->email, "name" => $this->contact->name_text()]);
833    }
834    function account(AssignmentSet $aset, AssignmentCountSet $deltarev) {
835        $aset->show_column("reviewers");
836        if ($this->cid > 0) {
837            $deltarev->rev = true;
838            $ct = $deltarev->ensure($this->cid);
839            ++$ct->ass;
840            $oldtype = $this->item->get(true, "_rtype") ? : 0;
841            $ct->rev += ($this->rtype != 0) - ($oldtype != 0);
842            $ct->meta += ($this->rtype == REVIEW_META) - ($oldtype == REVIEW_META);
843            $ct->pri += ($this->rtype == REVIEW_PRIMARY) - ($oldtype == REVIEW_PRIMARY);
844            $ct->sec += ($this->rtype == REVIEW_SECONDARY) - ($oldtype == REVIEW_SECONDARY);
845        }
846    }
847    function add_locks(AssignmentSet $aset, &$locks) {
848        $locks["PaperReview"] = $locks["PaperReviewRefused"] = $locks["Settings"] = "write";
849    }
850    function execute(AssignmentSet $aset) {
851        $extra = array();
852        $round = $this->item->get(false, "_round");
853        if ($round !== null && $this->rtype)
854            $extra["round_number"] = (int) $aset->conf->round_number($round, true);
855        if ($this->contact->is_anonymous_user()
856            && (!$this->item->existed() || $this->item->deleted())) {
857            $extra["token"] = true;
858            $aset->cleanup_callback("rev_token", function ($aset, $vals) {
859                $aset->conf->update_rev_tokens_setting(min($vals));
860            }, $this->item->existed() ? 0 : 1);
861        }
862        $reviewId = $aset->user->assign_review($this->pid, $this->cid, $this->rtype, $extra);
863        if ($this->unsubmit && $reviewId)
864            $aset->user->unsubmit_review_row((object) ["paperId" => $this->pid, "contactId" => $this->cid, "reviewType" => $this->rtype, "reviewId" => $reviewId]);
865        if (get($extra, "token") && $reviewId)
866            $this->token = $aset->conf->fetch_ivalue("select reviewToken from PaperReview where paperId=? and reviewId=?", $this->pid, $reviewId);
867    }
868    function cleanup(AssignmentSet $aset) {
869        if ($this->notify) {
870            $reviewer = $aset->conf->user_by_id($this->cid);
871            $prow = $aset->conf->paperRow(["paperId" => $this->pid], $reviewer);
872            HotCRPMailer::send_to($reviewer, $this->notify, $prow);
873        }
874    }
875}
876
877
878class UnsubmitReview_AssignmentParser extends AssignmentParser {
879    function __construct() {
880        parent::__construct("unsubmitreview");
881    }
882    function load_state(AssignmentState $state) {
883        if ($state->mark_type("review", ["pid", "cid"], "Review_Assigner::make"))
884            Review_AssignmentParser::load_review_state($state);
885    }
886    function contact_set(&$req, AssignmentState $state) {
887        return "reviewers";
888    }
889    function paper_filter($contact, &$req, AssignmentState $state) {
890        return $state->make_filter("pid", ["type" => "review", "cid" => $contact->contactId, "_rsubmitted" => 1]);
891    }
892    function expand_any_user(PaperInfo $prow, &$req, AssignmentState $state) {
893        $cf = $state->make_filter("cid", ["type" => "review", "pid" => $prow->paperId, "_rsubmitted" => 1]);
894        return $state->users_by_id(array_keys($cf));
895    }
896    function expand_missing_user(PaperInfo $prow, &$req, AssignmentState $state) {
897        return $this->expand_any_user($prow, $req, $state);
898    }
899    function allow_contact(PaperInfo $prow, Contact $contact, &$req, AssignmentState $state) {
900        return $contact->contactId != 0;
901    }
902    function apply(PaperInfo $prow, Contact $contact, &$req, AssignmentState $state) {
903        // parse round and reviewtype arguments
904        $rarg0 = trim(get_s($req, "round"));
905        $oldround = null;
906        if ($rarg0 !== "" && strcasecmp($rarg0, "any") != 0
907            && ($oldround = $state->conf->sanitize_round_name($rarg0)) === false)
908            return Conf::round_name_error($rarg0);
909        $targ0 = trim(get_s($req, "reviewtype"));
910        $oldtype = null;
911        if ($targ0 !== ""
912            && ($oldtype = ReviewInfo::parse_type($targ0)) === false)
913            return "Invalid reviewtype.";
914
915        // remove existing review
916        $revmatch = ["type" => "review", "pid" => $prow->paperId,
917                     "cid" => $contact->contactId,
918                     "_rtype" => $oldtype, "_round" => $oldround, "_rsubmitted" => 1];
919        $matches = $state->remove($revmatch);
920        foreach ($matches as $r) {
921            $r["_rsubmitted"] = 0;
922            $state->add($r);
923        }
924        return true;
925    }
926}
927
928
929class AssignmentSet {
930    public $conf;
931    public $user;
932    public $filename;
933    private $assigners = [];
934    private $assigners_pidhead = [];
935    private $enabled_pids = null;
936    private $enabled_actions = null;
937    private $msgs = array();
938    private $has_error = false;
939    private $has_user_error = false;
940    private $my_conflicts = null;
941    private $astate;
942    private $searches = array();
943    private $search_type = "s";
944    private $unparse_search = false;
945    private $unparse_columns = array();
946    private $assignment_type;
947    private $cleanup_callbacks;
948    private $cleanup_notify_tracker;
949    private $qe_stager;
950
951    function __construct(Contact $user, $overrides = null) {
952        $this->conf = $user->conf;
953        $this->user = $user;
954        $this->astate = new AssignmentState($user);
955        $this->set_overrides($overrides);
956    }
957
958    function set_search_type($search_type) {
959        $this->search_type = $search_type;
960    }
961    function set_reviewer(Contact $reviewer) {
962        $this->astate->reviewer = $reviewer;
963    }
964    function set_overrides($overrides) {
965        if ($overrides === null)
966            $overrides = $this->user->overrides();
967        else if ($overrides === true)
968            $overrides = $this->user->overrides() | Contact::OVERRIDE_CONFLICT;
969        if (!$this->user->privChair)
970            $overrides &= ~Contact::OVERRIDE_CONFLICT;
971        $this->astate->overrides = (int) $overrides;
972    }
973
974    function enable_actions($action) {
975        assert(empty($this->assigners));
976        if ($this->enabled_actions === null)
977            $this->enabled_actions = [];
978        foreach (is_array($action) ? $action : [$action] as $a)
979            if (($aparser = $this->conf->assignment_parser($a, $this->user)))
980                $this->enabled_actions[$aparser->type] = true;
981    }
982
983    function enable_papers($paper) {
984        assert(empty($this->assigners));
985        if ($this->enabled_pids === null)
986            $this->enabled_pids = [];
987        foreach (is_array($paper) ? $paper : [$paper] as $p)
988            if ($p instanceof PaperInfo) {
989                $this->astate->add_prow($p);
990                $this->enabled_pids[] = $p->paperId;
991            } else
992                $this->enabled_pids[] = (int) $p;
993    }
994
995    function is_empty() {
996        return empty($this->assigners);
997    }
998
999    function has_error() {
1000        return $this->has_error;
1001    }
1002
1003    function clear_errors() {
1004        $this->msgs = [];
1005        $this->has_error = false;
1006        $this->has_user_error = false;
1007    }
1008
1009    function msg($lineno, $msg, $status) {
1010        $l = ($this->filename ? $this->filename . ":" : "line ") . $lineno;
1011        $n = count($this->msgs) - 1;
1012        if ($n >= 0
1013            && $this->msgs[$n][0] === $l
1014            && $this->msgs[$n][1] === $msg)
1015            $this->msgs[$n][2] = max($this->msgs[$n][2], $status);
1016        else
1017            $this->msgs[] = [$l, $msg, $status];
1018        if ($status == 2)
1019            $this->has_error = true;
1020    }
1021    function error_at($lineno, $message) {
1022        $this->msg($lineno, $message, 2);
1023    }
1024    function error_here($message) {
1025        $this->msg($this->astate->lineno, $message, 2);
1026    }
1027
1028    function errors_html($linenos = false) {
1029        $es = array();
1030        foreach ($this->msgs as $e) {
1031            $t = $e[1];
1032            if ($linenos && $e[0])
1033                $t = '<span class="lineno">' . htmlspecialchars($e[0]) . ':</span> ' . $t;
1034            if (empty($es) || $es[count($es) - 1] !== $t)
1035                $es[] = $t;
1036        }
1037        return $es;
1038    }
1039    function errors_div_html($linenos = false) {
1040        $es = $this->errors_html($linenos);
1041        if (empty($es))
1042            return "";
1043        else if ($linenos)
1044            return '<div class="parseerr"><p>' . join("</p>\n<p>", $es) . '</p></div>';
1045        else if (count($es) == 1)
1046            return $es[0];
1047        else
1048            return '<div><div class="mmm">' . join('</div><div class="mmm">', $es) . '</div></div>';
1049    }
1050    function errors_text($linenos = false) {
1051        $es = array();
1052        foreach ($this->msgs as $e) {
1053            $t = htmlspecialchars_decode(preg_replace(',<(?:[^\'">]|\'[^\']*\'|"[^"]*")*>,', "", $e[1]));
1054            if ($linenos && $e[0])
1055                $t = $e[0] . ': ' . $t;
1056            if (empty($es) || $es[count($es) - 1] !== $t)
1057                $es[] = $t;
1058        }
1059        return $es;
1060    }
1061
1062    function report_errors() {
1063        if (!empty($this->msgs) && $this->has_error)
1064            Conf::msg_error('Assignment errors: ' . $this->errors_div_html(true) . ' Please correct these errors and try again.');
1065        else if (!empty($this->msgs))
1066            Conf::msg_warning('Assignment warnings: ' . $this->errors_div_html(true));
1067    }
1068
1069    function json_result($linenos = false) {
1070        if ($this->has_error) {
1071            $jr = new JsonResult(403, ["ok" => false, "error" => $this->errors_div_html($linenos)]);
1072            if ($this->has_user_error) {
1073                $jr->status = 422;
1074                $jr->content["user_error"] = true;
1075            }
1076            return $jr;
1077        } else if (!empty($this->msgs)) {
1078            return new JsonResult(["ok" => true, "response" => $this->errors_div_html($linenos)]);
1079        } else {
1080            return new JsonResult(["ok" => true]);
1081        }
1082    }
1083
1084    private static function req_user_html($req) {
1085        return Text::user_html_nolink(get($req, "firstName"), get($req, "lastName"), get($req, "email"));
1086    }
1087
1088    private function set_my_conflicts() {
1089        $this->my_conflicts = array();
1090        $result = $this->conf->qe("select Paper.paperId, managerContactId from Paper join PaperConflict on (PaperConflict.paperId=Paper.paperId) where conflictType>0 and PaperConflict.contactId=?", $this->user->contactId);
1091        while (($row = edb_row($result)))
1092            $this->my_conflicts[$row[0]] = ($row[1] ? $row[1] : true);
1093        Dbl::free($result);
1094    }
1095
1096    private static function apply_user_parts(&$req, $a) {
1097        foreach (array("firstName", "lastName", "email") as $i => $k)
1098            if (!get($req, $k) && get($a, $i))
1099                $req[$k] = $a[$i];
1100    }
1101
1102    private function lookup_users(&$req, $assigner) {
1103        // move all usable identification data to email, firstName, lastName
1104        if (isset($req["name"]))
1105            self::apply_user_parts($req, Text::split_name($req["name"]));
1106        if (isset($req["user"]) && strpos($req["user"], " ") === false) {
1107            if (!get($req, "email"))
1108                $req["email"] = $req["user"];
1109        } else if (isset($req["user"]))
1110            self::apply_user_parts($req, Text::split_name($req["user"], true));
1111
1112        // extract email, first, last
1113        $first = get($req, "firstName");
1114        $last = get($req, "lastName");
1115        $email = trim((string) get($req, "email"));
1116        $lemail = strtolower($email);
1117        $special = null;
1118        if ($lemail)
1119            $special = $lemail;
1120        else if (!$first && $last && strpos(trim($last), " ") === false)
1121            $special = trim(strtolower($last));
1122        $xspecial = $special;
1123
1124        // check special: missing, "none", "any", "pc", "me", PC tag, "external"
1125        if ($special === "all" || $special === "any")
1126            return "any";
1127        else if ($special === "missing" || (!$first && !$last && !$lemail))
1128            return "missing";
1129        else if ($special === "none")
1130            return [$this->astate->none_user()];
1131        else if (preg_match('/\A(?:new-?)?anonymous(?:\d*|-?new)\z/', $special))
1132            return $special;
1133        if ($special && !$first && (!$lemail || !$last)) {
1134            $ret = ContactSearch::make_special($special, $this->astate->user);
1135            if ($ret->ids !== false)
1136                return $ret->contacts();
1137        }
1138        if (($special === "ext" || $special === "external")
1139            && $assigner->contact_set($req, $this->astate) === "reviewers") {
1140            $ret = array();
1141            foreach ($this->astate->reviewer_users() as $u)
1142                if (!$u->is_pc_member())
1143                    $ret[] = $u;
1144            return $ret;
1145        }
1146
1147        // check for precise email match on existing contact (common case)
1148        if ($lemail && ($contact = $this->astate->user_by_email($email, false)))
1149            return array($contact);
1150
1151        // check PC list
1152        $cset = $assigner->contact_set($req, $this->astate);
1153        $cset_text = "user";
1154        if ($cset === "pc") {
1155            $cset = $this->astate->pc_users();
1156            $cset_text = "PC member";
1157        } else if ($cset === "reviewers") {
1158            $cset = $this->astate->reviewer_users();
1159            $cset_text = "reviewer";
1160        }
1161        if ($cset) {
1162            $text = "";
1163            if ($first && $last)
1164                $text = "$last, $first";
1165            else if ($first || $last)
1166                $text = "$last$first";
1167            if ($email)
1168                $text .= " <$email>";
1169            $ret = ContactSearch::make_cset($text, $this->astate->user, $cset);
1170            if (count($ret->ids) == 1)
1171                return $ret->contacts();
1172            else if (empty($ret->ids))
1173                $this->error_here("No $cset_text matches “" . self::req_user_html($req) . "”.");
1174            else
1175                $this->error_here("“" . self::req_user_html($req) . "” matches more than one $cset_text, use a full email address to disambiguate.");
1176            return false;
1177        }
1178
1179        // create contact
1180        if (!$email)
1181            return $this->error_here("Missing email address.");
1182        else if (!validate_email($email))
1183            return $this->error_here("Email address “" . htmlspecialchars($email) . "” is invalid.");
1184        else if (($u = $this->astate->user_by_email($email, true, $req)))
1185            return [$u];
1186        else
1187            return $this->error_here("Could not create user.");
1188    }
1189
1190    static private function is_csv_header($req) {
1191        foreach (array("action", "assignment", "paper", "pid", "paperId") as $k)
1192            if (array_search($k, $req) !== false)
1193                return true;
1194        return false;
1195    }
1196
1197    private function install_csv_header($csv, $req) {
1198        if (!self::is_csv_header($req)) {
1199            $csv->unshift($req);
1200            if (count($req) == 3
1201                && (!$req[2] || strpos($req[2], "@") !== false))
1202                $req = ["paper", "name", "email"];
1203            else if (count($req) == 2)
1204                $req = ["paper", "user"];
1205            else
1206                $req = ["paper", "action", "user", "round"];
1207        } else {
1208            $cleans = array("paper", "pid", "paper", "paperId",
1209                            "firstName", "first", "lastName", "last",
1210                            "firstName", "firstname", "lastName", "lastname",
1211                            "preference", "pref");
1212            for ($i = 0; $i < count($cleans); $i += 2)
1213                if (array_search($cleans[$i], $req) === false
1214                    && ($j = array_search($cleans[$i + 1], $req)) !== false)
1215                    $req[$j] = $cleans[$i];
1216        }
1217
1218        $has_action = array_search("action", $req) !== false
1219            || array_search("assignment", $req) !== false;
1220        if (!$has_action && !isset($this->astate->defaults["action"])) {
1221            $defaults = $modifications = [];
1222            if (array_search("tag", $req) !== false)
1223                $defaults[] = "tag";
1224            if (array_search("preference", $req) !== false)
1225                $defaults[] = "preference";
1226            if (($j = array_search("lead", $req)) !== false) {
1227                $defaults[] = "lead";
1228                $modifications = [$j, "user"];
1229            }
1230            if (($j = array_search("shepherd", $req)) !== false) {
1231                $defaults[] = "shepherd";
1232                $modifications = [$j, "user"];
1233            }
1234            if (($j = array_search("decision", $req)) !== false) {
1235                $defaults[] = "decision";
1236                $modifications = [$j, "decision"];
1237            }
1238            if (count($defaults) == 1) {
1239                $this->astate->defaults["action"] = $defaults[0];
1240                for ($i = 0; $i < count($modifications); $i += 2)
1241                    $req[$modifications[$i]] = $modifications[$i + 1];
1242            }
1243        }
1244        $csv->set_header($req);
1245
1246        if (!$has_action && !get($this->astate->defaults, "action"))
1247            return $this->error_at($csv->lineno(), "“assignment” column missing");
1248        else if (array_search("paper", $req) === false)
1249            return $this->error_at($csv->lineno(), "“paper” column missing");
1250        else {
1251            if (!isset($this->astate->defaults["action"]))
1252                $this->astate->defaults["action"] = "<missing>";
1253            return true;
1254        }
1255    }
1256
1257    function hide_column($coldesc, $force = false) {
1258        if (!isset($this->unparse_columns[$coldesc]) || $force)
1259            $this->unparse_columns[$coldesc] = false;
1260    }
1261
1262    function show_column($coldesc, $force = false) {
1263        if (!isset($this->unparse_columns[$coldesc]) || $force)
1264            $this->unparse_columns[$coldesc] = true;
1265    }
1266
1267    function parse_csv_comment($line) {
1268        if (preg_match('/\A#\s*hotcrp_assign_display_search\s*(\S.*)\s*\z/', $line, $m))
1269            $this->unparse_search = $m[1];
1270        if (preg_match('/\A#\s*hotcrp_assign_show\s+(\w+)\s*\z/', $line, $m))
1271            $this->show_column($m[1]);
1272    }
1273
1274    private function collect_papers($pfield, &$pids, $report_error) {
1275        $pfield = trim($pfield);
1276        if ($pfield !== "" && preg_match('/\A[\d,\s]+\z/', $pfield)) {
1277            $npids = [];
1278            foreach (preg_split('/[,\s]+/', $pfield) as $pid)
1279                $npids[] = intval($pid);
1280            $val = 2;
1281        } else if ($pfield !== "") {
1282            if (!isset($this->searches[$pfield])) {
1283                $search = new PaperSearch($this->user, ["q" => $pfield, "reviewer" => $this->astate->reviewer]);
1284                $this->searches[$pfield] = $search->paper_ids();
1285                if ($report_error)
1286                    foreach ($search->warnings as $w)
1287                        $this->error_here($w);
1288            }
1289            $npids = $this->searches[$pfield];
1290            $val = 1;
1291        } else {
1292            if ($report_error)
1293                $this->error_here("Bad paper column");
1294            return 0;
1295        }
1296        if (empty($npids) && $report_error)
1297            $this->msg($this->astate->lineno, "No papers match “" . htmlspecialchars($pfield) . "”", 1);
1298
1299        // Implement paper restriction
1300        if ($this->enabled_pids !== null)
1301            $npids = array_intersect($npids, $this->enabled_pids);
1302
1303        foreach ($npids as $pid)
1304            $pids[$pid] = $val;
1305        return $val;
1306    }
1307
1308    private function collect_parser($req) {
1309        if (($action = get($req, "action")) === null
1310            && ($action = get($req, "assignment")) === null
1311            && ($action = get($req, "type")) === null)
1312            $action = $this->astate->defaults["action"];
1313        $action = strtolower(trim($action));
1314        return $this->conf->assignment_parser($action, $this->user);
1315    }
1316
1317    private function expand_special_user($user, AssignmentParser $aparser, PaperInfo $prow, $req) {
1318        global $Now;
1319        if ($user === "any")
1320            $u = $aparser->expand_any_user($prow, $req, $this->astate);
1321        else if ($user === "missing") {
1322            $u = $aparser->expand_missing_user($prow, $req, $this->astate);
1323            if ($u === false || $u === null) {
1324                $this->astate->error("User required.");
1325                return false;
1326            }
1327        } else if (preg_match('/\A(?:new-?)?anonymous/', $user))
1328            $u = $aparser->expand_anonymous_user($prow, $req, $user, $this->astate);
1329        else
1330            $u = false;
1331        if ($u === false || $u === null)
1332            $this->astate->error("User “" . htmlspecialchars($user) . "” is not allowed here.");
1333        return $u;
1334    }
1335
1336    private function apply($aparser, $req) {
1337        // parse paper
1338        $pids = [];
1339        $x = $this->collect_papers((string) get($req, "paper"), $pids, true);
1340        if (empty($pids))
1341            return false;
1342        $pfield_straight = $x == 2;
1343        $pids = array_keys($pids);
1344
1345        // check action
1346        if (!$aparser)
1347            return $this->error_here("Unknown action.");
1348        if ($this->enabled_actions !== null
1349            && !isset($this->enabled_actions[$aparser->type]))
1350            return $this->error_here("Action " . htmlspecialchars($aparser->type) . " disabled.");
1351        $aparser->load_state($this->astate);
1352
1353        // clean user parts
1354        $contacts = $this->lookup_users($req, $aparser);
1355        if ($contacts === false || $contacts === null)
1356            return false;
1357
1358        // maybe filter papers
1359        if (count($pids) > 20
1360            && is_array($contacts)
1361            && count($contacts) == 1
1362            && $contacts[0]->contactId > 0
1363            && ($pf = $aparser->paper_filter($contacts[0], $req, $this->astate))) {
1364            $npids = [];
1365            foreach ($pids as $p)
1366                if (get($pf, $p))
1367                    $npids[] = $p;
1368            $pids = $npids;
1369        }
1370
1371        // fetch papers
1372        $this->astate->fetch_prows($pids);
1373        $this->astate->errors = [];
1374        $this->astate->paper_exact_match = $pfield_straight;
1375
1376        // check conflicts and perform assignment
1377        $any_success = false;
1378        foreach ($pids as $p) {
1379            assert(is_int($p));
1380            $prow = $this->astate->prow($p);
1381            if (!$prow) {
1382                $this->error_here("Submission #$p does not exist.");
1383                continue;
1384            }
1385
1386            $err = $aparser->allow_paper($prow, $this->astate);
1387            if ($err !== true) {
1388                if (is_string($err))
1389                    $this->astate->paper_error($err);
1390                continue;
1391            }
1392
1393            $this->encounter_order[$p] = $p;
1394
1395            // expand “all” and “missing”
1396            $pusers = $contacts;
1397            if (!is_array($pusers)) {
1398                $pusers = $this->expand_special_user($pusers, $aparser, $prow, $req);
1399                if ($pusers === false || $pusers === null)
1400                    break;
1401            }
1402
1403            foreach ($pusers as $contact) {
1404                $err = $aparser->allow_contact($prow, $contact, $req, $this->astate);
1405                if ($err === false) {
1406                    if (!$contact->contactId) {
1407                        $this->astate->error("User “none” is not allowed here. [{$contact->email}]");
1408                        break 2;
1409                    } else if ($prow->has_conflict($contact)) {
1410                        $err = Text::user_html_nolink($contact) . " has a conflict with #$p.";
1411                    } else {
1412                        $err = Text::user_html_nolink($contact) . " cannot be assigned to #$p.";
1413                    }
1414                }
1415                if ($err !== true) {
1416                    if (is_string($err)) {
1417                        $this->astate->paper_error($err);
1418                    }
1419                    continue;
1420                }
1421
1422                $err = $aparser->apply($prow, $contact, $req, $this->astate);
1423                if ($err !== true) {
1424                    if (is_string($err)) {
1425                        $this->astate->error($err);
1426                    }
1427                    continue;
1428                }
1429
1430                $any_success = true;
1431            }
1432        }
1433
1434        foreach ($this->astate->errors as $e) {
1435            $this->msg($this->astate->lineno, $e[0], $e[1] || !$any_success ? 2 : 1);
1436            if ($e[2])
1437                $this->has_user_error = true;
1438        }
1439        return $any_success;
1440    }
1441
1442    function parse($text, $filename = null, $defaults = null, $alertf = null) {
1443        assert(empty($this->assigners));
1444        $this->filename = $filename;
1445        $this->astate->defaults = $defaults ? : array();
1446
1447        if ($text instanceof CsvParser)
1448            $csv = $text;
1449        else {
1450            $csv = new CsvParser($text, CsvParser::TYPE_GUESS);
1451            $csv->set_comment_chars("%#");
1452            $csv->set_comment_function(array($this, "parse_csv_comment"));
1453        }
1454        if (!($req = $csv->header() ? : $csv->next()))
1455            return $this->error_at($csv->lineno(), "empty file");
1456        if (!$this->install_csv_header($csv, $req))
1457            return false;
1458
1459        $old_overrides = $this->user->set_overrides($this->astate->overrides);
1460
1461        // parse file, load papers all at once
1462        $lines = $pids = [];
1463        while (($req = $csv->next()) !== false) {
1464            $aparser = $this->collect_parser($req);
1465            $this->collect_papers((string) get($req, "paper"), $pids, false);
1466            if ($aparser
1467                && ($pfield = $aparser->expand_papers($req, $this->astate)))
1468                $this->collect_papers($pfield, $pids, false);
1469            $lines[] = [$csv->lineno(), $aparser, $req];
1470        }
1471        if (!empty($pids)) {
1472            $this->astate->lineno = $csv->lineno();
1473            $this->astate->fetch_prows(array_keys($pids), true);
1474        }
1475
1476        // now parse assignment
1477        foreach ($lines as $i => $linereq) {
1478            $this->astate->lineno = $linereq[0];
1479            if ($i % 100 == 0) {
1480                if ($alertf)
1481                    call_user_func($alertf, $this, $linereq[0], $linereq[2]);
1482                set_time_limit(30);
1483            }
1484            $this->apply($linereq[1], $linereq[2]);
1485        }
1486        if ($alertf)
1487            call_user_func($alertf, $this, $csv->lineno(), false);
1488
1489        // call finishers
1490        foreach ($this->astate->finishers as $fin)
1491            $fin->apply_finisher($this->astate);
1492
1493        // create assigners for difference
1494        $this->assigners_pidhead = $pidtail = [];
1495        foreach ($this->astate->diff() as $pid => $difflist)
1496            foreach ($difflist as $item) {
1497                try {
1498                    if (($a = $item->realize($this->astate))) {
1499                        if ($a->pid > 0) {
1500                            $index = count($this->assigners);
1501                            if (isset($pidtail[$a->pid]))
1502                                $pidtail[$a->pid]->next_index = $index;
1503                            else
1504                                $this->assigners_pidhead[$a->pid] = $index;
1505                            $pidtail[$a->pid] = $a;
1506                        }
1507                        $this->assigners[] = $a;
1508                    }
1509                } catch (Exception $e) {
1510                    $this->error_at($item->lineno, $e->getMessage());
1511                }
1512            }
1513
1514        $this->user->set_overrides($old_overrides);
1515    }
1516
1517    function assigned_types() {
1518        $types = array();
1519        foreach ($this->assigners as $assigner)
1520            $types[$assigner->type] = true;
1521        ksort($types);
1522        return array_keys($types);
1523    }
1524    function assigned_pids($compress = false) {
1525        $pids = array_keys($this->assigners_pidhead);
1526        sort($pids, SORT_NUMERIC);
1527        if ($compress) {
1528            $xpids = array();
1529            $lpid = $rpid = -1;
1530            foreach ($pids as $pid) {
1531                if ($lpid >= 0 && $pid != $rpid + 1)
1532                    $xpids[] = $lpid == $rpid ? $lpid : "$lpid-$rpid";
1533                if ($lpid < 0 || $pid != $rpid + 1)
1534                    $lpid = $pid;
1535                $rpid = $pid;
1536            }
1537            if ($lpid >= 0)
1538                $xpids[] = $lpid == $rpid ? $lpid : "$lpid-$rpid";
1539            $pids = $xpids;
1540        }
1541        return $pids;
1542    }
1543
1544    function type_description() {
1545        if ($this->assignment_type === null)
1546            foreach ($this->assigners as $assigner) {
1547                $desc = $assigner->unparse_description();
1548                if ($this->assignment_type === null
1549                    || $this->assignment_type === $desc)
1550                    $this->assignment_type = $desc;
1551                else
1552                    $this->assignment_type = "";
1553            }
1554        return $this->assignment_type;
1555    }
1556
1557    function unparse_paper_assignment(PaperInfo $prow) {
1558        $assigners = [];
1559        for ($index = get($this->assigners_pidhead, $prow->paperId);
1560             $index !== null;
1561             $index = $assigner->next_index) {
1562            $assigners[] = $assigner = $this->assigners[$index];
1563            if ($assigner->contact && !isset($assigner->contact->sorter))
1564                Contact::set_sorter($assigner->contact, $this->conf);
1565        }
1566        usort($assigners, function ($assigner1, $assigner2) {
1567            $c1 = $assigner1->contact;
1568            $c2 = $assigner2->contact;
1569            if ($c1 && $c2)
1570                return strnatcasecmp($c1->sorter, $c2->sorter);
1571            else if ($c1 || $c2)
1572                return $c1 ? -1 : 1;
1573            else
1574                return strcmp($c1->type, $c2->type);
1575        });
1576        $t = "";
1577        foreach ($assigners as $assigner) {
1578            if (($text = $assigner->unparse_display($this))) {
1579                $t .= ($t ? ", " : "") . '<span class="nw">' . $text . '</span>';
1580            }
1581        }
1582        if (isset($this->my_conflicts[$prow->paperId])) {
1583            if ($this->my_conflicts[$prow->paperId] !== true)
1584                $t = '<em>Hidden for conflict</em>';
1585            else
1586                $t = PaperList::wrapChairConflict($t);
1587        }
1588        return $t;
1589    }
1590    function echo_unparse_display() {
1591        $this->set_my_conflicts();
1592        $deltarev = new AssignmentCountSet($this->conf);
1593        foreach ($this->assigners as $assigner)
1594            $assigner->account($this, $deltarev);
1595
1596        $query = $this->assigned_pids(true);
1597        if ($this->unparse_search)
1598            $query_order = "(" . $this->unparse_search . ") THEN HEADING:none " . join(" ", $query);
1599        else
1600            $query_order = empty($query) ? "NONE" : join(" ", $query);
1601        foreach ($this->unparse_columns as $k => $v) {
1602            if ($v)
1603                $query_order .= " show:$k";
1604        }
1605        $query_order .= " show:autoassignment";
1606        $search = new PaperSearch($this->user, ["t" => "vis", "q" => $query_order, "reviewer" => $this->astate->reviewer]);
1607        $plist = new PaperList($search);
1608        $plist->add_column("autoassignment", new AutoassignmentPaperColumn($this));
1609        $plist->set_table_id_class("foldpl", "pltable_full");
1610        echo $plist->table_html("reviewers", ["nofooter" => 1]);
1611
1612        if (count(array_intersect_key($deltarev->bypc, $this->conf->pc_members()))) {
1613            $summary = [];
1614            $tagger = new Tagger($this->user);
1615            $nrev = new AssignmentCountSet($this->conf);
1616            $deltarev->rev && $nrev->load_rev();
1617            $deltarev->lead && $nrev->load_lead();
1618            $deltarev->shepherd && $nrev->load_shepherd();
1619            foreach ($this->conf->pc_members() as $p)
1620                if ($deltarev->get($p->contactId)->ass) {
1621                    $t = '<div class="ctelt"><div class="ctelti';
1622                    if (($k = $p->viewable_color_classes($this->user)))
1623                        $t .= ' ' . $k;
1624                    $t .= '"><span class="taghl">' . $this->user->name_html_for($p) . "</span>: "
1625                        . plural($deltarev->get($p->contactId)->ass, "assignment")
1626                        . self::review_count_report($nrev, $deltarev, $p, "After assignment:&nbsp;")
1627                        . "<hr class=\"c\" /></div></div>";
1628                    $summary[] = $t;
1629                }
1630            if (!empty($summary))
1631                echo "<div class=\"g\"></div>\n",
1632                    "<h3>Summary</h3>\n",
1633                    '<div class="pc_ctable">', join("", $summary), "</div>\n";
1634        }
1635    }
1636
1637    function unparse_csv() {
1638        $this->set_my_conflicts();
1639        $acsv = new AssignmentCsv;
1640        foreach ($this->assigners as $assigner)
1641            if (($x = $assigner->unparse_csv($this, $acsv))) {
1642                if (isset($x[0])) {
1643                    foreach ($x as $elt)
1644                        $acsv->add($elt);
1645                } else
1646                    $acsv->add($x);
1647            }
1648        $acsv->header = array_keys($acsv->header);
1649        return $acsv;
1650    }
1651
1652    function prow($pid) {
1653        return $this->astate->prow($pid);
1654    }
1655
1656    function execute($verbose = false) {
1657        global $Now;
1658        if ($this->has_error || empty($this->assigners)) {
1659            if ($verbose && !empty($this->msgs))
1660                $this->report_errors();
1661            else if ($verbose)
1662                $this->conf->warnMsg("Nothing to assign.");
1663            return !$this->has_error; // true means no errors
1664        }
1665
1666        // mark activity now to avoid DB errors later
1667        $this->user->mark_activity();
1668
1669        // create new contacts, collect pids
1670        $locks = array("ContactInfo" => "read", "Paper" => "read", "PaperConflict" => "read");
1671        $this->conf->save_logs(true);
1672        $pids = [];
1673        foreach ($this->assigners as $assigner) {
1674            if (($u = $assigner->contact) && $u->contactId < 0) {
1675                $assigner->contact = $this->astate->register_user($u);
1676                $assigner->cid = $assigner->contact->contactId;
1677            }
1678            $assigner->add_locks($this, $locks);
1679            if ($assigner->pid > 0)
1680                $pids[$assigner->pid] = true;
1681        }
1682
1683        // execute assignments
1684        $tables = array();
1685        foreach ($locks as $t => $type)
1686            $tables[] = "$t $type";
1687        $this->conf->qe("lock tables " . join(", ", $tables));
1688        $this->cleanup_callbacks = $this->cleanup_notify_tracker = [];
1689        $this->qe_stager = null;
1690
1691        foreach ($this->assigners as $assigner)
1692            $assigner->execute($this);
1693
1694        if ($this->qe_stager)
1695            call_user_func($this->qe_stager, null);
1696        $this->conf->qe("unlock tables");
1697        $this->conf->save_logs(false);
1698
1699        // confirmation message
1700        if ($verbose) {
1701            if ($this->conf->setting("pcrev_assigntime") == $Now)
1702                $this->conf->confirmMsg("Assignments saved! You may want to <a href=\"" . hoturl("mail", "template=newpcrev") . "\">send mail about the new assignments</a>.");
1703            else
1704                $this->conf->confirmMsg("Assignments saved!");
1705        }
1706
1707        // clean up
1708        foreach ($this->assigners as $assigner)
1709            $assigner->cleanup($this);
1710        foreach ($this->cleanup_callbacks as $cb)
1711            call_user_func($cb[0], $this, $cb[1]);
1712        if (!empty($this->cleanup_notify_tracker)
1713            && $this->conf->opt("trackerCometSite"))
1714            MeetingTracker::contact_tracker_comet($this->conf, array_keys($this->cleanup_notify_tracker));
1715        if (!empty($pids))
1716            $this->conf->update_autosearch_tags(array_keys($pids));
1717
1718        return true;
1719    }
1720
1721    function stage_qe($query /* ... */) {
1722        $this->stage_qe_apply($query, array_slice(func_get_args(), 1));
1723    }
1724    function stage_qe_apply($query, $args) {
1725        if (!$this->qe_stager)
1726            $this->qe_stager = Dbl::make_multi_qe_stager($this->conf->dblink);
1727        call_user_func($this->qe_stager, $query, $args);
1728    }
1729
1730    function cleanup_callback($name, $func, $arg = null) {
1731        if (!isset($this->cleanup_callbacks[$name]))
1732            $this->cleanup_callbacks[$name] = [$func, null];
1733        if (func_num_args() > 2)
1734            $this->cleanup_callbacks[$name][1][] = $arg;
1735    }
1736    function cleanup_update_rights() {
1737        $this->cleanup_callback("update_rights", "Contact::update_rights");
1738    }
1739    function cleanup_notify_tracker($pid) {
1740        $this->cleanup_notify_tracker[$pid] = true;
1741    }
1742
1743    private static function _review_count_link($count, $word, $pl, $prefix, $pc) {
1744        $word = $pl ? plural($count, $word) : $count . "&nbsp;" . $word;
1745        if ($count == 0)
1746            return $word;
1747        return '<a class="qq" href="' . hoturl("search", "q=" . urlencode("$prefix:$pc->email"))
1748            . '">' . $word . "</a>";
1749    }
1750
1751    private static function _review_count_report_one($ct, $pc) {
1752        $t = self::_review_count_link($ct->rev, "review", true, "re", $pc);
1753        $x = array();
1754        if ($ct->meta != 0)
1755            $x[] = self::_review_count_link($ct->meta, "meta", false, "meta", $pc);
1756        if ($ct->pri != $ct->rev && (!$ct->meta || $ct->meta != $ct->rev))
1757            $x[] = self::_review_count_link($ct->pri, "primary", false, "pri", $pc);
1758        if ($ct->sec != 0 && $ct->sec != $ct->rev && $ct->pri + $ct->sec != $ct->rev)
1759            $x[] = self::_review_count_link($ct->sec, "secondary", false, "sec", $pc);
1760        if (!empty($x))
1761            $t .= " (" . join(", ", $x) . ")";
1762        return $t;
1763    }
1764
1765    static function review_count_report($nrev, $deltarev, $pc, $prefix) {
1766        $data = [];
1767        $ct = $nrev->get($pc->contactId);
1768        $deltarev && ($ct = $ct->add($deltarev->get($pc->contactId)));
1769        if (!$deltarev || $deltarev->rev)
1770            $data[] = self::_review_count_report_one($ct, $pc);
1771        if ($deltarev && $deltarev->lead)
1772            $data[] = self::_review_count_link($ct->lead, "lead", true, "lead", $pc);
1773        if ($deltarev && $deltarev->shepherd)
1774            $data[] = self::_review_count_link($ct->shepherd, "shepherd", true, "shepherd", $pc);
1775        return '<span class="pcrevsum">' . $prefix . join(", ", $data) . "</span>";
1776    }
1777
1778    static function run($contact, $text, $forceShow = null) {
1779        $aset = new AssignmentSet($contact, $forceShow);
1780        $aset->parse($text);
1781        return $aset->execute();
1782    }
1783}
1784
1785
1786class AutoassignmentPaperColumn extends PaperColumn {
1787    private $aset;
1788    function __construct(AssignmentSet $aset) {
1789        parent::__construct($aset->conf, ["name" => "autoassignment", "row" => true, "className" => "pl_autoassignment"]);
1790        $this->aset = $aset;
1791    }
1792    function header(PaperList $pl, $is_text) {
1793        return "Assignment";
1794    }
1795    function content(PaperList $pl, PaperInfo $row) {
1796        return $this->aset->unparse_paper_assignment($row);
1797    }
1798}
1799