1<?php
2// reviewinfo.php -- HotCRP class representing reviews
3// Copyright (c) 2006-2018 Eddie Kohler; see LICENSE.
4
5class ReviewInfo {
6    public $conf;
7    public $paperId;
8    public $reviewId;
9    public $contactId;
10    public $reviewToken;
11    public $reviewType;
12    public $reviewRound;
13    public $requestedBy;
14    //public $timeRequested;
15    //public $timeRequestNotified;
16    public $reviewBlind;
17    public $reviewModified;
18    //public $reviewAuthorModified;
19    public $reviewSubmitted;
20    //public $reviewNotified;
21    //public $reviewAuthorNotified;
22    public $reviewAuthorSeen;
23    public $reviewOrdinal;
24    //public $timeDisplayed;
25    public $timeApprovalRequested;
26    //public $reviewEditVersion;
27    public $reviewNeedsSubmit;
28    // ... scores ...
29    //public $reviewWordCount;
30    //public $reviewFormat;
31
32    static public $text_field_map = [
33        "paperSummary" => "t01", "commentsToAuthor" => "t02",
34        "commentsToPC" => "t03", "commentsToAddress" => "t04",
35        "weaknessOfPaper" => "t05", "strengthOfPaper" => "t06",
36        "textField7" => "t07", "textField8" => "t08"
37    ];
38    static private $new_text_fields = [
39        null, "paperSummary", "commentsToAuthor", "commentsToPC",
40        "commentsToAddress", "weaknessOfPaper", "strengthOfPaper",
41        "textField7", "textField8"
42    ];
43    static private $score_field_map = [
44        "overAllMerit" => "s01", "reviewerQualification" => "s02",
45        "novelty" => "s03", "technicalMerit" => "s04",
46        "interestToCommunity" => "s05", "longevity" => "s06", "grammar" => "s07",
47        "likelyPresentation" => "s08", "suitableForShort" => "s09",
48        "potential" => "s10", "fixability" => "s11"
49    ];
50    static private $new_score_fields = [
51        null, "overAllMerit", "reviewerQualification", "novelty",
52        "technicalMerit", "interestToCommunity", "longevity", "grammar",
53        "likelyPresentation", "suitableForShort", "potential", "fixability"
54    ];
55    const MIN_SFIELD = 12;
56
57    const RATING_GOODMASK = 1;
58    const RATING_BADMASK = 126;
59    // See also script.js:unparse_ratings
60    static public $rating_options = [
61        1 => "good review", 2 => "needs work",
62        4 => "too short", 8 => "too vague", 16 => "too narrow",
63        32 => "not constructive", 64 => "not correct"
64    ];
65    static public $rating_bits = [
66        1 => "good", 2 => "bad", 4 => "short", 8 => "vague",
67        16 => "narrow", 32 => "not-constructive", 64 => "wrong"
68    ];
69
70    static private $type_map = [
71        "meta" => REVIEW_META,
72        "primary" => REVIEW_PRIMARY, "pri" => REVIEW_PRIMARY,
73        "secondary" => REVIEW_SECONDARY, "sec" => REVIEW_SECONDARY,
74        "optional" => REVIEW_PC, "opt" => REVIEW_PC, "pc" => REVIEW_PC,
75        "external" => REVIEW_EXTERNAL, "ext" => REVIEW_EXTERNAL
76    ];
77    static private $type_revmap = [
78        REVIEW_EXTERNAL => "review", REVIEW_PC => "pcreview",
79        REVIEW_SECONDARY => "secondary", REVIEW_PRIMARY => "primary",
80        REVIEW_META => "metareview"
81    ];
82
83    static function parse_type($str) {
84        $str = strtolower($str);
85        if ($str === "review" || $str === "" || $str === "all" || $str === "any")
86            return null;
87        if (str_ends_with($str, "review"))
88            $str = substr($str, 0, -6);
89        return get(self::$type_map, $str, false);
90    }
91    static function unparse_assigner_action($type) {
92        return get(self::$type_revmap, $type, "clearreview");
93    }
94
95    private function merge(Conf $conf) {
96        $this->conf = $conf;
97        foreach (["paperId", "reviewId", "contactId", "reviewType",
98                  "reviewRound", "requestedBy", "reviewBlind",
99                  "reviewOrdinal", "reviewNeedsSubmit"] as $k) {
100            assert($this->$k !== null, "null $k");
101            $this->$k = (int) $this->$k;
102        }
103        foreach (["reviewModified", "reviewSubmitted", "reviewAuthorSeen"] as $k)
104            if (isset($this->$k))
105                $this->$k = (int) $this->$k;
106        if (isset($this->tfields) && ($x = json_decode($this->tfields, true))) {
107            foreach ($x as $k => $v)
108                $this->$k = $v;
109        }
110        if (isset($this->sfields) && ($x = json_decode($this->sfields, true))) {
111            foreach ($x as $k => $v)
112                $this->$k = $v;
113        }
114        if ($conf->sversion < 176) {
115            foreach (self::$text_field_map as $kmain => $kjson)
116                if (isset($this->$kmain) && !isset($this->$kjson))
117                    $this->$kjson = $this->$kmain;
118        }
119    }
120    static function fetch($result, Conf $conf) {
121        $rrow = $result ? $result->fetch_object("ReviewInfo") : null;
122        if ($rrow)
123            $rrow->merge($conf);
124        return $rrow;
125    }
126    static function review_signature_sql() {
127        return "group_concat(r.reviewId, ' ', r.contactId, ' ', r.reviewToken, ' ', r.reviewType, ' ', "
128            . "r.reviewRound, ' ', r.requestedBy, ' ', r.reviewBlind, ' ', r.reviewModified, ' ', "
129            . "coalesce(r.reviewSubmitted,0), ' ', coalesce(r.reviewAuthorSeen,0), ' ', "
130            . "r.reviewOrdinal, ' ', r.timeApprovalRequested, ' ', r.reviewNeedsSubmit order by r.reviewId)";
131    }
132    static function make_signature(PaperInfo $prow, $signature) {
133        $rrow = new ReviewInfo;
134        $rrow->paperId = $prow->paperId;
135        list($rrow->reviewId, $rrow->contactId, $rrow->reviewToken, $rrow->reviewType,
136             $rrow->reviewRound, $rrow->requestedBy, $rrow->reviewBlind, $rrow->reviewModified,
137             $rrow->reviewSubmitted, $rrow->reviewAuthorSeen,
138             $rrow->reviewOrdinal, $rrow->timeApprovalRequested, $rrow->reviewNeedsSubmit)
139            = explode(" ", $signature);
140        $rrow->merge($prow->conf);
141        return $rrow;
142    }
143
144    function round_name() {
145        return $this->reviewRound ? $this->conf->round_name($this->reviewRound) : "";
146    }
147
148    function assign_name($c) {
149        $this->firstName = $c->firstName;
150        $this->lastName = $c->lastName;
151        $this->email = $c->email;
152        $this->sorter = $c->sorter;
153    }
154
155    static function field_info($id, Conf $conf) {
156        $sversion = $conf->sversion;
157        if (strlen($id) === 3 && ctype_digit(substr($id, 1))) {
158            $n = intval(substr($id, 1), 10);
159            $json_storage = $sversion >= 174 ? $id : null;
160            if ($id[0] === "t") {
161                if (isset(self::$new_text_fields[$n]) && $sversion < 175)
162                    return new ReviewFieldInfo($id, $id, false, self::$new_text_fields[$n], $json_storage);
163                else if ($json_storage)
164                    return new ReviewFieldInfo($id, $id, false, null, $json_storage);
165                else
166                    return false;
167            } else if ($id[0] === "s") {
168                if (isset(self::$new_score_fields[$n])) {
169                    $fid = self::$new_score_fields[$n];
170                    return new ReviewFieldInfo($fid, $id, true, $fid, null);
171                } else if ($json_storage)
172                    return new ReviewFieldInfo($id, $id, true, null, $json_storage);
173                else
174                    return false;
175            } else
176                return false;
177        } else if (isset(self::$text_field_map[$id])) {
178            $short_id = self::$text_field_map[$id];
179            $main_storage = $sversion < 175 ? $id : null;
180            $json_storage = $sversion >= 174 ? $short_id : null;
181            return new ReviewFieldInfo($short_id, $short_id, false, $main_storage, $json_storage);
182        } else if (isset(self::$score_field_map[$id])) {
183            $short_id = self::$score_field_map[$id];
184            return new ReviewFieldInfo($id, $short_id, true, $id, null);
185        } else
186            return false;
187    }
188
189    function field_match_pregexes($reg, $field) {
190        $data = $this->$field;
191        $field_deaccent = $field . "_deaccent";
192        if (!isset($this->$field_deaccent)) {
193            if (preg_match('/[\x80-\xFF]/', $data))
194                $this->$field_deaccent = UnicodeHelper::deaccent($data);
195            else
196                $this->$field_deaccent = false;
197        }
198        return Text::match_pregexes($reg, $data, $this->$field_deaccent);
199    }
200
201    function unparse_sfields() {
202        $data = null;
203        foreach (get_object_vars($this) as $k => $v)
204            if (strlen($k) === 3
205                && $k[0] === "s"
206                && (int) $v !== 0
207                && ($n = cvtint(substr($k, 1))) >= self::MIN_SFIELD)
208                $data[$k] = (int) $v;
209        if ($data === null)
210            return null;
211        return json_encode_db($data);
212    }
213    function unparse_tfields() {
214        global $Conf;
215        $data = null;
216        foreach (get_object_vars($this) as $k => $v)
217            if (strlen($k) === 3
218                && $k[0] === "t"
219                && $v !== null
220                && $v !== "")
221                $data[$k] = $v;
222        if ($data === null)
223            return null;
224        $json = json_encode_db($data);
225        if ($json === null)
226            error_log(($Conf ? "{$Conf->dbname}: " : "") . "review #{$this->paperId}/{$this->reviewId}: text fields cannot be converted to JSON");
227        return $json;
228    }
229
230    static function compare($a, $b) {
231        // 1. different papers
232        if ($a->paperId != $b->paperId)
233            return (int) $a->paperId < (int) $b->paperId ? -1 : 1;
234        // 2. different ordinals (both have ordinals)
235        if ($a->reviewOrdinal
236            && $b->reviewOrdinal
237            && $a->reviewOrdinal != $b->reviewOrdinal)
238            return (int) $a->reviewOrdinal < (int) $b->reviewOrdinal ? -1 : 1;
239        // 3. some submitted reviews have no ordinal (ordinal is reserved for
240        //    user-visible reviews)
241        $asub = (int) $a->reviewSubmitted;
242        $bsub = (int) $b->reviewSubmitted;
243        if (($asub > 0) != ($bsub > 0))
244            return $asub > 0 ? -1 : 1;
245        if ($asub !== $bsub)
246            return $asub < $bsub ? -1 : 1;
247        // 4. submission class
248        $asclass = self::submission_class($a);
249        $bsclass = self::submission_class($b);
250        if ($asclass !== $bsclass)
251            return $asclass < $bsclass ? 1 : -1;
252        // 5. reviewer
253        if (isset($a->sorter)
254            && isset($b->sorter)
255            && ($x = strcmp($a->sorter, $b->sorter)) != 0)
256            return $x;
257        // 6. review id
258        if ($a->reviewId != $b->reviewId)
259            return (int) $a->reviewId < (int) $b->reviewId ? -1 : 1;
260        return 0;
261    }
262
263    static function compare_id($a, $b) {
264        if ($a->paperId != $b->paperId)
265            return (int) $a->paperId < (int) $b->paperId ? -1 : 1;
266        if ($a->reviewId != $b->reviewId)
267            return (int) $a->reviewId < (int) $b->reviewId ? -1 : 1;
268        return 0;
269    }
270
271    static function submission_class($rr) {
272        if ($rr->reviewSubmitted > 0)
273            return 5;
274        else if ($rr->reviewType == REVIEW_SECONDARY && $rr->reviewNeedsSubmit <= 0)
275            return 4;
276        else if ($rr->reviewModified > 1 && $rr->timeApprovalRequested > 0)
277            return 3;
278        else if ($rr->reviewModified > 1)
279            return 2;
280        else if ($rr->reviewModified > 0)
281            return 1;
282        else
283            return 0;
284    }
285
286    function ratings() {
287        $ratings = [];
288        if ((string) $this->allRatings !== "") {
289            foreach (explode(",", $this->allRatings) as $rx) {
290                list($cid, $rating) = explode(" ", $rx);
291                $ratings[(int) $cid] = intval($rating);
292            }
293        }
294        return $ratings;
295    }
296
297    function rating_of_user($user) {
298        $cid = is_object($user) ? $user->contactId : $user;
299        $str = ",$cid ";
300        $pos = strpos("," . $this->allRatings, $str);
301        if ($pos !== false)
302            return intval(substr($this->allRatings, $pos + strlen($str) - 1));
303        return null;
304    }
305
306    static function unparse_rating($rating) {
307        if (isset(self::$rating_bits[$rating]))
308            return self::$rating_bits[$rating];
309        else if (!$rating)
310            return "none";
311        else {
312            $a = [];
313            foreach (self::$rating_bits as $k => $v)
314                if ($rating & $k)
315                    $a[] = $v;
316            return join(" ", $a);
317        }
318    }
319
320    static function parse_rating($s) {
321        if (ctype_digit($s)) {
322            $n = intval($s);
323            if ($n >= 0 && $n < 127)
324                return $n ? : null;
325        }
326        $n = 0;
327        foreach (preg_split('/\s+/', $s) as $word) {
328            if (($k = array_search($word, ReviewInfo::$rating_bits)) !== false)
329                $n |= $k;
330            else if ($word !== "" && $word !== "none")
331                return false;
332        }
333        return $n;
334    }
335}
336