1<?php
2// src/settings/s_reviewform.php -- HotCRP review form definition page
3// Copyright (c) 2006-2018 Eddie Kohler; see LICENSE.
4
5class ReviewForm_SettingParser extends SettingParser {
6    private $nrfj;
7    private $option_error;
8
9    public static $setting_prefixes = ["shortName_", "description_", "order_", "authorView_", "options_", "option_class_prefix_"];
10
11    private function check_options(SettingValues $sv, $fid, $fj) {
12        $text = cleannl($sv->req["options_$fid"]);
13        $letters = ($text && ord($text[0]) >= 65 && ord($text[0]) <= 90);
14        $expect = ($letters ? "[A-Z]" : "[1-9]");
15
16        $opts = array();
17        $lowonum = 10000;
18        $allow_empty = false;
19
20        foreach (explode("\n", $text) as $line) {
21            $line = trim($line);
22            if ($line != "") {
23                if ((preg_match("/^($expect)\\.\\s*(\\S.*)/", $line, $m)
24                     || preg_match("/^($expect)\\s+(\\S.*)/", $line, $m))
25                    && !isset($opts[$m[1]])) {
26                    $onum = ($letters ? ord($m[1]) : (int) $m[1]);
27                    $lowonum = min($lowonum, $onum);
28                    $opts[$onum] = $m[2];
29                } else if (preg_match('/^(?:0\.\s*)?No entry$/i', $line))
30                    $allow_empty = true;
31                else
32                    return false;
33            }
34        }
35
36        // numeric options must start from 1
37        if (!$letters && count($opts) > 0 && $lowonum != 1)
38            return false;
39
40        $text = "";
41        $seqopts = array();
42        for ($onum = $lowonum; $onum < $lowonum + count($opts); ++$onum) {
43            if (!isset($opts[$onum]))       // options out of order
44                return false;
45            $seqopts[] = $opts[$onum];
46        }
47
48        unset($fj->option_letter, $fj->allow_empty);
49        if ($letters) {
50            $seqopts = array_reverse($seqopts, true);
51            $fj->option_letter = chr($lowonum);
52        }
53        $fj->options = array_values($seqopts);
54        if ($allow_empty)
55            $fj->allow_empty = true;
56        return true;
57    }
58
59    private function populate_field($fj, ReviewField $f, SettingValues $sv, $fid) {
60        $sn = simplify_whitespace(get($sv->req, "shortName_$fid", ""));
61        if ($sn === "<None>" || $sn === "<New field>" || $sn === "Field name")
62            $sn = "";
63
64        if (isset($sv->req["order_$fid"]))
65            $pos = cvtnum(get($sv->req, "order_$fid"));
66        else
67            $pos = get($fj, "position", -1);
68        if ($pos > 0 && $sn == ""
69            && isset($sv->req["description_$fid"])
70            && trim($sv->req["description_$fid"]) === ""
71            && (!$f->has_options
72                || (isset($sv->req["options_$fid"])
73                    ? trim($sv->req["options_$fid"]) === ""
74                    : empty($fj->options))))
75            $pos = -1;
76
77        if ($sn !== "")
78            $fj->name = $sn;
79        else if ($pos > 0)
80            $sv->error_at("shortName_$fid", "Missing review field name.");
81
82        if (isset($sv->req["authorView_$fid"]))
83            $fj->visibility = $sv->req["authorView_$fid"];
84
85        if (isset($sv->req["description_$fid"])) {
86            $x = CleanHTML::basic_clean($sv->req["description_$fid"], $err);
87            if ($x !== false) {
88                $fj->description = trim($x);
89                if ($fj->description === "")
90                    unset($fj->description);
91            } else if ($pos > 0)
92                $sv->error_at("description_$fid", htmlspecialchars($sn) . " description: " . $err);
93        }
94
95        if ($pos > 0)
96            $fj->position = $pos;
97        else
98            unset($fj->position);
99
100        if ($f->has_options) {
101            $ok = true;
102            if (isset($sv->req["options_$fid"]))
103                $ok = $this->check_options($sv, $fid, $fj);
104            if ((!$ok || count($fj->options) < 2) && $pos > 0) {
105                $sv->error_at("options_$fid", htmlspecialchars($sn) . ": Invalid options.");
106                if ($this->option_error)
107                    $sv->error_at(null, $this->option_error);
108                $this->option_error = false;
109            }
110            if (isset($sv->req["option_class_prefix_$fid"])) {
111                $prefixes = ["sv", "svr", "sv-blpu", "sv-publ", "sv-viridis", "sv-viridisr"];
112                $pindex = array_search($sv->req["option_class_prefix_$fid"], $prefixes) ? : 0;
113                if (get($sv->req, "option_class_prefix_flipped_$fid"))
114                    $pindex ^= 1;
115                $fj->option_class_prefix = $prefixes[$pindex];
116            }
117        }
118
119        if (isset($sv->req["round_list_$fid"])) {
120            $fj->round_mask = 0;
121            foreach (explode(" ", trim($sv->req["round_list_$fid"])) as $round_name)
122                if ($round_name !== "")
123                    $fj->round_mask |= 1 << (int) $sv->conf->round_number($round_name, false);
124        }
125    }
126
127    static function requested_fields(SettingValues $sv) {
128        $fs = [];
129        $max_fields = ["s" => "s00", "t" => "t00"];
130        foreach ($sv->conf->review_form()->fmap as $fid => $f) {
131            $fs[$f->short_id] = true;
132            if (strcmp($f->short_id, $max_fields[$f->short_id[0]]) > 0)
133                $max_fields[$f->short_id[0]] = $f->short_id;
134        }
135        for ($i = 1; ; ++$i) {
136            $fid = sprintf("s%02d", $i);
137            if (isset($sv->req["shortName_$fid"]) || isset($sv->req["order_$fid"]))
138                $fs[$fid] = true;
139            else if (strcmp($fid, $max_fields["s"]) > 0)
140                break;
141        }
142        for ($i = 1; ; ++$i) {
143            $fid = sprintf("t%02d", $i);
144            if (isset($sv->req["shortName_$fid"]) || isset($sv->req["order_$fid"]))
145                $fs[$fid] = true;
146            else if (strcmp($fid, $max_fields["t"]) > 0)
147                break;
148        }
149        return $fs;
150    }
151
152    function parse(SettingValues $sv, Si $si) {
153        $this->nrfj = (object) array();
154        $this->option_error = "Review fields with options must have at least two choices, numbered sequentially from 1 (higher numbers are better) or lettered with consecutive uppercase letters (lower letters are better). Example: <pre>1. Low quality
1552. Medium quality
1563. High quality</pre>";
157
158        $rf = $sv->conf->review_form();
159        foreach (self::requested_fields($sv) as $fid => $x) {
160            $finfo = ReviewInfo::field_info($fid, $sv->conf);
161            if (!$finfo) {
162                if (isset($sv->req["order_$fid"]) && $sv->req["order_$fid"] > 0)
163                    $sv->error_at("shortName_$fid", htmlspecialchars($sv->req["shortName_$fid"]) . ": Too many review fields. You must delete some other fields before adding this one.");
164                continue;
165            }
166            if (isset($rf->fmap[$finfo->id]))
167                $f = $rf->fmap[$finfo->id];
168            else
169                $f = new ReviewField($finfo, $sv->conf);
170            $fj = $f->unparse_json(true);
171            if (isset($sv->req["shortName_$fid"])) {
172                $this->populate_field($fj, $f, $sv, $fid);
173                $xf = clone $f;
174                $xf->assign($fj);
175                $fj = $xf->unparse_json(true);
176            }
177            $this->nrfj->{$finfo->id} = $fj;
178        }
179
180        $sv->need_lock["PaperReview"] = true;
181        return true;
182    }
183
184    private function clear_existing_fields($fields, Conf $conf) {
185        // clear fields from main storage
186        $clear_sfields = $clear_tfields = [];
187        foreach ($fields as $f) {
188            if ($f->main_storage) {
189                if ($f->has_options)
190                    $result = $conf->qe("update PaperReview set {$f->main_storage}=0");
191                else
192                    $result = $conf->qe("update PaperReview set {$f->main_storage}=null");
193            }
194            if ($f->json_storage) {
195                if ($f->has_options)
196                    $clear_sfields[] = $f;
197                else
198                    $clear_tfields[] = $f;
199            }
200        }
201        if (!$clear_sfields && !$clear_tfields)
202            return;
203
204        // clear fields from json storage
205        $clearf = Dbl::make_multi_qe_stager($conf->dblink);
206        $result = $conf->qe("select * from PaperReview where sfields is not null or tfields is not null");
207        while (($rrow = ReviewInfo::fetch($result, $conf))) {
208            $cleared = false;
209            foreach ($clear_sfields as $f)
210                if (isset($rrow->{$f->id})) {
211                    unset($rrow->{$f->id}, $rrow->{$f->short_id});
212                    $cleared = true;
213                }
214            if ($cleared)
215                $clearf("update PaperReview set sfields=? where paperId=? and reviewId=?", [$rrow->unparse_sfields(), $rrow->paperId, $rrow->reviewId]);
216            $cleared = false;
217            foreach ($clear_tfields as $f)
218                if (isset($rrow->{$f->id})) {
219                    unset($rrow->{$f->id}, $rrow->{$f->short_id});
220                    $cleared = true;
221                }
222            if ($cleared)
223                $clearf("update PaperReview set tfields=? where paperId=? and reviewId=?", [$rrow->unparse_tfields(), $rrow->paperId, $rrow->reviewId]);
224        }
225        $clearf(null);
226    }
227
228    private function clear_nonexisting_options($fields, Conf $conf) {
229        $updates = [];
230
231        // clear options from main storage
232        $clear_sfields = [];
233        foreach ($fields as $f) {
234            if ($f->main_storage) {
235                $result = $conf->qe("update PaperReview set {$f->main_storage}=0 where {$f->main_storage}>" . count($f->options));
236                if ($result && $result->affected_rows > 0)
237                    $updates[$f->name] = true;
238            }
239            if ($f->json_storage)
240                $clear_sfields[] = $f;
241        }
242
243        if ($clear_sfields) {
244            // clear options from json storage
245            $clearf = Dbl::make_multi_qe_stager($conf->dblink);
246            $result = $conf->qe("select * from PaperReview where sfields is not null");
247            while (($rrow = ReviewInfo::fetch($result, $conf))) {
248                $cleared = false;
249                foreach ($clear_sfields as $f)
250                    if (isset($rrow->{$f->id}) && $rrow->{$f->id} > count($f->options)) {
251                        unset($rrow->{$f->id}, $rrow->{$f->short_id});
252                        $cleared = $updates[$f->name] = true;
253                    }
254                if ($cleared)
255                    $clearf("update PaperReview set sfields=? where paperId=? and reviewId=?", [$rrow->unparse_sfields(), $rrow->paperId, $rrow->reviewId]);
256            }
257            $clearf(null);
258        }
259
260        return array_keys($updates);
261    }
262
263    function save(SettingValues $sv, Si $si) {
264        global $Now;
265        if (!$sv->update("review_form", json_encode_db($this->nrfj)))
266            return;
267        $oform = $sv->conf->review_form();
268        $nform = new ReviewForm($this->nrfj, $sv->conf);
269        $clear_fields = $clear_options = [];
270        $reset_wordcount = $assign_ordinal = false;
271        foreach ($nform->all_fields() as $nf) {
272            $of = get($oform->fmap, $nf->id);
273            if ($nf->displayed && (!$of || !$of->displayed))
274                $clear_fields[] = $nf;
275            else if ($nf->displayed && $nf->has_options
276                     && count($nf->options) < count($of->options))
277                $clear_options[] = $nf;
278            if ($of && $of->include_word_count() != $nf->include_word_count())
279                $reset_wordcount = true;
280            if ($of && $of->displayed && $of->view_score < VIEWSCORE_AUTHORDEC
281                && $nf->displayed && $nf->view_score >= VIEWSCORE_AUTHORDEC)
282                $assign_ordinal = true;
283            foreach (self::$setting_prefixes as $fx)
284                unset($sv->req[$fx . $nf->short_id]);
285        }
286        $sv->conf->invalidate_caches(["rf" => true]);
287        // reset existing review values
288        if (!empty($clear_fields))
289            $this->clear_existing_fields($clear_fields, $sv->conf);
290        // ensure no review has a nonexisting option
291        if (!empty($clear_options)) {
292            $updates = $this->clear_nonexisting_options($clear_options, $sv->conf);
293            if (!empty($updates)) {
294                sort($updates);
295                $sv->warning_at(null, "Your changes invalidated some existing review scores.  The invalid scores have been reset to “Unknown”.  The relevant fields were: " . join(", ", $updates) . ".");
296            }
297        }
298        // reset all word counts if author visibility changed
299        if ($reset_wordcount)
300            $sv->conf->qe("update PaperReview set reviewWordCount=null");
301        // assign review ordinals if necessary
302        if ($assign_ordinal) {
303            $rrows = [];
304            $result = $sv->conf->qe("select * from PaperReview where reviewOrdinal=0 and reviewSubmitted>0");
305            while (($rrow = ReviewInfo::fetch($result, $sv->conf)))
306                $rrows[] = $rrow;
307            Dbl::free($result);
308            $locked = false;
309            foreach ($rrows as $rrow)
310                if ($nform->nonempty_view_score($rrow) >= VIEWSCORE_AUTHORDEC) {
311                    if (!$locked) {
312                        $sv->conf->qe("lock tables PaperReview write");
313                        $locked = true;
314                    }
315                    $max_ordinal = $sv->conf->fetch_ivalue("select coalesce(max(reviewOrdinal), 0) from PaperReview where paperId=? group by paperId", $rrow->paperId);
316                    if ($max_ordinal !== null)
317                        $sv->conf->qe("update PaperReview set reviewOrdinal=?, timeDisplayed=? where paperId=? and reviewId=?", $max_ordinal + 1, $Now, $rrow->paperId, $rrow->reviewId);
318                }
319            if ($locked)
320                $sv->conf->qe("unlock tables");
321        }
322    }
323}
324
325class ReviewForm_SettingRenderer {
326static function render(SettingValues $sv) {
327    global $ConfSitePATH;
328
329    $samples = json_decode(file_get_contents("$ConfSitePATH/etc/reviewformlibrary.json"));
330
331    $rf = $sv->conf->review_form();
332    $req = [];
333    if ($sv->use_req())
334        foreach (array_keys(ReviewForm_SettingParser::requested_fields($sv)) as $fid) {
335            foreach (ReviewForm_SettingParser::$setting_prefixes as $fx)
336                if (isset($sv->req["$fx$fid"]))
337                    $req["$fx$fid"] = $sv->req["$fx$fid"];
338        }
339
340    Ht::stash_html('<div id="review_form_caption_description" class="hidden">'
341      . '<p>Enter an HTML description for the review form.
342Include any guidance you’d like to provide for reviewers.
343Note that complex HTML will not appear on offline review forms.</p></div>'
344      . '<div id="review_form_caption_options" class="hidden">'
345      . '<p>Enter one option per line, numbered starting from 1 (higher numbers
346are better). For example:</p>
347<pre class="entryexample dark">1. Reject
3482. Weak reject
3493. Weak accept
3504. Accept</pre>
351<p>Or use consecutive capital letters (lower letters are better).</p>
352<p>Normally scores are mandatory: a review with a missing score cannot be
353submitted. Add a “<code>No entry</code>” line to make the score optional.</p></div>');
354
355    $rfj = [];
356    foreach ($rf->fmap as $f)
357        $rfj[$f->short_id] = $f->unparse_json();
358
359    // track whether fields have any nonempty values
360    $where = ["false", "false"];
361    foreach ($rf->fmap as $f) {
362        $fj = $rfj[$f->short_id];
363        $fj->internal_id = $f->id;
364        $fj->has_any_nonempty = false;
365        if ($f->json_storage) {
366            if ($f->has_options)
367                $where[0] = "sfields is not null";
368            else
369                $where[1] = "tfields is not null";
370        } else {
371            if ($f->has_options)
372                $where[] = "{$f->main_storage}!=0";
373            else
374                $where[] = "coalesce({$f->main_storage},'')!=''";
375        }
376    }
377
378    $unknown_nonempty = array_values($rfj);
379    $limit = 0;
380    while (!empty($unknown_nonempty)) {
381        $result = $sv->conf->qe("select * from PaperReview where " . join(" or ", $where) . " limit $limit,100");
382        $expect_limit = $limit + 100;
383        while (($rrow = ReviewInfo::fetch($result, $sv->conf))) {
384            for ($i = 0; $i < count($unknown_nonempty); ++$i) {
385                $fj = $unknown_nonempty[$i];
386                $fid = $fj->internal_id;
387                if (isset($rrow->$fid)
388                    && (isset($fj->options) ? (int) $rrow->$fid !== 0 : $rrow->$fid !== "")) {
389                    $fj->has_any_nonempty = true;
390                    array_splice($unknown_nonempty, $i, 1);
391                } else
392                    ++$i;
393            }
394            ++$limit;
395        }
396        Dbl::free($result);
397        if ($limit !== $expect_limit) // ran out of reviews
398            break;
399    }
400
401    // output settings json
402    Ht::stash_script("review_form_settings({"
403        . "fields:" . json_encode_browser($rfj)
404        . ", samples:" . json_encode_browser($samples)
405        . ", errf:" . json_encode_browser($sv->message_field_map())
406        . ", req:" . json_encode_browser($req)
407        . ", stemplate:" . json_encode_browser(ReviewField::make_template(true, $sv->conf))
408        . ", ttemplate:" . json_encode_browser(ReviewField::make_template(false, $sv->conf))
409        . "})");
410
411    echo Ht::hidden("has_review_form", 1),
412        "<div id=\"reviewform_container\"></div>",
413        "<div id=\"reviewform_removedcontainer\"></div>",
414        Ht::button("Add score field", ["class" => "btn settings-add-review-field score"]),
415        "<span class='sep'></span>",
416        Ht::button("Add text field", ["class" => "btn settings-add-review-field"]);
417    Ht::stash_script('$("button.settings-add-review-field").on("click", function () { review_form_settings.add(hasClass(this,"score")?1:0) })');
418}
419}
420