1<?php
2// autoassign.php -- HotCRP automatic paper assignment page
3// Copyright (c) 2006-2018 Eddie Kohler; see LICENSE.
4
5require_once("src/initweb.php");
6if (!$Me->is_manager())
7    $Me->escape();
8
9// clean request
10
11// paper selection
12if (!isset($Qreq->q) || trim($Qreq->q) === "(All)")
13    $Qreq->q = "";
14if ($Qreq->post_ok())
15    header("X-Accel-Buffering: no");  // NGINX: do not hold on to file
16
17$tOpt = PaperSearch::manager_search_types($Me);
18if ($Me->privChair && !isset($Qreq->t)
19    && $Qreq->a === "prefconflict"
20    && $Conf->can_pc_see_all_submissions())
21    $Qreq->t = "all";
22if (!isset($Qreq->t) || !isset($tOpt[$Qreq->t])) {
23    reset($tOpt);
24    $Qreq->t = key($tOpt);
25}
26
27// PC selection
28$Qreq->allow_a("pcs", "pap", "p");
29if (isset($Qreq->pcs) && is_string($Qreq->pcs))
30    $Qreq->pcs = preg_split('/\s+/', $Qreq->pcs);
31if (isset($Qreq->pcs) && is_array($Qreq->pcs)) {
32    $pcsel = array();
33    foreach ($Qreq->pcs as $p)
34        if (($p = cvtint($p)) > 0)
35            $pcsel[$p] = 1;
36} else
37    $pcsel = $Conf->pc_members();
38
39if (!isset($Qreq->pctyp)
40    || ($Qreq->pctyp !== "all" && $Qreq->pctyp !== "sel"))
41    $Qreq->pctyp = "all";
42
43// bad pairs
44// load defaults from last autoassignment or save entry to default
45if (!isset($Qreq->badpairs) && !isset($Qreq->assign) && $Qreq->method() !== "POST") {
46    $x = preg_split('/\s+/', $Conf->setting_data("autoassign_badpairs", ""), null, PREG_SPLIT_NO_EMPTY);
47    $pcm = $Conf->pc_members();
48    $bpnum = 1;
49    for ($i = 0; $i < count($x) - 1; $i += 2)
50        if (isset($pcm[$x[$i]]) && isset($pcm[$x[$i+1]])) {
51            $Qreq["bpa$bpnum"] = $pcm[$x[$i]]->email;
52            $Qreq["bpb$bpnum"] = $pcm[$x[$i+1]]->email;
53            ++$bpnum;
54        }
55    if ($Conf->setting("autoassign_badpairs"))
56        $Qreq->badpairs = 1;
57} else if ($Me->privChair && isset($Qreq->assign) && $Qreq->post_ok()) {
58    $x = array();
59    for ($i = 1; isset($Qreq["bpa$i"]); ++$i)
60        if ($Qreq["bpa$i"] && $Qreq["bpb$i"]
61            && ($pca = $Conf->pc_member_by_email($Qreq["bpa$i"]))
62            && ($pcb = $Conf->pc_member_by_email($Qreq["bpb$i"]))) {
63            $x[] = $pca->contactId;
64            $x[] = $pcb->contactId;
65        }
66    if (count($x) || $Conf->setting_data("autoassign_badpairs")
67        || (!isset($Qreq->badpairs) != !$Conf->setting("autoassign_badpairs")))
68        $Conf->q("insert into Settings (name, value, data) values ('autoassign_badpairs', ?, ?) on duplicate key update data=values(data), value=values(value)", isset($Qreq->badpairs) ? 1 : 0, join(" ", $x));
69}
70// set $badpairs array
71$badpairs = array();
72if (isset($Qreq->badpairs))
73    for ($i = 1; isset($Qreq["bpa$i"]); ++$i)
74        if ($Qreq["bpa$i"] && $Qreq["bpb$i"]) {
75            if (!isset($badpairs[$Qreq["bpa$i"]]))
76                $badpairs[$Qreq["bpa$i"]] = array();
77            $badpairs[$Qreq["bpa$i"]][$Qreq["bpb$i"]] = 1;
78        }
79
80// paper selection
81if ((isset($Qreq->prevt) && isset($Qreq->t) && $Qreq->prevt !== $Qreq->t)
82    || (isset($Qreq->prevq) && isset($Qreq->q) && $Qreq->prevq !== $Qreq->q)) {
83    if (isset($Qreq->assign))
84        $Conf->warnMsg("You changed the paper search. Please review the paper list.");
85    unset($Qreq->assign);
86    $Qreq->requery = 1;
87}
88
89if (isset($Qreq->saveassignment))
90    $SSel = SearchSelection::make($Qreq, $Me, $Qreq->submit ? "pap" : "p");
91else {
92    $SSel = new SearchSelection;
93    if (!$Qreq->requery)
94        $SSel = SearchSelection::make($Qreq, $Me);
95    if ($SSel->is_empty()) {
96        $search = new PaperSearch($Me, array("t" => $Qreq->t, "q" => $Qreq->q));
97        $SSel = new SearchSelection($search->paper_ids());
98    }
99}
100$SSel->sort_selection();
101
102// rev_round
103if (($x = $Conf->sanitize_round_name($Qreq->rev_round)) !== false)
104    $Qreq->rev_round = $x;
105
106// score selector
107$scoreselector = array("+overAllMerit" => "", "-overAllMerit" => "");
108foreach ($Conf->all_review_fields() as $f)
109    if ($f->has_options) {
110        $scoreselector["+" . $f->id] = "high $f->name_html scores";
111        $scoreselector["-" . $f->id] = "low $f->name_html scores";
112    }
113if ($scoreselector["+overAllMerit"] === "")
114    unset($scoreselector["+overAllMerit"], $scoreselector["-overAllMerit"]);
115$scoreselector["__break"] = null;
116$scoreselector["x"] = "random submitted reviews";
117$scoreselector["xa"] = "random reviews";
118
119// download proposed assignment
120if (isset($Qreq->saveassignment)
121    && isset($Qreq->download)
122    && isset($Qreq->assignment)) {
123    $assignset = new AssignmentSet($Me, true);
124    $assignset->parse($Qreq->assignment);
125    $x = $assignset->unparse_csv();
126    csv_exit($Conf->make_csvg("assignments")->select($x->header)
127             ->add($x->data)->sort(SORT_NATURAL));
128}
129
130
131$Conf->header("Assignments &nbsp;&#x2215;&nbsp; <strong>Automatic</strong>", "autoassign");
132echo '<div class="psmode">',
133    '<div class="papmodex"><a href="', hoturl("autoassign"), '">Automatic</a></div>',
134    '<div class="papmode"><a href="', hoturl("manualassign"), '">Manual</a></div>',
135    '<div class="papmode"><a href="', hoturl("conflictassign"), '">Conflicts</a></div>',
136    '<div class="papmode"><a href="', hoturl("bulkassign"), '">Bulk update</a></div>',
137    '</div><hr class="c" />';
138
139
140class AutoassignerInterface {
141    private $conf;
142    private $user;
143    private $qreq;
144    private $atype;
145    private $atype_review;
146    private $reviewtype;
147    private $reviewcount;
148    private $reviewround;
149    private $discordertag;
150    private $autoassigner;
151    private $start_at;
152    private $live;
153    public $ok = false;
154    public $errors = [];
155
156    static function current_costs(Conf $conf, $qreq) {
157        $costs = new AutoassignerCosts;
158        if (($x = $conf->opt("autoassignCosts"))
159            && ($x = json_decode($x))
160            && is_object($x))
161            $costs = $x;
162        foreach (get_object_vars($costs) as $k => $v)
163            if ($qreq && isset($qreq["{$k}_cost"])
164                && ($v = cvtint($qreq["{$k}_cost"], null)) !== null)
165                $costs->$k = $v;
166        return $costs;
167    }
168
169    function __construct(Contact $user, Qrequest $qreq) {
170        $this->conf = $user->conf;
171        $this->user = $user;
172        $this->qreq = $qreq;
173
174        $atypes = array("rev" => "r", "revadd" => "r", "revpc" => "r",
175                        "lead" => true, "shepherd" => true,
176                        "prefconflict" => true, "clear" => true,
177                        "discorder" => true, "" => null);
178        $this->atype = $qreq->a;
179        if (!$this->atype || !isset($atypes[$this->atype])) {
180            $this->errors["ass"] = "Malformed request!";
181            $this->atype = "";
182        }
183        $this->atype_review = $atypes[$this->atype] === "r";
184
185        $r = false;
186        if ($this->atype_review) {
187            $r = $qreq[$this->atype . "type"];
188            if ($r != REVIEW_META && $r != REVIEW_PRIMARY
189                && $r != REVIEW_SECONDARY && $r != REVIEW_PC)
190                $this->errors["ass"] = "Malformed request!";
191        } else if ($this->atype === "clear") {
192            $r = $qreq->cleartype;
193            if ($r != REVIEW_META && $r != REVIEW_PRIMARY
194                && $r != REVIEW_SECONDARY && $r != REVIEW_PC
195                && $r !== "conflict"
196                && $r !== "lead" && $r !== "shepherd")
197                $this->errors["a-clear"] = "Malformed request!";
198        }
199        $this->reviewtype = $r;
200
201        if ($this->atype_review) {
202            $this->reviewcount = cvtint($qreq[$this->atype . "ct"], -1);
203            if ($this->reviewcount <= 0)
204                $this->errors[$this->atype . "ct"] = "You must assign at least one review.";
205
206            $this->reviewround = $qreq->rev_round;
207            if ($this->reviewround !== ""
208                && ($err = Conf::round_name_error($this->reviewround)))
209                $this->errors["rev_round"] = $err;
210        }
211
212        if ($this->atype === "discorder") {
213            $tag = trim((string) $qreq->discordertag);
214            $tag = $tag === "" ? "discuss" : $tag;
215            $tagger = new Tagger;
216            if (($tag = $tagger->check($tag, Tagger::NOVALUE)))
217                $this->discordertag = $tag;
218            else
219                $this->errors["discordertag"] = $tagger->error_html;
220        }
221
222        $this->ok = empty($this->errors);
223    }
224
225    function check() {
226        foreach ($this->errors as $etype => $msg) {
227            Conf::msg_error($msg);
228            Ht::error_at($etype);
229        }
230        return $this->ok;
231    }
232
233    private function result_html() {
234        global $SSel, $pcsel;
235        $assignments = $this->autoassigner->assignments();
236        Review_Assigner::$prefinfo = $this->autoassigner->prefinfo;
237        ob_start();
238
239        if (!$assignments) {
240            Conf::msg_warning("Nothing to assign.");
241            return ob_get_clean();
242        }
243
244        $assignset = new AssignmentSet($this->user, true);
245        $assignset->set_search_type($this->qreq->t);
246        $assignset->parse(join("\n", $assignments));
247
248        $atypes = $assignset->assigned_types();
249        $apids = $assignset->assigned_pids(true);
250        $badpairs_inputs = $badpairs_arg = array();
251        for ($i = 1; isset($this->qreq["bpa$i"]); ++$i)
252            if ($this->qreq["bpa$i"] && $this->qreq["bpb$i"]) {
253                array_push($badpairs_inputs, Ht::hidden("bpa$i", $this->qreq["bpa$i"]),
254                           Ht::hidden("bpb$i", $this->qreq["bpb$i"]));
255                $badpairs_arg[] = $this->qreq["bpa$i"] . "-" . $this->qreq["bpb$i"];
256            }
257        echo Ht::form(hoturl_post("autoassign",
258                                  ["saveassignment" => 1,
259                                   "assigntypes" => join(" ", $atypes),
260                                   "assignpids" => join(" ", $apids),
261                                   "xbadpairs" => count($badpairs_arg) ? join(" ", $badpairs_arg) : null,
262                                   "profile" => $this->qreq->profile,
263                                   "XDEBUG_PROFILE" => $this->qreq->XDEBUG_PROFILE,
264                                   "seed" => $this->qreq->seed]));
265
266        $atype = $assignset->type_description();
267        echo "<h3>Proposed " . ($atype ? $atype . " " : "") . "assignment</h3>";
268        Conf::msg_info("Select “Apply changes” if this looks OK.  (You can always alter the assignment afterwards.)  Reviewer preferences, if any, are shown as “P#”.");
269        $assignset->report_errors();
270        $assignset->echo_unparse_display();
271
272        // print preference unhappiness
273        if ($this->qreq->profile && $this->atype_review) {
274            $umap = $this->autoassigner->pc_unhappiness();
275            sort($umap);
276            echo '<p style="font-size:65%">Preference unhappiness: ';
277            $usum = 0;
278            foreach ($umap as $u)
279                $usum += $u;
280            if (count($umap) % 2 == 0)
281                $umedian = ($umap[count($umap) / 2 - 1] + $umap[count($umap) / 2]) / 2;
282            else
283                $umedian = $umap[(count($umap) - 1) / 2];
284            echo 'mean ', sprintf("%.2f", $usum / count($umap)),
285                ', min ', $umap[0],
286                ', 10% ', $umap[(int) (count($umap) * 0.1)],
287                ', 25% ', $umap[(int) (count($umap) * 0.25)],
288                ', median ', $umedian,
289                ', 75% ', $umap[(int) (count($umap) * 0.75)],
290                ', 90% ', $umap[(int) (count($umap) * 0.9)],
291                ', max ', $umap[count($umap) - 1],
292                '<br/>Time: ', sprintf("%.6f", microtime(true) - $this->start_at);
293            foreach ($this->autoassigner->profile as $name => $time)
294                echo ', ', sprintf("%s %.6f", htmlspecialchars($name), $time);
295            echo '</p>';
296        }
297
298        echo '<div class="aab aabig btnp">',
299            Ht::submit("submit", "Apply changes", ["class" => "btn btn-primary"]),
300            Ht::submit("download", "Download assignment file", ["class" => "btn"]),
301            Ht::submit("cancel", "Cancel", ["class" => "btn"]);
302        foreach (array("t", "q", "a", "revtype", "revaddtype", "revpctype", "cleartype", "revct", "revaddct", "revpcct", "pctyp", "balance", "badpairs", "rev_round", "method", "haspap") as $t)
303            if (isset($this->qreq[$t]))
304                echo Ht::hidden($t, $this->qreq[$t]);
305        echo Ht::hidden("pcs", join(" ", array_keys($pcsel))),
306            join("", $badpairs_inputs),
307            Ht::hidden("p", join(" ", $SSel->selection())), "\n";
308
309        // save the assignment
310        echo Ht::hidden("assignment", join("\n", $assignments)), "\n";
311
312        echo "</div></form>";
313        return ob_get_clean();
314    }
315
316    function progress($status) {
317        if ($this->live && microtime(true) - $this->start_at > 1) {
318            $this->live = false;
319            echo "</div>\n", Ht::unstash();
320        }
321        if (!$this->live) {
322            $t = '<h3>Preparing assignment</h3><p><strong>Status:</strong> ' . htmlspecialchars($status);
323            echo Ht::script('$$("propass").innerHTML=' . json_encode_browser($t) . ';'), "\n";
324            flush();
325            while (@ob_end_flush())
326                /* skip */;
327        }
328    }
329
330    function run() {
331        global $SSel, $pcsel, $badpairs;
332        assert($this->ok);
333        session_write_close(); // this might take a long time
334        set_time_limit(240);
335
336        // prepare autoassigner
337        if ($this->qreq->seed && is_numeric($this->qreq->seed))
338            srand((int) $this->qreq->seed);
339        $this->autoassigner = $autoassigner = new Autoassigner($this->conf, $SSel->selection());
340        if ($this->qreq->pctyp === "sel") {
341            $n = $autoassigner->select_pc(array_keys($pcsel));
342            if ($n == 0) {
343                Conf::msg_error("Select one or more PC members to assign.");
344                return null;
345            }
346        }
347        if ($this->qreq->balance === "all")
348            $autoassigner->set_balance(Autoassigner::BALANCE_ALL);
349        foreach ($badpairs as $cid1 => $bp) {
350            foreach ($bp as $cid2 => $x)
351                $autoassigner->avoid_pair_assignment($cid1, $cid2);
352        }
353        if ($this->qreq->method === "random")
354            $autoassigner->set_method(Autoassigner::METHOD_RANDOM);
355        else
356            $autoassigner->set_method(Autoassigner::METHOD_MCMF);
357        if ($this->conf->opt("autoassignReviewGadget") === "expertise")
358            $autoassigner->set_review_gadget(Autoassigner::REVIEW_GADGET_EXPERTISE);
359        // save costs
360        $autoassigner->costs = self::current_costs($this->conf, $this->qreq);
361        $costs_json = json_encode($autoassigner->costs);
362        if ($costs_json !== $this->conf->opt("autoassignCosts")) {
363            if ($costs_json === json_encode(new AutoassignerCosts))
364                $this->conf->save_setting("opt.autoassignCosts", null);
365            else
366                $this->conf->save_setting("opt.autoassignCosts", 1, $costs_json);
367        }
368        $autoassigner->add_progressf([$this, "progress"]);
369        $this->live = true;
370        echo '<div id="propass" class="propass">';
371
372        $this->start_at = microtime(true);
373        if ($this->atype === "prefconflict")
374            $autoassigner->run_prefconflict($this->qreq->t);
375        else if ($this->atype === "clear")
376            $autoassigner->run_clear($this->reviewtype);
377        else if ($this->atype === "lead" || $this->atype === "shepherd")
378            $autoassigner->run_paperpc($this->atype, $this->qreq["{$this->atype}score"]);
379        else if ($this->atype === "revpc")
380            $autoassigner->run_reviews_per_pc($this->reviewtype, $this->reviewround, $this->reviewcount);
381        else if ($this->atype === "revadd")
382            $autoassigner->run_more_reviews($this->reviewtype, $this->reviewround, $this->reviewcount);
383        else if ($this->atype === "rev")
384            $autoassigner->run_ensure_reviews($this->reviewtype, $this->reviewround, $this->reviewcount);
385        else if ($this->atype === "discorder")
386            $autoassigner->run_discussion_order($this->discordertag);
387
388        if ($this->live)
389            echo $this->result_html(), "</div>\n";
390        else {
391            PaperList::$include_stash = false;
392            $result_html = $this->result_html();
393            echo Ht::unstash_script('$$("propass").innerHTML=' . json_encode($result_html)), "\n";
394        }
395        if ($this->autoassigner->assignments()) {
396            $this->conf->footer();
397            exit;
398        }
399    }
400}
401
402if (isset($Qreq->assign) && isset($Qreq->a)
403    && isset($Qreq->pctyp) && $Qreq->post_ok()) {
404    $ai = new AutoassignerInterface($Me, $Qreq);
405    if ($ai->check())
406        $ai->run();
407    ensure_session();
408} else if ($Qreq->saveassignment && $Qreq->submit
409           && isset($Qreq->assignment) && $Qreq->post_ok()) {
410    $assignset = new AssignmentSet($Me, true);
411    $assignset->enable_papers($SSel->selection());
412    $assignset->parse($Qreq->assignment);
413    $assignset->execute(true);
414}
415
416
417function echo_radio_row($name, $value, $text, $extra = null) {
418    global $Qreq;
419    if (($checked = (!isset($Qreq[$name]) || $Qreq[$name] === $value)))
420        $Qreq[$name] = $value;
421    $extra = ($extra ? $extra : array());
422    $extra["id"] = "${name}_$value";
423    $is_open = get($extra, "open");
424    unset($extra["open"]);
425    $k = Ht::control_class("{$name}-{$value}");
426    echo '<tr class="js-radio-focus', $k, '"><td class="nw">',
427        Ht::radio($name, $value, $checked, $extra), "&nbsp;</td><td>";
428    if ($text !== "")
429        echo Ht::label($text, "${name}_$value");
430    if (!$is_open)
431        echo "</td></tr>\n";
432}
433
434function doSelect($name, $opts, $extra = null) {
435    global $Qreq;
436    if (!isset($Qreq[$name]))
437        $Qreq[$name] = key($opts);
438    echo Ht::select($name, $opts, $Qreq[$name], $extra);
439}
440
441function divClass($name, $classes = null) {
442    if (($c = Ht::control_class($name, $classes)))
443        return '<div class="' . $c . '">';
444    else
445        return '<div>';
446}
447
448echo Ht::form(hoturl_post("autoassign", array("profile" => $Qreq->profile, "seed" => $Qreq->seed, "XDEBUG_PROFILE" => $Qreq->XDEBUG_PROFILE)), ["id" => "autoassignform"]),
449    "<div class='helpside'><div class='helpinside'>
450Assignment methods:
451<ul><li><a href='", hoturl("autoassign"), "' class='q'><strong>Automatic</strong></a></li>
452 <li><a href=\"", hoturl("manualassign"), "\">Manual by PC member</a></li>
453 <li><a href=\"", hoturl("assign") . "\">Manual by paper</a></li>
454 <li><a href=\"", hoturl("conflictassign"), "\">Potential conflicts</a></li>
455 <li><a href=\"", hoturl("bulkassign"), "\">Bulk update</a></li>
456</ul>
457<hr class='hr' />
458Types of PC review:
459<dl><dt>" . review_type_icon(REVIEW_PRIMARY) . " Primary</dt><dd>Mandatory review</dd>
460  <dt>" . review_type_icon(REVIEW_SECONDARY) . " Secondary</dt><dd>May be delegated to external reviewers</dd>
461  <dt>" . review_type_icon(REVIEW_PC) . " Optional</dt><dd>May be declined</dd>
462  <dt>" . review_type_icon(REVIEW_META) . " Metareview</dt><dd>Can view all other reviews before completing their own</dd></dl>
463</div></div>\n";
464echo Ht::unstash_script("hiliter_children(\"#autoassignform\")");
465
466// paper selection
467echo divClass("pap"), "<h3>Paper selection</h3>";
468if (!isset($Qreq->q)) // XXX redundant
469    $Qreq->q = join(" ", $SSel->selection());
470echo Ht::entry("q", $Qreq->q,
471               array("id" => "autoassignq", "placeholder" => "(All)",
472                     "size" => 40, "title" => "Enter paper numbers or search terms",
473                     "class" => Ht::control_class("q", "papersearch js-autosubmit"),
474                     "data-autosubmit-type" => "requery")), " &nbsp;in &nbsp;";
475if (count($tOpt) > 1)
476    echo Ht::select("t", $tOpt, $Qreq->t);
477else
478    echo join("", $tOpt);
479echo " &nbsp; ", Ht::submit("requery", "List", ["id" => "requery", "class" => "btn"]);
480if (isset($Qreq->requery) || isset($Qreq->haspap)) {
481    $search = new PaperSearch($Me, array("t" => $Qreq->t, "q" => $Qreq->q,
482                                         "urlbase" => hoturl_site_relative_raw("autoassign")));
483    $plist = new PaperList($search, ["display" => "show:reviewers"]);
484    $plist->set_selection($SSel);
485
486    if ($search->paper_ids())
487        echo "<br /><span class='hint'>Assignments will apply to the selected papers.</span>";
488
489    echo '<div class="g"></div>';
490    echo $plist->table_html("reviewersSel", ["nofooter" => true]),
491        Ht::hidden("prevt", $Qreq->t), Ht::hidden("prevq", $Qreq->q),
492        Ht::hidden("haspap", 1);
493}
494echo "</div>\n";
495
496
497// action
498echo '<div>';
499echo divClass("ass"), "<h3>Action</h3>", "</div>";
500echo '<table>';
501echo_radio_row("a", "rev", "Ensure each selected paper has <i>at least</i>", ["open" => true]);
502echo "&nbsp; ",
503    Ht::entry("revct", get($Qreq, "revct", 1),
504              ["size" => 3, "class" => Ht::control_class("revct", "js-autosubmit")]), "&nbsp; ";
505doSelect("revtype", array(REVIEW_PRIMARY => "primary", REVIEW_SECONDARY => "secondary", REVIEW_PC => "optional", REVIEW_META => "metareview"));
506echo "&nbsp; review(s)</td></tr>\n";
507
508echo_radio_row("a", "revadd", "Assign", ["open" => true]);
509echo "&nbsp; ",
510    Ht::entry("revaddct", get($Qreq, "revaddct", 1),
511              ["size" => 3, "class" => Ht::control_class("revaddct", "js-autosubmit")]),
512    "&nbsp; <i>additional</i>&nbsp; ";
513doSelect("revaddtype", array(REVIEW_PRIMARY => "primary", REVIEW_SECONDARY => "secondary", REVIEW_PC => "optional", REVIEW_META => "metareview"));
514echo "&nbsp; review(s) per selected paper</td></tr>\n";
515
516echo_radio_row("a", "revpc", "Assign each PC member", ["open" => true]);
517echo "&nbsp; ",
518    Ht::entry("revpcct", get($Qreq, "revpcct", 1),
519              ["size" => 3, "class" => Ht::control_class("revpcct", "js-autosubmit")]),
520    "&nbsp; additional&nbsp; ";
521doSelect("revpctype", array(REVIEW_PRIMARY => "primary", REVIEW_SECONDARY => "secondary", REVIEW_PC => "optional", REVIEW_META => "metareview"));
522echo "&nbsp; review(s) from this paper selection</td></tr>\n";
523
524// Review round
525$rev_rounds = $Conf->round_selector_options(null);
526if (count($rev_rounds) > 1 || !get($rev_rounds, "unnamed")) {
527    echo '<tr><td></td><td';
528    if (($c = Ht::control_class("rev_round")))
529        echo ' class="', trim($c), '"';
530    echo ' style="font-size:smaller">Review round: ';
531    if (count($rev_rounds) > 1)
532        echo '&nbsp;', Ht::select("rev_round", $rev_rounds, $Qreq->rev_round ? : "unnamed");
533    else
534        echo $Qreq->rev_round ? : "unnamed";
535    echo "</td></tr>\n";
536}
537
538// gap
539echo '<tr><td colspan="2" class="mg"></td></tr>';
540
541// conflicts, leads, shepherds
542echo_radio_row("a", "prefconflict", "Assign conflicts when PC members have review preferences of &minus;100 or less");
543
544echo_radio_row("a", "lead", "Assign discussion lead from reviewers, preferring&nbsp; ", ["open" => true]);
545doSelect('leadscore', $scoreselector);
546echo "</td></tr>\n";
547
548echo_radio_row("a", "shepherd", "Assign shepherd from reviewers, preferring&nbsp; ", ["open" => true]);
549doSelect('shepherdscore', $scoreselector);
550echo "</td></tr>\n";
551
552// gap
553echo '<tr><td colspan="2" class="mg"></td></tr>';
554
555// clear assignments
556echo_radio_row("a", "clear", "Clear all &nbsp;", ["open" => true]);
557doSelect('cleartype', array(REVIEW_PRIMARY => "primary", REVIEW_SECONDARY => "secondary", REVIEW_PC => "optional", REVIEW_META => "metareview", "conflict" => "conflict", "lead" => "discussion lead", "shepherd" => "shepherd"));
558echo " &nbsp;assignments for selected papers and PC members</td></tr>\n";
559
560// gap
561echo '<tr><td colspan="2" class="mg"></td></tr>';
562
563// discussion order
564echo_radio_row("a", "discorder", "Create discussion order in tag #", ["open" => true]);
565echo Ht::entry("discordertag", get($Qreq, "discordertag", "discuss"),
566               ["size" => 12, "class" => Ht::control_class("discordertag", "js-autosubmit")]),
567    ", grouping papers with similar PC conflicts</td></tr>";
568
569echo "</table>\n";
570
571
572// PC
573echo "<h3>PC members</h3>\n<table>\n";
574
575echo_radio_row("pctyp", "all", "Use entire PC");
576
577echo_radio_row("pctyp", "sel", "Use selected PC members:", ["open" => true]);
578echo " &nbsp; (select ";
579$pctyp_sel = array(array("all", "all"), array("none", "none"));
580$pctags = $Conf->pc_tags();
581if (!empty($pctags)) {
582    $tagsjson = array();
583    foreach ($Conf->pc_members() as $pc)
584        $tagsjson[$pc->contactId] = " " . trim(strtolower($pc->viewable_tags($Me))) . " ";
585    Ht::stash_script("var hotcrp_pc_tags=" . json_encode($tagsjson) . ";");
586    foreach ($pctags as $tagname => $pctag)
587        if ($tagname !== "pc" && $Conf->tags()->strip_nonviewable($tagname, $Me, null))
588            $pctyp_sel[] = [$pctag, "#$pctag"];
589}
590$pctyp_sel[] = array("__flip__", "flip");
591$sep = "";
592foreach ($pctyp_sel as $pctyp) {
593    echo $sep, "<a class=\"ui js-pcsel-tag\" href=\"#pc_", $pctyp[0], "\">", $pctyp[1], "</a>";
594    $sep = ", ";
595}
596echo ")";
597Ht::stash_script('function make_pcsel_members(tag) {
598    if (tag === "__flip__")
599        return function () { return !this.checked; };
600    else if (tag === "all")
601        return function () { return true; };
602    else if (tag === "none")
603        return function () { return false; };
604    else {
605        tag = " " + tag.toLowerCase() + "#";
606        return function () {
607            var tlist = hotcrp_pc_tags[this.value] || "";
608            return tlist.indexOf(tag) >= 0;
609        };
610    }
611}
612function pcsel_tag(event) {
613    var $g = $(this).closest(".js-radio-focus"), e;
614    if (this.tagName === "A") {
615        $g.find("input[type=radio]").first().click();
616        var f = make_pcsel_members(this.hash.substring(4));
617        $g.find("input").each(function () {
618            if (this.name === "pcs[]")
619                this.checked = f.call(this);
620        });
621        event_prevent(event);
622    }
623    var tags = [], functions = {};
624    $g.find("a.js-pcsel-tag").each(function () {
625        var tag = this.hash.substring(4);
626        tags.push(tag);
627        functions[tag] = make_pcsel_members(tag);
628    });
629    $g.find("input").each(function () {
630        if (this.name === "pcs[]") {
631            for (var i = 0; i < tags.length; ) {
632                if (this.checked !== functions[tags[i]].call(this))
633                    tags.splice(i, 1);
634                else
635                    ++i;
636            }
637        }
638    });
639    $g.find("a.js-pcsel-tag").each(function () {
640        if ($.inArray(this.hash.substring(4), tags) >= 0)
641            $(this).css("font-weight", "bold");
642        else
643            $(this).css("font-weight", "inherit");
644    });
645}
646$(document).on("click", "a.js-pcsel-tag", pcsel_tag);
647$(document).on("change", "input.js-pcsel-tag", pcsel_tag);
648$(function(){$("input.js-pcsel-tag").first().trigger("change")})');
649
650$summary = [];
651$tagger = new Tagger($Me);
652$nrev = new AssignmentCountSet($Conf);
653$nrev->load_rev();
654foreach ($Conf->pc_members() as $id => $p) {
655    $t = '<div class="ctelt"><label class="ctelti checki';
656    if (($k = $p->viewable_color_classes($Me)))
657        $t .= ' ' . $k;
658    $t .= '"><span class="checkc">'
659        . Ht::checkbox("pcs[]", $id, isset($pcsel[$id]),
660                       ["id" => "pcc$id", "class" => "uix js-range-click js-pcsel-tag"])
661        . ' </span>'
662        . '<span class="taghl">' . $Me->name_html_for($p) . '</span>'
663        . AssignmentSet::review_count_report($nrev, null, $p, "")
664        . "</label></div>";
665    $summary[] = $t;
666}
667echo '<div class="pc_ctable" style="margin-top:0.5em">', join("", $summary), "</div>\n",
668    "</td></tr></table>\n";
669
670
671// Bad pairs
672function bpSelector($i, $which) {
673    global $Qreq;
674    return Ht::select("bp$which$i", [], 0,
675        ["class" => "need-pcselector badpairs", "data-pcselector-selected" => $Qreq["bp$which$i"], "data-pcselector-options" => "[\"(PC member)\",\"*\"]", "data-default-value" => $Qreq["bp$which$i"]]);
676}
677
678echo "<div class='g'></div><div class='relative'><table id=\"bptable\"><tbody>\n";
679for ($i = 1; $i == 1 || isset($Qreq["bpa$i"]); ++$i) {
680    $selector_text = bpSelector($i, "a") . " &nbsp;and&nbsp; " . bpSelector($i, "b");
681    echo '    <tr><td class="rentry nw">';
682    if ($i == 1)
683        echo Ht::checkbox("badpairs", 1, isset($Qreq["badpairs"]),
684                           array("id" => "badpairs")),
685            "&nbsp;", Ht::label("Don’t assign", "badpairs"), " &nbsp;";
686    else
687        echo "or &nbsp;";
688    echo '</td><td class="lentry">', $selector_text;
689    if ($i == 1)
690        echo ' &nbsp;to the same paper &nbsp;(<a class="ui js-badpairs-row more" href="#">More</a> &nbsp;·&nbsp; <a class="ui js-badpairs-row less" href="#">Fewer</a>)';
691    echo "</td></tr>\n";
692}
693echo "</tbody></table></div>\n";
694$Conf->stash_hotcrp_pc($Me);
695echo Ht::unstash_script('$("#bptable").on("change", "select.badpairs", function () {
696    if (this.value !== "none") {
697        var x = $$("badpairs");
698        x.checked || x.click();
699    }
700});
701$("#bptable a.js-badpairs-row").on("click", function () {
702    var tbody = $("#bptable > tbody"), n = tbody.children().length;
703    if (hasClass(this, "more")) {
704        ++n;
705        tbody.append(\'<tr><td class="rentry nw">or &nbsp;</td><td class="lentry"><select name="bpa\' + n + \'" class="badpairs"></select> &nbsp;and&nbsp; <select name="bpb\' + n + \'" class="badpairs"></select></td></tr>\');
706        var options = tbody.find("select").first().html();
707        tbody.find("select[name=bpa" + n + "], select[name=bpb" + n + "]").html(options).val("none");
708    } else if (n > 1) {
709        --n;
710        tbody.children().last().remove();
711    }
712    return false;
713});
714$(".need-pcselector").each(populate_pcselector)');
715
716
717// Load balancing
718echo "<h3>Load balancing</h3>\n<table>\n";
719echo_radio_row("balance", "new", "New assignments—spread new assignments equally among selected PC members");
720echo_radio_row("balance", "all", "All assignments—spread assignments so that selected PC members have roughly equal overall load");
721echo "</table>\n";
722
723
724// Method
725echo "<h3>Assignment method</h3>\n<table>\n";
726echo_radio_row("method", "mcmf", "Globally optimal assignment");
727echo_radio_row("method", "random", "Random good assignment");
728echo "</table>\n";
729
730if ($Conf->opt("autoassignReviewGadget") === "expertise") {
731    echo "<div><strong>Costs:</strong> ";
732    $costs = AutoassignerInterface::current_costs($Conf, $Qreq);
733    foreach (get_object_vars($costs) as $k => $v)
734        echo '<span style="display:inline-block;margin-right:2em">',
735            Ht::label($k, "{$k}_cost"),
736            "&nbsp;", Ht::entry("{$k}_cost", $v, ["size" => 4]),
737            '</span>';
738    echo "</div>\n";
739}
740
741
742// Create assignment
743echo '<div class="aab aabig">', Ht::submit("assign", "Prepare assignments", ["class" => "btn btn-primary"]),
744    ' &nbsp; <span class="hint">You’ll be able to check the assignment before it is saved.</span>',
745    '</div>';
746
747echo "</div></form>";
748
749$Conf->footer();
750