1<?php
2// paperinfo.php -- HotCRP paper objects
3// Copyright (c) 2006-2018 Eddie Kohler; see LICENSE.
4
5class PaperContactInfo {
6    public $paperId;
7    public $contactId;
8    public $conflictType = 0;
9    public $reviewType = 0;
10    public $reviewSubmitted = 0;
11    public $review_status = 0;
12
13    public $rights_forced = null;
14    public $forced_rights_link = null;
15
16    // set by Contact::rights()
17    public $allow_administer;
18    public $can_administer;
19    public $allow_pc_broad;
20    public $allow_pc;
21    public $potential_reviewer;
22    public $allow_review;
23    public $act_author;
24    public $allow_author;
25    public $view_conflict_type;
26    public $act_author_view;
27    public $allow_author_view;
28    public $nonblind;
29
30    public $vsreviews_array;
31    public $vsreviews_version;
32
33    static function make_empty(PaperInfo $prow, $cid) {
34        $ci = new PaperContactInfo;
35        $ci->paperId = $prow->paperId;
36        $ci->contactId = $cid;
37        if ($cid > 0
38            && isset($prow->leadContactId)
39            && $prow->leadContactId == $cid)
40            $ci->review_status = 1;
41        return $ci;
42    }
43
44    static function make_my(PaperInfo $prow, $contact, $object) {
45        $cid = is_object($contact) ? $contact->contactId : $contact;
46        $ci = PaperContactInfo::make_empty($prow, $cid);
47        $ci->conflictType = (int) $object->conflictType;
48        if (property_exists($object, "myReviewPermissions")) {
49            $ci->mark_my_review_permissions($object->myReviewPermissions);
50        } else if ($object instanceof PaperInfo
51                   && property_exists($object, "reviewSignatures")) {
52            $rev_tokens = is_object($contact) ? $contact->review_tokens() : null;
53            foreach ($object->reviews_of_user($cid, $rev_tokens) as $rrow)
54                $ci->mark_review($rrow);
55        }
56        return $ci;
57    }
58
59    private function mark_conflict($ct) {
60        $this->conflictType = max($ct, $this->conflictType);
61    }
62
63    private function mark_review_type($rt, $rs, $rns) {
64        $this->reviewType = max($rt, $this->reviewType);
65        $this->reviewSubmitted = max($rs, $this->reviewSubmitted);
66        if ($rt > 0) {
67            if ($rs > 0 || $rns == 0)
68                $this->review_status = 1;
69            else if ($this->review_status == 0)
70                $this->review_status = -1;
71        }
72    }
73
74    function mark_review(ReviewInfo $rrow) {
75        $this->mark_review_type($rrow->reviewType, (int) $rrow->reviewSubmitted, $rrow->reviewNeedsSubmit);
76    }
77
78    private function mark_my_review_permissions($sig) {
79        if ((string) $sig !== "") {
80            foreach (explode(",", $sig) as $r) {
81                list($rt, $rs, $rns) = explode(" ", $r);
82                $this->mark_review_type((int) $rt, (int) $rs, (int) $rns);
83            }
84        }
85    }
86
87    static function load_into(PaperInfo $prow, $cid, $rev_tokens) {
88        global $Me;
89        $conf = $prow->conf;
90        $pid = $prow->paperId;
91        $q = "select conflictType, reviewType, reviewSubmitted, reviewNeedsSubmit";
92        if ($cid
93            && !$rev_tokens
94            && ($row_set = $prow->_row_set)
95            && $row_set->size() > 1) {
96            $result = $conf->qe("$q, Paper.paperId paperId, ? contactId
97                from Paper
98                left join PaperConflict on (PaperConflict.paperId=Paper.paperId and PaperConflict.contactId=?)
99                left join PaperReview on (PaperReview.paperId=Paper.paperId and PaperReview.contactId=?)
100                where Paper.paperId?a",
101                $cid, $cid, $cid, $row_set->paper_ids());
102            foreach ($row_set->all() as $row)
103                $row->_clear_contact_info($cid);
104            while ($result && ($local = $result->fetch_row())) {
105                $row = $row_set->get($local[4]);
106                $ci = $row->_get_contact_info($local[5]);
107                $ci->mark_conflict((int) $local[0]);
108                $ci->mark_review_type((int) $local[1], (int) $local[2], (int) $local[3]);
109            }
110            Dbl::free($result);
111            return;
112        }
113        if ($cid
114            && !$rev_tokens
115            && (!$Me || ($Me->contactId != $cid
116                         && ($Me->privChair || $Me->contactId == $prow->managerContactId)))
117            && ($pcm = $conf->pc_members())
118            && isset($pcm[$cid])) {
119            $cids = array_keys($pcm);
120            $result = $conf->qe("$q, ContactInfo.contactId
121                from ContactInfo
122                left join PaperConflict on (PaperConflict.paperId=? and PaperConflict.contactId=ContactInfo.contactId)
123                left join PaperReview on (PaperReview.paperId=? and PaperReview.contactId=ContactInfo.contactId)
124                where roles!=0 and (roles&" . Contact::ROLE_PC . ")!=0",
125                $pid, $pid);
126        } else {
127            $cids = [$cid];
128            if ($cid) {
129                $q = "$q, ? contactId
130                from (select ? paperId) P
131                left join PaperConflict on (PaperConflict.paperId=? and PaperConflict.contactId=?)
132                left join PaperReview on (PaperReview.paperId=? and (PaperReview.contactId=?";
133                $qv = [$cid, $pid, $pid, $cid, $pid, $cid];
134                if ($rev_tokens) {
135                    $q .= " or PaperReview.reviewToken?a";
136                    $qv[] = $rev_tokens;
137                }
138                $result = $conf->qe_apply("$q))", $qv);
139            } else
140                $result = null;
141        }
142        foreach ($cids as $cid)
143            $prow->_clear_contact_info($cid);
144        while ($result && ($local = $result->fetch_row())) {
145            $ci = $prow->_get_contact_info($local[4]);
146            $ci->mark_conflict((int) $local[0]);
147            $ci->mark_review_type((int) $local[1], (int) $local[2], (int) $local[3]);
148        }
149        Dbl::free($result);
150    }
151
152    function get_forced_rights() {
153        if (!$this->forced_rights_link) {
154            $ci = $this->forced_rights_link = clone $this;
155            $ci->vsreviews_array = null;
156        }
157        return $this->forced_rights_link;
158    }
159}
160
161class PaperInfo_Conflict {
162    public $contactId;
163    public $conflictType;
164    public $email;
165
166    function __construct($cid, $ctype, $email = null) {
167        $this->contactId = (int) $cid;
168        $this->conflictType = (int) $ctype;
169        $this->email = $email;
170    }
171}
172
173class PaperInfoSet implements IteratorAggregate {
174    private $prows = [];
175    private $by_pid = [];
176    public $loaded_allprefs = 0;
177    function __construct(PaperInfo $prow = null) {
178        if ($prow)
179            $this->add($prow, true);
180    }
181    function add(PaperInfo $prow, $copy = false) {
182        $this->prows[] = $prow;
183        if (!isset($this->by_pid[$prow->paperId]))
184            $this->by_pid[$prow->paperId] = $prow;
185        if (!$copy) {
186            assert(!$prow->_row_set);
187            $prow->_row_set = $this;
188        }
189    }
190    function take_all(PaperInfoSet $set) {
191        foreach ($set->prows as $prow) {
192            $prow->_row_set = null;
193            $this->add($prow);
194        }
195        $set->prows = $set->by_pid = [];
196    }
197    function all() {
198        return $this->prows;
199    }
200    function size() {
201        return count($this->prows);
202    }
203    function is_empty() {
204        return empty($this->prows);
205    }
206    function paper_ids() {
207        return array_keys($this->by_pid);
208    }
209    function get($pid) {
210        return get($this->by_pid, $pid);
211    }
212    function filter($func) {
213        $next_set = new PaperInfoSet;
214        foreach ($this as $prow)
215            if (call_user_func($func, $prow))
216                $next_set->add($prow, true);
217        return $next_set;
218    }
219    function any($func) {
220        foreach ($this as $prow)
221            if (($x = call_user_func($func, $prow)))
222                return $x;
223        return false;
224    }
225    function getIterator() {
226        return new ArrayIterator($this->prows);
227    }
228}
229
230class PaperInfo {
231    public $paperId;
232    public $conf;
233    public $title;
234    public $authorInformation;
235    public $abstract;
236    public $collaborators;
237    public $timeSubmitted;
238    public $timeWithdrawn;
239    public $paperStorageId;
240    public $finalPaperStorageId;
241    public $managerContactId;
242    public $paperFormat;
243    public $outcome;
244    // $paperTags: DO NOT LIST (property_exists() is meaningful)
245    // $optionIds: DO NOT LIST (property_exists() is meaningful)
246    // $allConflictTypes: DO NOT LIST (property_exists() is meaningful)
247    // $reviewSignatures: DO NOT LIST (property_exists() is meaningful)
248
249    private $_unaccented_title;
250    private $_contact_info = [];
251    private $_rights_version = 0;
252    private $_author_array;
253    private $_collaborator_array;
254    private $_prefs_array;
255    private $_prefs_cid;
256    private $_desirability;
257    private $_topics_array;
258    private $_topic_interest_score_array;
259    private $_option_values;
260    private $_option_data;
261    private $_option_array;
262    private $_all_option_array;
263    private $_document_array;
264    private $_conflict_array;
265    private $_conflict_array_email;
266    private $_review_array;
267    private $_review_array_version = 0;
268    private $_reviews_have = [];
269    private $_full_review;
270    private $_full_review_key;
271    private $_comment_array;
272    private $_comment_skeleton_array;
273    private $_potential_conflicts;
274    public $_row_set;
275
276    const SUBMITTED_AT_FOR_WITHDRAWN = 1000000000;
277
278    function __construct($p = null, $contact = null, Conf $conf = null) {
279        $this->merge($p, $contact, $conf);
280    }
281
282    private function merge($p, $contact, $conf) {
283        assert($contact === null ? $conf !== null : $contact instanceof Contact);
284        $this->conf = $contact ? $contact->conf : $conf;
285        if ($p)
286            foreach ($p as $k => $v)
287                $this->$k = $v;
288        $this->paperId = (int) $this->paperId;
289        $this->managerContactId = (int) $this->managerContactId;
290        if ($contact && (property_exists($this, "myReviewPermissions")
291                         || property_exists($this, "reviewSignatures"))) {
292            $this->_rights_version = Contact::$rights_version;
293            $this->load_my_contact_info($contact, $this);
294        } else if ($contact && property_exists($this, "conflictType")) {
295            error_log("conflictType exists but myReviewPermissions does not " . json_encode(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)));
296        }
297        foreach (["paperTags", "optionIds"] as $k)
298            if (property_exists($this, $k) && $this->$k === null)
299                $this->$k = "";
300    }
301
302    static function fetch($result, $contact, Conf $conf = null) {
303        $prow = $result ? $result->fetch_object("PaperInfo", [null, $contact, $conf]) : null;
304        if ($prow && !is_int($prow->paperId))
305            $prow->merge(null, $contact, $conf);
306        return $prow;
307    }
308
309    static function table_name() {
310        return "Paper";
311    }
312
313    static function id_column() {
314        return "paperId";
315    }
316
317    static function comment_table_name() {
318        return "PaperComment";
319    }
320
321    static function my_review_permissions_sql($prefix = "") {
322        return "group_concat({$prefix}reviewType, ' ', coalesce({$prefix}reviewSubmitted,0), ' ', reviewNeedsSubmit)";
323    }
324
325    function make_whynot($rest = []) {
326        return ["fail" => true, "paperId" => $this->paperId, "conf" => $this->conf] + $rest;
327    }
328
329
330    static private function contact_to_cid($contact) {
331        global $Me;
332        assert($contact !== null);
333        if ($contact && is_object($contact))
334            return $contact->contactId;
335        else
336            return $contact !== null ? $contact : $Me->contactId;
337    }
338
339    function _get_contact_info($cid) {
340        return get($this->_contact_info, $cid);
341    }
342
343    function _clear_contact_info($cid) {
344        $this->_contact_info[$cid] = PaperContactInfo::make_empty($this, $cid);
345    }
346
347    private function update_rights_version() {
348        if ($this->_rights_version !== Contact::$rights_version) {
349            if ($this->_rights_version) {
350                $this->_contact_info = $this->_reviews_have = [];
351                $this->_review_array = $this->_conflict_array = null;
352                ++$this->_review_array_version;
353                unset($this->reviewSignatures, $this->allConflictType);
354            }
355            $this->_rights_version = Contact::$rights_version;
356        }
357    }
358
359    function contact_info($contact) {
360        global $Me;
361        $this->update_rights_version();
362        $rev_tokens = null;
363        if (!$contact || !is_object($contact))
364            error_log("PaperInfo::contact_info bad argument: " . json_encode(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)));
365        if (!$contact || is_object($contact)) {
366            $contact = $contact ? : $Me;
367            $rev_tokens = $contact->review_tokens();
368        }
369        $cid = self::contact_to_cid($contact);
370        if (!array_key_exists($cid, $this->_contact_info)) {
371            if ($this->_review_array
372                || property_exists($this, "reviewSignatures")) {
373                $ci = PaperContactInfo::make_empty($this, $cid);
374                if (($c = get($this->conflicts(), $cid)))
375                    $ci->conflictType = $c->conflictType;
376                foreach ($this->reviews_of_user($cid, $rev_tokens) as $rrow)
377                    $ci->mark_review($rrow);
378                $this->_contact_info[$cid] = $ci;
379            } else
380                PaperContactInfo::load_into($this, $cid, $rev_tokens);
381        }
382        return $this->_contact_info[$cid];
383    }
384
385    function replace_contact_info_map($cimap) {
386        $old_cimap = $this->_contact_info;
387        $this->_contact_info = $cimap;
388        $this->_rights_version = Contact::$rights_version;
389        return $old_cimap;
390    }
391
392    function load_my_contact_info($contact, $object) {
393        $ci = PaperContactInfo::make_my($this, $contact, $object);
394        $this->_contact_info[$ci->contactId] = $ci;
395    }
396
397
398    function unaccented_title() {
399        if ($this->_unaccented_title === null)
400            $this->_unaccented_title = UnicodeHelper::deaccent($this->title);
401        return $this->_unaccented_title;
402    }
403
404    function pretty_text_title_indent($width = 75) {
405        $n = "Paper #{$this->paperId}: ";
406        $vistitle = $this->unaccented_title();
407        $l = (int) (($width + 0.5 - strlen($vistitle) - strlen($n)) / 2);
408        return strlen($n) + max(0, $l);
409    }
410
411    function pretty_text_title($width = 75) {
412        $l = $this->pretty_text_title_indent($width);
413        return prefix_word_wrap("Paper #{$this->paperId}: ", $this->title, $l);
414    }
415
416    function format_of($text, $check_simple = false) {
417        return $this->conf->check_format($this->paperFormat, $check_simple ? $text : null);
418    }
419
420    function title_format() {
421        return $this->format_of($this->title, true);
422    }
423
424    function abstract_format() {
425        return $this->format_of($this->abstract, true);
426    }
427
428    function edit_format() {
429        return $this->conf->format_info($this->paperFormat);
430    }
431
432    function author_list() {
433        if (!isset($this->_author_array)) {
434            $this->_author_array = array();
435            foreach (explode("\n", $this->authorInformation) as $line)
436                if ($line != "")
437                    $this->_author_array[] = Author::make_tabbed($line);
438        }
439        return $this->_author_array;
440    }
441
442    function author_by_email($email) {
443        foreach ($this->author_list() as $a)
444            if (strcasecmp($a->email, $email) == 0 && (string) $email !== "")
445                return $a;
446        return null;
447    }
448
449    function parse_author_list() {
450        $ai = "";
451        foreach ($this->_author_array as $au)
452            $ai .= $au->firstName . "\t" . $au->lastName . "\t" . $au->email . "\t" . $au->affiliation . "\n";
453        return ($this->authorInformation = $ai);
454    }
455
456    function pretty_text_author_list() {
457        $info = "";
458        foreach ($this->author_list() as $au) {
459            $info .= $au->name() ? : $au->email;
460            if ($au->affiliation)
461                $info .= " (" . $au->affiliation . ")";
462            $info .= "\n";
463        }
464        return $info;
465    }
466
467    function conflict_type($contact) {
468        $cid = self::contact_to_cid($contact);
469        if (array_key_exists($cid, $this->_contact_info))
470            return $this->_contact_info[$cid]->conflictType;
471        else if (($ci = get($this->conflicts(), $cid)))
472            return $ci->conflictType;
473        else
474            return 0;
475    }
476
477    function has_conflict($contact) {
478        return $this->conflict_type($contact) > 0;
479    }
480
481    function has_author($contact) {
482        return $this->conflict_type($contact) >= CONFLICT_AUTHOR;
483    }
484
485    function collaborator_list() {
486        if ($this->_collaborator_array === null) {
487            $this->_collaborator_array = [];
488            foreach (explode("\n", (string) $this->collaborators) as $co)
489                if (($m = AuthorMatcher::make_collaborator_line($co)))
490                    $this->_collaborator_array[] = $m;
491        }
492        return $this->_collaborator_array;
493    }
494
495    function potential_conflict_callback(Contact $user, $callback) {
496        $nproblems = $auproblems = 0;
497        if ($this->field_match_pregexes($user->aucollab_general_pregexes(), "authorInformation")) {
498            foreach ($this->author_list() as $n => $au)
499                foreach ($user->aucollab_matchers() as $matcher) {
500                    if (($why = $matcher->test($au, $matcher->nonauthor))) {
501                        if (!$callback)
502                            return true;
503                        $auproblems |= $why;
504                        ++$nproblems;
505                        call_user_func($callback, $user, $matcher, $au, $n + 1, $why);
506                    }
507                }
508        }
509        if ((string) $this->collaborators !== "") {
510            $aum = $user->full_matcher();
511            if (Text::match_pregexes($aum->general_pregexes(), $this->collaborators, UnicodeHelper::deaccent($this->collaborators))) {
512                foreach ($this->collaborator_list() as $co)
513                    if (($co->lastName !== ""
514                         || !($auproblems & AuthorMatcher::MATCH_AFFILIATION))
515                        && ($why = $aum->test($co, true))) {
516                        if (!$callback)
517                            return true;
518                        ++$nproblems;
519                        call_user_func($callback, $user, $aum, $co, 0, $why);
520                    }
521            }
522        }
523        return $nproblems > 0;
524    }
525
526    function potential_conflict(Contact $user) {
527        return $this->potential_conflict_callback($user, null);
528    }
529
530    function _potential_conflict_html_callback($user, $matcher, $conflict, $aunum, $why) {
531        if ($aunum) {
532            if ($matcher->nonauthor) {
533                $aumatcher = new AuthorMatcher($conflict);
534                $what = "PC collaborator " . $aumatcher->highlight($matcher) . "<br>matches author #$aunum " . $matcher->highlight($conflict);
535            } else if ($why == AuthorMatcher::MATCH_AFFILIATION)
536                $what = "PC affiliation matches author #$aunum affiliation " . $matcher->highlight($conflict->affiliation);
537            else
538                $what = "PC name matches author #$aunum name " . $matcher->highlight($conflict->name());
539            $this->_potential_conflicts[] = ["#$aunum", '<div class="mmm">' . $what . '</div>'];
540        } else {
541            if ($why == AuthorMatcher::MATCH_AFFILIATION)
542                $what = "PC affiliation matches paper collaborator ";
543            else
544                $what = "PC name matches paper collaborator ";
545            $this->_potential_conflicts[] = ["other conflicts", '<div class="mmm">' . $what . $matcher->highlight($conflict) . '</div>'];
546        }
547    }
548
549    function potential_conflict_html(Contact $user, $highlight = false) {
550        $this->_potential_conflicts = [];
551        if (!$this->potential_conflict_callback($user, [$this, "_potential_conflict_html_callback"]))
552            return false;
553        usort($this->_potential_conflicts, function ($a, $b) { return strnatcmp($a[0], $b[0]); });
554        $authors = array_unique(array_map(function ($x) { return $x[0]; }, $this->_potential_conflicts));
555        $authors = array_filter($authors, function ($f) { return $f !== "other conflicts"; });
556        $messages = join("", array_map(function ($x) { return $x[1]; }, $this->_potential_conflicts));
557        $this->_potential_conflicts = null;
558        return ['<div class="pcconfmatch'
559            . ($highlight ? " pcconfmatch-highlight" : "")
560            . '">Possible conflict'
561            . (empty($authors) ? "" : " with " . pluralx($authors, "author") . " " . numrangejoin($authors))
562            . '…</div>', $messages];
563    }
564
565    function field_match_pregexes($reg, $field) {
566        $data = $this->$field;
567        $field_deaccent = $field . "_deaccent";
568        if (!isset($this->$field_deaccent)) {
569            if (preg_match('/[\x80-\xFF]/', $data))
570                $this->$field_deaccent = UnicodeHelper::deaccent($data);
571            else
572                $this->$field_deaccent = false;
573        }
574        return Text::match_pregexes($reg, $data, $this->$field_deaccent);
575    }
576
577    function submitted_at() {
578        if ($this->timeSubmitted > 0)
579            return (int) $this->timeSubmitted;
580        if ($this->timeWithdrawn > 0) {
581            if ($this->timeSubmitted == -100)
582                return self::SUBMITTED_AT_FOR_WITHDRAWN;
583            if ($this->timeSubmitted < -100)
584                return -(int) $this->timeSubmitted;
585        }
586        return 0;
587    }
588
589    function can_author_view_decision() {
590        return $this->conf->can_all_author_view_decision();
591    }
592
593    function review_type($contact) {
594        $this->update_rights_version();
595        $cid = self::contact_to_cid($contact);
596        if (array_key_exists($cid, $this->_contact_info))
597            $rrow = $this->_contact_info[$cid];
598        else
599            $rrow = $this->review_of_user($cid);
600        return $rrow ? $rrow->reviewType : 0;
601    }
602
603    function has_reviewer($contact) {
604        return $this->review_type($contact) > 0;
605    }
606
607    function review_not_incomplete($contact) {
608        $ci = $this->contact_info($contact);
609        return $ci && $ci->review_status > 0;
610    }
611
612    function review_submitted($contact) {
613        $ci = $this->contact_info($contact);
614        return $ci && $ci->reviewType > 0 && $ci->reviewSubmitted > 0;
615    }
616
617    function pc_can_become_reviewer() {
618        if (!$this->conf->check_track_review_sensitivity())
619            return $this->conf->pc_members();
620        else {
621            $pcm = array();
622            foreach ($this->conf->pc_members() as $cid => $pc)
623                if ($pc->can_become_reviewer_ignore_conflict($this))
624                    $pcm[$cid] = $pc;
625            return $pcm;
626        }
627    }
628
629    function load_tags() {
630        $result = $this->conf->qe("select group_concat(' ', tag, '#', tagIndex order by tag separator '') from PaperTag where paperId=? group by paperId", $this->paperId);
631        $this->paperTags = "";
632        if (($row = edb_row($result)) && $row[0] !== null)
633            $this->paperTags = $row[0];
634        Dbl::free($result);
635    }
636
637    function has_tag($tag) {
638        if (!property_exists($this, "paperTags"))
639            $this->load_tags();
640        return $this->paperTags !== ""
641            && stripos($this->paperTags, " $tag#") !== false;
642    }
643
644    function has_any_tag($tags) {
645        if (!property_exists($this, "paperTags"))
646            $this->load_tags();
647        foreach ($tags as $tag)
648            if (stripos($this->paperTags, " $tag#") !== false)
649                return true;
650        return false;
651    }
652
653    function has_viewable_tag($tag, Contact $user) {
654        $tags = $this->viewable_tags($user);
655        return $tags !== "" && stripos(" " . $tags, " $tag#") !== false;
656    }
657
658    function tag_value($tag) {
659        if (!property_exists($this, "paperTags"))
660            $this->load_tags();
661        if ($this->paperTags !== ""
662            && ($pos = stripos($this->paperTags, " $tag#")) !== false)
663            return (float) substr($this->paperTags, $pos + strlen($tag) + 2);
664        else
665            return false;
666    }
667
668    function all_tags_text() {
669        if (!property_exists($this, "paperTags"))
670            $this->load_tags();
671        return $this->paperTags;
672    }
673
674    function searchable_tags(Contact $user) {
675        if ($user->allow_administer($this))
676            return $this->all_tags_text();
677        else
678            return $this->viewable_tags($user);
679    }
680
681    function viewable_tags(Contact $user) {
682        // see also Contact::can_view_tag()
683        $tags = "";
684        if ($user->isPC)
685            $tags = (string) $this->all_tags_text();
686        if ($tags !== "") {
687            $dt = $this->conf->tags();
688            if ($user->can_view_most_tags($this))
689                $tags = $dt->strip_nonviewable($tags, $user, $this);
690            else if ($dt->has_sitewide && $user->can_view_tags($this))
691                $tags = Tagger::strip_nonsitewide($tags, $user);
692            else
693                $tags = "";
694        }
695        return $tags;
696    }
697
698    function editable_tags(Contact $user) {
699        $tags = $this->all_tags_text();
700        if ($tags !== "") {
701            $old_overrides = $user->add_overrides(Contact::OVERRIDE_CONFLICT);
702            $tags = $this->viewable_tags($user);
703            if ($tags !== "") {
704                $etags = [];
705                foreach (explode(" ", $tags) as $tag)
706                    if ($tag !== "" && $user->can_change_tag($this, $tag, 0, 1))
707                        $etags[] = $tag;
708                $tags = join(" ", $etags);
709            }
710            $user->set_overrides($old_overrides);
711        }
712        return $tags;
713    }
714
715    function add_tag_info_json($pj, Contact $user) {
716        $tagger = new Tagger($user);
717        if (($can_override = $user->can_meaningfully_override($this)))
718            $overrides = $user->add_overrides(Contact::OVERRIDE_CONFLICT);
719        $editable = $this->editable_tags($user);
720        $viewable = $this->viewable_tags($user);
721        $pj->tags = TagInfo::split($viewable);
722        $pj->tags_edit_text = $tagger->unparse($editable);
723        $pj->tags_view_html = $tagger->unparse_and_link($viewable);
724        if (($decor = $tagger->unparse_decoration_html($viewable)))
725            $pj->tag_decoration_html = $decor;
726        $tagmap = $this->conf->tags();
727        $pj->color_classes = $tagmap->color_classes($viewable);
728        if ($can_override && $viewable) {
729            $user->remove_overrides(Contact::OVERRIDE_CONFLICT);
730            $viewable_c = $this->viewable_tags($user);
731            if ($viewable_c !== $viewable) {
732                $pj->tags_conflicted = TagInfo::split($viewable_c);
733                if ($decor
734                    && ($decor_c = $tagger->unparse_decoration_html($viewable_c)) !== $decor)
735                    $pj->tag_decoration_html_conflicted = $decor_c;
736                if ($pj->color_classes
737                    && ($cc_c = $tagmap->color_classes($viewable_c)) !== $pj->color_classes)
738                    $pj->color_classes_conflicted = $cc_c;
739            }
740        }
741        if ($can_override)
742            $user->set_overrides($overrides);
743    }
744
745    private function load_topics() {
746        $row_set = $this->_row_set ? : new PaperInfoSet($this);
747        foreach ($row_set as $prow)
748            $prow->topicIds = null;
749        if ($this->conf->has_topics()) {
750            $result = $this->conf->qe("select paperId, group_concat(topicId) from PaperTopic where paperId?a group by paperId", $row_set->paper_ids());
751            while ($result && ($row = $result->fetch_row())) {
752                $prow = $row_set->get($row[0]);
753                $prow->topicIds = (string) $row[1];
754            }
755            Dbl::free($result);
756        }
757    }
758
759    function has_topics() {
760        if (!property_exists($this, "topicIds"))
761            $this->load_topics();
762        return $this->topicIds !== null && $this->topicIds !== "";
763    }
764
765    function topic_list() {
766        if ($this->_topics_array === null) {
767            if (!property_exists($this, "topicIds"))
768                $this->load_topics();
769            $this->_topics_array = [];
770            if ($this->topicIds !== null && $this->topicIds !== "") {
771                foreach (explode(",", $this->topicIds) as $t)
772                    $this->_topics_array[] = (int) $t;
773                $tomap = $this->conf->topic_order_map();
774                usort($this->_topics_array, function ($a, $b) use ($tomap) {
775                    return $tomap[$a] - $tomap[$b];
776                });
777            }
778        }
779        return $this->_topics_array;
780    }
781
782    function topic_map() {
783        return array_fill_keys($this->topic_list(), true);
784    }
785
786    function named_topic_map() {
787        $t = [];
788        foreach ($this->topic_list() as $tid) {
789            if (empty($t))
790                $tmap = $this->conf->topic_map();
791            $t[$tid] = $tmap[$tid];
792        }
793        return $t;
794    }
795
796    function unparse_topics_text() {
797        return join("; ", $this->named_topic_map());
798    }
799
800    private static function render_topic($tname, $i, &$long) {
801        $s = '<span class="topicsp topic' . ($i ? : 0);
802        if (strlen($tname) <= 50)
803            $s .= ' nw';
804        else
805            $long = true;
806        return $s . '">' . htmlspecialchars($tname) . '</span>';
807    }
808
809    static function unparse_topic_list_html(Conf $conf, $ti) {
810        if (!$ti)
811            return "";
812        $out = array();
813        $tmap = $conf->topic_map();
814        $tomap = $conf->topic_order_map();
815        $long = false;
816        foreach ($ti as $t => $i)
817            $out[$tomap[$t]] = self::render_topic($tmap[$t], $i, $long);
818        ksort($out);
819        return join($conf->topic_separator(), $out);
820    }
821
822    private static $topic_interest_values = [-0.7071, -0.5, 0, 0.7071, 1];
823
824    function topic_interest_score($contact) {
825        $score = 0;
826        if (is_int($contact))
827            $contact = get($this->conf->pc_members(), $contact);
828        if ($contact) {
829            if ($this->_topic_interest_score_array === null)
830                $this->_topic_interest_score_array = array();
831            if (isset($this->_topic_interest_score_array[$contact->contactId]))
832                $score = $this->_topic_interest_score_array[$contact->contactId];
833            else {
834                $interests = $contact->topic_interest_map();
835                $topics = $this->topic_list();
836                foreach ($topics as $t)
837                    if (($j = get($interests, $t, 0))) {
838                        if ($j >= -2 && $j <= 2)
839                            $score += self::$topic_interest_values[$j + 2];
840                        else if ($j > 2)
841                            $score += sqrt($j / 2);
842                        else
843                            $score += -sqrt(-$j / 4);
844                    }
845                if ($score)
846                    // * Strong interest in the paper's single topic gets
847                    //   score 10.
848                    $score = (int) ($score / sqrt(count($topics)) * 10 + 0.5);
849                $this->_topic_interest_score_array[$contact->contactId] = $score;
850            }
851        }
852        return $score;
853    }
854
855
856    function load_conflicts($email) {
857        if (!$email && isset($this->allConflictType)) {
858            $this->_conflict_array = [];
859            $this->_conflict_array_email = $email;
860            foreach (explode(",", $this->allConflictType) as $x) {
861                list($cid, $ctype) = explode(" ", $x);
862                $cflt = new PaperInfo_Conflict($cid, $ctype);
863                $this->_conflict_array[$cflt->contactId] = $cflt;
864            }
865        } else {
866            $row_set = $this->_row_set ? : new PaperInfoSet($this);
867            foreach ($row_set->all() as $prow) {
868                $prow->_conflict_array = [];
869                $prow->_conflict_array_email = $email;
870            }
871            if ($email)
872                $result = $this->conf->qe("select paperId, PaperConflict.contactId, conflictType, email from PaperConflict join ContactInfo using (contactId) where paperId?a", $row_set->paper_ids());
873            else
874                $result = $this->conf->qe("select paperId, contactId, conflictType, null from PaperConflict where paperId?a", $row_set->paper_ids());
875            while ($result && ($row = $result->fetch_row())) {
876                $prow = $row_set->get($row[0]);
877                $cflt = new PaperInfo_Conflict($row[1], $row[2], $row[3]);
878                $prow->_conflict_array[$cflt->contactId] = $cflt;
879            }
880            Dbl::free($result);
881        }
882    }
883
884    function conflicts($email = false) {
885        if ($this->_conflict_array === null
886            || ($email && !$this->_conflict_array_email))
887            $this->load_conflicts($email);
888        return $this->_conflict_array;
889    }
890
891    function pc_conflicts($email = false) {
892        return array_intersect_key($this->conflicts($email), $this->conf->pc_members());
893    }
894
895    function contacts($email = false) {
896        $c = array();
897        foreach ($this->conflicts($email) as $id => $cflt)
898            if ($cflt->conflictType >= CONFLICT_AUTHOR)
899                $c[$id] = $cflt;
900        return $c;
901    }
902
903    function named_contacts() {
904        $vals = Dbl::fetch_objects($this->conf->qe("select ContactInfo.contactId, conflictType, email, firstName, lastName, affiliation from PaperConflict join ContactInfo using (contactId) where paperId=$this->paperId and conflictType>=" . CONFLICT_AUTHOR));
905        foreach ($vals as $v) {
906            $v->contactId = (int) $v->contactId;
907            $v->conflictType = (int) $v->conflictType;
908        }
909        return $vals;
910    }
911
912    function load_reviewer_preferences() {
913        if ($this->_row_set && ++$this->_row_set->loaded_allprefs >= 10)
914            $row_set = $this->_row_set->filter(function ($prow) {
915                return !property_exists($prow, "allReviewerPreference");
916            });
917        else
918            $row_set = new PaperInfoSet($this);
919        foreach ($row_set as $prow) {
920            $prow->allReviewerPreference = null;
921            $prow->_prefs_array = $prow->_prefs_cid = $prow->_desirability = null;
922        }
923        $result = $this->conf->qe("select paperId, " . $this->conf->query_all_reviewer_preference() . " from PaperReviewPreference where paperId?a group by paperId", $row_set->paper_ids());
924        while ($result && ($row = $result->fetch_row())) {
925            $prow = $row_set->get($row[0]);
926            $prow->allReviewerPreference = $row[1];
927        }
928        Dbl::free($result);
929    }
930
931    function reviewer_preferences() {
932        if (!property_exists($this, "allReviewerPreference"))
933            $this->load_reviewer_preferences();
934        if ($this->_prefs_array === null) {
935            $x = array();
936            if ($this->allReviewerPreference !== null && $this->allReviewerPreference !== "") {
937                $p = preg_split('/[ ,]/', $this->allReviewerPreference);
938                for ($i = 0; $i + 2 < count($p); $i += 3) {
939                    if ($p[$i+1] != "0" || $p[$i+2] != ".")
940                        $x[(int) $p[$i]] = array((int) $p[$i+1], $p[$i+2] == "." ? null : (int) $p[$i+2]);
941                }
942            }
943            $this->_prefs_array = $x;
944        }
945        return $this->_prefs_array;
946    }
947
948    function reviewer_preference($contact, $include_topic_score = false) {
949        $cid = is_int($contact) ? $contact : $contact->contactId;
950        if ($this->_prefs_cid === null && $this->_prefs_array === null) {
951            $row_set = $this->_row_set ? : new PaperInfoSet($this);
952            foreach ($row_set as $prow)
953                $prow->_prefs_cid = [$cid, null];
954            $result = $this->conf->qe("select paperId, preference, expertise from PaperReviewPreference where paperId?a and contactId=?", $row_set->paper_ids(), $cid);
955            while ($result && ($row = $result->fetch_row())) {
956                $prow = $row_set->get($row[0]);
957                $prow->_prefs_cid[1] = [(int) $row[1], $row[2] === null ? null : (int) $row[2]];
958            }
959            Dbl::free($result);
960        }
961        if ($this->_prefs_cid !== null && $this->_prefs_cid[0] == $cid)
962            $pref = $this->_prefs_cid[1];
963        else
964            $pref = get($this->reviewer_preferences(), $cid);
965        $pref = $pref ? : [0, null];
966        if ($include_topic_score)
967            $pref[] = $this->topic_interest_score($contact);
968        return $pref;
969    }
970
971    function desirability() {
972        if ($this->_desirability === null) {
973            $prefs = $this->reviewer_preferences();
974            $this->_desirability = 0;
975            foreach ($prefs as $pf) {
976                if ($pf[0] > 0)
977                    $this->_desirability += 1;
978                else if ($pf[0] > -100 && $pf[0] < 0)
979                    $this->_desirability -= 1;
980            }
981        }
982        return $this->_desirability;
983    }
984
985    private function load_options($only_me, $need_data) {
986        if ($this->_option_values === null
987            && isset($this->optionIds)
988            && (!$need_data || $this->optionIds === "")) {
989            if ($this->optionIds === "")
990                $this->_option_values = $this->_option_data = [];
991            else {
992                $this->_option_values = [];
993                preg_match_all('/(\d+)#(-?\d+)/', $this->optionIds, $m);
994                for ($i = 0; $i < count($m[1]); ++$i)
995                    $this->_option_values[(int) $m[1][$i]][] = (int) $m[2][$i];
996            }
997        } else if ($this->_option_values === null
998                   || ($need_data && $this->_option_data === null)) {
999            $old_row_set = $this->_row_set;
1000            if ($only_me)
1001                $this->_row_set = null;
1002            $row_set = $this->_row_set ? : new PaperInfoSet($this);
1003            foreach ($row_set->all() as $prow)
1004                $prow->_option_values = $prow->_option_data = [];
1005            $result = $this->conf->qe("select paperId, optionId, value, data, dataOverflow from PaperOption where paperId?a order by paperId", $row_set->paper_ids());
1006            while ($result && ($row = $result->fetch_row())) {
1007                $prow = $row_set->get((int) $row[0]);
1008                $prow->_option_values[(int) $row[1]][] = (int) $row[2];
1009                $prow->_option_data[(int) $row[1]][] = $row[3] !== null ? $row[3] : $row[4];
1010            }
1011            Dbl::free($result);
1012            if ($only_me)
1013                $this->_row_set = $old_row_set;
1014        }
1015    }
1016
1017    private function _make_option_array($all) {
1018        $this->load_options(false, false);
1019        $paper_opts = $this->conf->paper_opts;
1020        $option_array = [];
1021        foreach ($this->_option_values as $oid => $ovalues)
1022            if (($o = $paper_opts->get($oid, $all)))
1023                $option_array[$oid] = new PaperOptionValue($this, $o, $ovalues, get($this->_option_data, $oid));
1024        uasort($option_array, function ($a, $b) {
1025            if ($a->option && $b->option)
1026                return PaperOption::compare($a->option, $b->option);
1027            else if ($a->option || $b->option)
1028                return $a->option ? -1 : 1;
1029            else
1030                return $a->id - $b->id;
1031        });
1032        return $option_array;
1033    }
1034
1035    function option_value_data($id) {
1036        if ($this->_option_data === null)
1037            $this->load_options(false, true);
1038        return [get($this->_option_values, $id, []),
1039                get($this->_option_data, $id, [])];
1040    }
1041
1042    function options() {
1043        if ($this->_option_array === null)
1044            $this->_option_array = $this->_make_option_array(false);
1045        return $this->_option_array;
1046    }
1047
1048    function option($id) {
1049        return get($this->options(), $id);
1050    }
1051
1052    function force_option($id) {
1053        if (($oa = get($this->options(), $id)))
1054            return $oa;
1055        else if (($opt = $this->conf->paper_opts->get($id)))
1056            return new PaperOptionValue($this, $opt);
1057        else
1058            return null;
1059    }
1060
1061    function all_options() {
1062        if ($this->_all_option_array === null)
1063            $this->_all_option_array = $this->_make_option_array(true);
1064        return $this->_all_option_array;
1065    }
1066
1067    function all_option($id) {
1068        return get($this->all_options(), $id);
1069    }
1070
1071    function invalidate_options($reload = false) {
1072        unset($this->optionIds);
1073        $this->_option_array = $this->_all_option_array =
1074            $this->_option_values = $this->_option_data = null;
1075        if ($reload)
1076            $this->load_options(true, true);
1077    }
1078
1079    private function _document_sql() {
1080        return "paperId, paperStorageId, timestamp, mimetype, sha1, documentType, filename, infoJson, size, filterType, originalStorageId, inactive";
1081    }
1082
1083    function document($dtype, $did = 0, $full = false) {
1084        if ($did <= 0) {
1085            if ($dtype == DTYPE_SUBMISSION)
1086                $did = $this->paperStorageId;
1087            else if ($dtype == DTYPE_FINAL)
1088                $did = $this->finalPaperStorageId;
1089            else if (($oa = $this->force_option($dtype))
1090                     && $oa->option->is_document())
1091                return $oa->document(0);
1092        }
1093
1094        if ($did <= 1)
1095            return null;
1096
1097        if ($this->_document_array !== null
1098            && array_key_exists($did, $this->_document_array))
1099            return $this->_document_array[$did];
1100
1101        if ((($dtype == DTYPE_SUBMISSION
1102              && $did == $this->paperStorageId
1103              && $this->finalPaperStorageId <= 0)
1104             || ($dtype == DTYPE_FINAL
1105                 && $did == $this->finalPaperStorageId))
1106            && !$full) {
1107            $infoJson = get($this, $dtype == DTYPE_SUBMISSION ? "paper_infoJson" : "final_infoJson");
1108            return new DocumentInfo(["paperStorageId" => $did, "paperId" => $this->paperId, "documentType" => $dtype, "timestamp" => get($this, "timestamp"), "mimetype" => $this->mimetype, "sha1" => $this->sha1, "size" => get($this, "size"), "infoJson" => $infoJson, "is_partial" => true], $this->conf, $this);
1109        }
1110
1111        if ($this->_document_array === null) {
1112            $result = $this->conf->qe("select " . $this->_document_sql() . " from PaperStorage where paperId=? and inactive=0", $this->paperId);
1113            $this->_document_array = [];
1114            while (($di = DocumentInfo::fetch($result, $this->conf, $this)))
1115                $this->_document_array[$di->paperStorageId] = $di;
1116            Dbl::free($result);
1117        }
1118        if (!array_key_exists($did, $this->_document_array)) {
1119            $result = $this->conf->qe("select " . $this->_document_sql() . " from PaperStorage where paperStorageId=?", $did);
1120            $this->_document_array[$did] = DocumentInfo::fetch($result, $this->conf, $this);
1121            Dbl::free($result);
1122        }
1123        return $this->_document_array[$did];
1124    }
1125    function joindoc() {
1126        return $this->document($this->finalPaperStorageId > 0 ? DTYPE_FINAL : DTYPE_SUBMISSION);
1127    }
1128    function is_joindoc(DocumentInfo $doc) {
1129        return $doc->paperStorageId > 1
1130            && (($doc->paperStorageId == $this->paperStorageId
1131                 && $this->finalPaperStorageId <= 0
1132                 && $doc->documentType == DTYPE_SUBMISSION)
1133                || ($doc->paperStorageId == $this->finalPaperStorageId
1134                    && $doc->documentType == DTYPE_FINAL));
1135    }
1136    function documents($dtype) {
1137        if ($dtype <= 0) {
1138            $doc = $this->document($dtype, 0, true);
1139            return $doc ? [$doc] : [];
1140        } else if (($oa = $this->option($dtype)) && $oa->has_document())
1141            return $oa->documents();
1142        else
1143            return [];
1144    }
1145    function mark_inactive_documents() {
1146        $dids = [];
1147        if ($this->paperStorageId > 1)
1148            $dids[] = $this->paperStorageId;
1149        if ($this->finalPaperStorageId > 1)
1150            $dids[] = $this->finalPaperStorageId;
1151        foreach ($this->options() as $oa)
1152            if ($oa->option->has_document())
1153                $dids = array_merge($dids, $oa->unsorted_values());
1154        $this->conf->qe("update PaperStorage set inactive=1 where paperId=? and documentType>=? and paperStorageId?A", $this->paperId, DTYPE_FINAL, $dids);
1155    }
1156
1157    function attachment($dtype, $name) {
1158        $oa = $this->option($dtype);
1159        return $oa ? $oa->attachment($name) : null;
1160    }
1161
1162    function npages() {
1163        $doc = $this->document($this->finalPaperStorageId <= 0 ? DTYPE_SUBMISSION : DTYPE_FINAL);
1164        return $doc ? $doc->npages() : 0;
1165    }
1166
1167    private function ratings_query() {
1168        if ($this->conf->setting("rev_ratings") != REV_RATINGS_NONE)
1169            return "(select group_concat(contactId, ' ', rating) from ReviewRating where paperId=PaperReview.paperId and reviewId=PaperReview.reviewId)";
1170        else
1171            return "''";
1172    }
1173
1174    function load_reviews($always = false) {
1175        ++$this->_review_array_version;
1176
1177        if (property_exists($this, "reviewSignatures")
1178            && $this->_review_array === null
1179            && !$always) {
1180            $this->_review_array = $this->_reviews_have = [];
1181            if ((string) $this->reviewSignatures !== "")
1182                foreach (explode(",", $this->reviewSignatures) as $rs) {
1183                    $rrow = ReviewInfo::make_signature($this, $rs);
1184                    $this->_review_array[$rrow->reviewId] = $rrow;
1185                }
1186            return;
1187        }
1188
1189        if ($this->_row_set && ($this->_review_array === null || $always))
1190            $row_set = $this->_row_set;
1191        else
1192            $row_set = new PaperInfoSet($this);
1193        $had = [];
1194        foreach ($row_set as $prow) {
1195            $prow->_review_array = [];
1196            $had += $prow->_reviews_have;
1197            $prow->_reviews_have = ["full" => true];
1198        }
1199
1200        $result = $this->conf->qe("select PaperReview.*, " . $this->ratings_query() . " allRatings from PaperReview where paperId?a order by paperId, reviewId", $row_set->paper_ids());
1201        while (($rrow = ReviewInfo::fetch($result, $this->conf))) {
1202            $prow = $row_set->get($rrow->paperId);
1203            $prow->_review_array[$rrow->reviewId] = $rrow;
1204        }
1205        Dbl::free($result);
1206
1207        $this->ensure_reviewer_names_set($row_set);
1208        if (get($had, "lastLogin"))
1209            $this->ensure_reviewer_last_login_set($row_set);
1210    }
1211
1212    private function parse_textual_id($textid) {
1213        if (ctype_digit($textid))
1214            return intval($textid);
1215        if (str_starts_with($textid, (string) $this->paperId))
1216            $textid = (string) substr($textid, strlen($this->paperId));
1217        if ($textid !== "" && ctype_upper($textid)
1218            && ($n = parseReviewOrdinal($textid)) > 0)
1219            return -$n;
1220        return false;
1221    }
1222
1223    function reviews_by_id() {
1224        if ($this->_review_array === null)
1225            $this->load_reviews();
1226        return $this->_review_array;
1227    }
1228
1229    function reviews_by_id_order() {
1230        return array_values($this->reviews_by_id());
1231    }
1232
1233    function reviews_by_display() {
1234        $rrows = $this->reviews_by_id();
1235        uasort($rrows, "ReviewInfo::compare");
1236        return $rrows;
1237    }
1238
1239    function review_of_id($id) {
1240        return get($this->reviews_by_id(), $id);
1241    }
1242
1243    function review_of_user($contact) {
1244        $cid = self::contact_to_cid($contact);
1245        foreach ($this->reviews_by_id() as $rrow)
1246            if ($rrow->contactId == $cid)
1247                return $rrow;
1248        return null;
1249    }
1250
1251    function reviews_of_user($contact, $rev_tokens = null) {
1252        $cid = self::contact_to_cid($contact);
1253        $rrows = [];
1254        foreach ($this->reviews_by_id() as $rrow)
1255            if ($rrow->contactId == $cid
1256                || ($rev_tokens
1257                    && $rrow->reviewToken
1258                    && in_array($rrow->reviewToken, $rev_tokens)))
1259                $rrows[] = $rrow;
1260        return $rrows;
1261    }
1262
1263    function review_of_ordinal($ordinal) {
1264        foreach ($this->reviews_by_id() as $rrow)
1265            if ($rrow->reviewOrdinal == $ordinal)
1266                return $rrow;
1267        return null;
1268    }
1269
1270    function review_of_token($token) {
1271        if (!is_int($token))
1272            $token = decode_token($token, "V");
1273        foreach ($this->reviews_by_id() as $rrow)
1274            if ($rrow->reviewToken == $token)
1275                return $rrow;
1276        return null;
1277    }
1278
1279    function review_of_textual_id($textid) {
1280        if (($n = $this->parse_textual_id($textid)) === false)
1281            return false;
1282        else if ($n < 0)
1283            return $this->review_of_ordinal(-$n);
1284        else
1285            return $this->review_of_id($n);
1286    }
1287
1288    private function ensure_full_review_name() {
1289        if (($rrows = $this->_full_review)) {
1290            foreach (is_array($rrows) ? $rrows : [$rrows] as $rrow)
1291                if (($u = $this->conf->cached_user_by_id($rrow->contactId)))
1292                    $rrow->assign_name($u);
1293        }
1294    }
1295
1296    function full_review_of_id($id) {
1297        if ($this->_full_review_key === null
1298            && !isset($this->_reviews_have["full"])) {
1299            $this->_full_review_key = "r$id";
1300            $result = $this->conf->qe("select PaperReview.*, " . $this->ratings_query() . " allRatings from PaperReview where paperId=? and reviewId=?", $this->paperId, $id);
1301            $this->_full_review = ReviewInfo::fetch($result, $this->conf);
1302            Dbl::free($result);
1303            $this->ensure_full_review_name();
1304        }
1305        if ($this->_full_review_key === "r$id")
1306            return $this->_full_review;
1307        $this->ensure_full_reviews();
1308        return $this->review_of_id($id);
1309    }
1310
1311    function full_reviews_of_user($contact) {
1312        $cid = self::contact_to_cid($contact);
1313        if ($this->_full_review_key === null
1314            && !isset($this->_reviews_have["full"])) {
1315            $row_set = $this->_row_set ? : new PaperInfoSet($this);
1316            foreach ($row_set as $prow) {
1317                $prow->_full_review = [];
1318                $prow->_full_review_key = "u$cid";
1319            }
1320            $result = $this->conf->qe("select PaperReview.*, " . $this->ratings_query() . " allRatings from PaperReview where paperId?a and contactId=? order by paperId, reviewId", $row_set->paper_ids(), $cid);
1321            while (($rrow = ReviewInfo::fetch($result, $this->conf))) {
1322                $prow = $row_set->get($rrow->paperId);
1323                $prow->_full_review[] = $rrow;
1324            }
1325            Dbl::free($result);
1326            $this->ensure_full_review_name();
1327        }
1328        if ($this->_full_review_key === "u$cid")
1329            return $this->_full_review;
1330        $this->ensure_full_reviews();
1331        return $this->reviews_of_user($contact);
1332    }
1333
1334    function full_review_of_ordinal($ordinal) {
1335        if ($this->_full_review_key === null
1336            && !isset($this->_reviews_have["full"])) {
1337            $this->_full_review_key = "o$ordinal";
1338            $result = $this->conf->qe("select PaperReview.*, " . $this->ratings_query() . " allRatings from PaperReview where paperId=? and reviewOrdinal=?", $this->paperId, $ordinal);
1339            $this->_full_review = ReviewInfo::fetch($result, $this->conf);
1340            Dbl::free($result);
1341            $this->ensure_full_review_name();
1342        }
1343        if ($this->_full_review_key === "o$ordinal")
1344            return $this->_full_review;
1345        $this->ensure_full_reviews();
1346        return $this->review_of_ordinal($ordinal);
1347    }
1348
1349    function full_review_of_textual_id($textid) {
1350        if (($n = $this->parse_textual_id($textid)) === false)
1351            return false;
1352        else if ($n < 0)
1353            return $this->full_review_of_ordinal(-$n);
1354        else
1355            return $this->full_review_of_id($n);
1356    }
1357
1358    private function fresh_review_of($key, $value) {
1359        $result = $this->conf->qe("select PaperReview.*, " . $this->ratings_query() . " allRatings, ContactInfo.firstName, ContactInfo.lastName, ContactInfo.email from PaperReview join ContactInfo using (contactId) where paperId=? and $key=? order by paperId, reviewId", $this->paperId, $value);
1360        $rrow = ReviewInfo::fetch($result, $this->conf);
1361        Dbl::free($result);
1362        return $rrow;
1363    }
1364
1365    function fresh_review_of_id($id) {
1366        return $this->fresh_review_of("reviewId", $id);
1367    }
1368
1369    function fresh_review_of_user($contact) {
1370        return $this->fresh_review_of("contactId", self::contact_to_cid($contact));
1371    }
1372
1373    function viewable_submitted_reviews_by_display(Contact $contact) {
1374        $cinfo = $contact->__rights($this, null);
1375        if ($cinfo->vsreviews_array === null
1376            || $cinfo->vsreviews_version !== $this->_review_array_version) {
1377            $cinfo->vsreviews_array = [];
1378            foreach ($this->reviews_by_display() as $id => $rrow) {
1379                if ($rrow->reviewSubmitted > 0
1380                    && $contact->can_view_review($this, $rrow))
1381                    $cinfo->vsreviews_array[$id] = $rrow;
1382            }
1383            $cinfo->vsreviews_version = $this->_review_array_version;
1384        }
1385        return $cinfo->vsreviews_array;
1386    }
1387
1388    function viewable_submitted_reviews_by_user(Contact $contact) {
1389        $rrows = [];
1390        foreach ($this->viewable_submitted_reviews_by_display($contact) as $rrow)
1391            $rrows[$rrow->contactId] = $rrow;
1392        return $rrows;
1393    }
1394
1395    function can_view_review_identity_of($cid, Contact $contact) {
1396        if ($contact->can_administer_for_track($this, Track::VIEWREVID)
1397            || $cid == $contact->contactId)
1398            return true;
1399        foreach ($this->reviews_of_user($cid) as $rrow)
1400            if ($contact->can_view_review_identity($this, $rrow))
1401                return true;
1402        return false;
1403    }
1404
1405    function may_have_viewable_scores($field, Contact $contact) {
1406        $field = is_object($field) ? $field : $this->conf->review_field($field);
1407        return $contact->can_view_review($this, $field->view_score)
1408            || $this->review_type($contact);
1409    }
1410
1411    function ensure_reviews() {
1412        if ($this->_review_array === null)
1413            $this->load_reviews();
1414    }
1415
1416    function ensure_full_reviews() {
1417        if (!isset($this->_reviews_have["full"]))
1418            $this->load_reviews(true);
1419    }
1420
1421    private function ensure_reviewer_names_set($row_set) {
1422        $missing = [];
1423        foreach ($row_set as $prow) {
1424            $prow->_reviews_have["names"] = true;
1425            foreach ($prow->reviews_by_id() as $rrow)
1426                if (($u = $this->conf->cached_user_by_id($rrow->contactId, true)))
1427                    $rrow->assign_name($u);
1428                else
1429                    $missing[] = $rrow;
1430        }
1431        if ($this->conf->load_missing_cached_users()) {
1432            foreach ($missing as $rrow)
1433                if (($u = $this->conf->cached_user_by_id($rrow->contactId, true)))
1434                    $rrow->assign_name($u);
1435        }
1436    }
1437
1438    function ensure_reviewer_names() {
1439        $this->ensure_reviews();
1440        if (!empty($this->_review_array)
1441            && !isset($this->_reviews_have["names"]))
1442            $this->ensure_reviewer_names_set($this->_row_set ? : new PaperInfoSet($this));
1443    }
1444
1445    private function ensure_reviewer_last_login_set($row_set) {
1446        $users = [];
1447        foreach ($row_set as $prow) {
1448            $prow->_reviews_have["lastLogin"] = true;
1449            foreach ($prow->reviews_by_id() as $rrow)
1450                $users[$rrow->contactId] = true;
1451        }
1452        if (!empty($users)) {
1453            $result = $this->conf->qe("select contactId, lastLogin from ContactInfo where contactId?a", array_keys($users));
1454            $users = Dbl::fetch_iimap($result);
1455            foreach ($row_set as $prow) {
1456                foreach ($prow->reviews_by_id() as $rrow)
1457                    $rrow->reviewLastLogin = $users[$rrow->contactId];
1458            }
1459        }
1460    }
1461
1462    function ensure_reviewer_last_login() {
1463        $this->ensure_reviews();
1464        if (!empty($this->_review_array)
1465            && !isset($this->_reviews_have["lastLogin"]))
1466            $this->ensure_reviewer_last_login_set($this->_row_set ? : new PaperInfoSet($this));
1467    }
1468
1469    private function load_review_fields($fid, $maybe_null = false) {
1470        $k = $fid . "Signature";
1471        $row_set = $this->_row_set ? : new PaperInfoSet($this);
1472        foreach ($row_set as $prow)
1473            $prow->$k = "";
1474        $select = $maybe_null ? "coalesce($fid,'.')" : $fid;
1475        $result = $this->conf->qe("select paperId, group_concat($select order by reviewId) from PaperReview where paperId?a group by paperId", $row_set->paper_ids());
1476        while ($result && ($row = $result->fetch_row())) {
1477            $prow = $row_set->get($row[0]);
1478            $prow->$k = $row[1];
1479        }
1480        Dbl::free($result);
1481    }
1482
1483    function ensure_review_score($field) {
1484        $fid = is_object($field) ? $field->id : $field;
1485        if (!isset($this->_reviews_have[$fid])
1486            && !isset($this->_reviews_have["full"])) {
1487            $rfi = is_object($field) ? $field : ReviewInfo::field_info($fid, $this->conf);
1488            if (!$rfi)
1489                $this->_reviews_have[$fid] = false;
1490            else if (!$rfi->main_storage)
1491                $this->ensure_full_reviews();
1492            else {
1493                $this->_reviews_have[$fid] = true;
1494                $k = $rfi->main_storage . "Signature";
1495                if (!property_exists($this, $k))
1496                    $this->load_review_fields($rfi->main_storage);
1497                $x = explode(",", $this->$k);
1498                foreach ($this->reviews_by_id_order() as $i => $rrow)
1499                    $rrow->$fid = (int) $x[$i];
1500            }
1501        }
1502    }
1503
1504    private function _update_review_word_counts($rids) {
1505        $rf = $this->conf->review_form();
1506        $result = $this->conf->qe("select * from PaperReview where paperId=$this->paperId and reviewId?a", $rids);
1507        $qs = [];
1508        while (($rrow = ReviewInfo::fetch($result, $this->conf))) {
1509            if ($rrow->reviewWordCount === null) {
1510                $rrow->reviewWordCount = $rf->word_count($rrow);
1511                $qs[] = "update PaperReview set reviewWordCount={$rrow->reviewWordCount} where paperId={$this->paperId} and reviewId={$rrow->reviewId}";
1512            }
1513            $my_rrow = get($this->_review_array, $rrow->reviewId);
1514            $my_rrow->reviewWordCount = (int) $rrow->reviewWordCount;
1515        }
1516        Dbl::free($result);
1517        if (!empty($qs)) {
1518            $mresult = Dbl::multi_qe($this->conf->dblink, join(";", $qs));
1519            $mresult->free_all();
1520        }
1521    }
1522
1523    function ensure_review_word_counts() {
1524        if (!isset($this->_reviews_have["reviewWordCount"])) {
1525            $this->_reviews_have["reviewWordCount"] = true;
1526            if (!property_exists($this, "reviewWordCountSignature"))
1527                $this->load_review_fields("reviewWordCount", true);
1528            $x = explode(",", $this->reviewWordCountSignature);
1529            $bad_ids = [];
1530
1531            foreach ($this->reviews_by_id_order() as $i => $rrow)
1532                if ($x[$i] !== ".")
1533                    $rrow->reviewWordCount = (int) $x[$i];
1534                else
1535                    $bad_ids[] = $rrow->reviewId;
1536            if (!empty($bad_ids))
1537                $this->_update_review_word_counts($bad_ids);
1538        }
1539    }
1540
1541    static function fetch_comment_query() {
1542        return "select PaperComment.*,
1543            firstName reviewFirstName, lastName reviewLastName, email reviewEmail
1544            from PaperComment
1545            join ContactInfo on (ContactInfo.contactId=PaperComment.contactId)";
1546    }
1547
1548    function fetch_comments($extra_where = null) {
1549        $result = $this->conf->qe(self::fetch_comment_query()
1550            . " where paperId={$this->paperId}" . ($extra_where ? " and $extra_where" : "")
1551            . " order by paperId, commentId");
1552        $comments = array();
1553        while (($c = CommentInfo::fetch($result, $this, $this->conf)))
1554            $comments[$c->commentId] = $c;
1555        Dbl::free($result);
1556        return $comments;
1557    }
1558
1559    function load_comments() {
1560        $row_set = $this->_row_set ? : new PaperInfoSet($this);
1561        foreach ($row_set as $prow)
1562            $prow->_comment_array = [];
1563        $result = $this->conf->qe(self::fetch_comment_query()
1564            . " where paperId?a order by paperId, commentId", $row_set->paper_ids());
1565        $comments = [];
1566        while (($c = CommentInfo::fetch($result, null, $this->conf))) {
1567            $prow = $row_set->get($c->paperId);
1568            $c->set_prow($prow);
1569            $prow->_comment_array[$c->commentId] = $c;
1570        }
1571        Dbl::free($result);
1572    }
1573
1574    function all_comments() {
1575        if ($this->_comment_array === null)
1576            $this->load_comments();
1577        return $this->_comment_array;
1578    }
1579
1580    function viewable_comments(Contact $user) {
1581        $crows = [];
1582        foreach ($this->all_comments() as $cid => $crow)
1583            if ($user->can_view_comment($this, $crow))
1584                $crows[$cid] = $crow;
1585        return $crows;
1586    }
1587
1588    function all_comment_skeletons() {
1589        if ($this->_comment_skeleton_array !== null)
1590            return $this->_comment_skeleton_array;
1591        if ($this->_comment_array !== null
1592            || !property_exists($this, "commentSkeletonInfo"))
1593            return $this->all_comments();
1594        $this->_comment_skeleton_array = [];
1595        preg_match_all('/(\d+);(\d+);(\d+);(\d+);([^|]*)/', $this->commentSkeletonInfo, $ms, PREG_SET_ORDER);
1596        foreach ($ms as $m) {
1597            $c = new CommentInfo((object) [
1598                    "commentId" => $m[1], "contactId" => $m[2],
1599                    "commentType" => $m[3], "commentRound" => $m[4],
1600                    "commentTags" => $m[5]
1601                ], $this, $this->conf);
1602            $this->_comment_skeleton_array[$c->commentId] = $c;
1603        }
1604        return $this->_comment_skeleton_array;
1605    }
1606
1607    function viewable_comment_skeletons(Contact $user) {
1608        $crows = [];
1609        foreach ($this->all_comment_skeletons() as $cid => $crow)
1610            if ($user->can_view_comment($this, $crow))
1611                $crows[$cid] = $crow;
1612        return $crows;
1613    }
1614
1615    function has_commenter($contact) {
1616        $cid = self::contact_to_cid($contact);
1617        foreach ($this->all_comment_skeletons() as $crow)
1618            if ($crow->contactId == $cid)
1619                return true;
1620        return false;
1621    }
1622
1623    static function analyze_review_or_comment($x) {
1624        if (isset($x->commentId))
1625            return [!!($x->commentType & COMMENTTYPE_DRAFT),
1626                    (int) $x->timeDisplayed, true];
1627        else
1628            return [$x->reviewSubmitted && !$x->reviewOrdinal,
1629                    (int) $x->timeDisplayed, false];
1630    }
1631    static function review_or_comment_compare($a, $b) {
1632        list($a_draft, $a_displayed_at, $a_iscomment) = self::analyze_review_or_comment($a);
1633        list($b_draft, $b_displayed_at, $b_iscomment) = self::analyze_review_or_comment($b);
1634        // drafts come last
1635        if ($a_draft !== $b_draft
1636            && ($a_draft ? !$a_displayed_at : !$b_displayed_at))
1637            return $a_draft ? 1 : -1;
1638        // order by displayed_at
1639        if ($a_displayed_at !== $b_displayed_at)
1640            return $a_displayed_at < $b_displayed_at ? -1 : 1;
1641        // reviews before comments
1642        if ($a_iscomment !== $b_iscomment)
1643            return !$a_iscomment ? -1 : 1;
1644        if ($a_iscomment)
1645            // order by commentId (which generally agrees with ordinal)
1646            return $a->commentId < $b->commentId ? -1 : 1;
1647        else {
1648            // order by ordinal or reviewId
1649            if ($a->reviewOrdinal && $b->reviewOrdinal)
1650                return $a->reviewOrdinal < $b->reviewOrdinal ? -1 : 1;
1651            else
1652                return $a->reviewId < $b->reviewId ? -1 : 1;
1653        }
1654    }
1655    function viewable_submitted_reviews_and_comments(Contact $user) {
1656        $this->ensure_full_reviews();
1657        $rrows = $this->viewable_submitted_reviews_by_display($user);
1658        $crows = $this->viewable_comments($user);
1659        $rcs = array_merge(array_values($rrows), array_values($crows));
1660        usort($rcs, "PaperInfo::review_or_comment_compare");
1661        return $rcs;
1662    }
1663    static function review_or_comment_text_separator($a, $b) {
1664        if (!$a || !$b)
1665            return "";
1666        else if (isset($a->reviewId) || isset($b->reviewId)
1667                 || (($a->commentType | $b->commentType) & COMMENTTYPE_RESPONSE))
1668            return "\n\n\n";
1669        else
1670            return "\n\n";
1671    }
1672
1673
1674    static function notify_user_compare($a, $b) {
1675        // group authors together, then reviewers
1676        $act = (int) $a->conflictType;
1677        $bct = (int) $b->conflictType;
1678        if (($act >= CONFLICT_AUTHOR) !== ($bct >= CONFLICT_AUTHOR))
1679            return $act >= CONFLICT_AUTHOR ? -1 : 1;
1680        $arp = $a->myReviewPermissions;
1681        $brp = $b->myReviewPermissions;
1682        if ((bool) $arp !== (bool) $brp)
1683            return (bool) $arp ? -1 : 1;
1684        return Contact::compare($a, $b);
1685    }
1686
1687    function notify_reviews($callback, $sending_user) {
1688        $result = $this->conf->qe_raw("select ContactInfo.contactId, firstName, lastName, email,
1689                password, contactTags, roles, defaultWatch,
1690                " . self::my_review_permissions_sql() . " myReviewPermissions,
1691                conflictType, watch, preferredEmail, disabled
1692        from ContactInfo
1693        left join PaperConflict on (PaperConflict.paperId=$this->paperId and PaperConflict.contactId=ContactInfo.contactId)
1694        left join PaperWatch on (PaperWatch.paperId=$this->paperId and PaperWatch.contactId=ContactInfo.contactId)
1695        left join PaperReview on (PaperReview.paperId=$this->paperId and PaperReview.contactId=ContactInfo.contactId)
1696        where (watch&" . Contact::WATCH_REVIEW . ")!=0
1697        or (defaultWatch&" . (Contact::WATCH_REVIEW_ALL | Contact::WATCH_REVIEW_MANAGED) . ")!=0
1698        or conflictType>=" . CONFLICT_AUTHOR . "
1699        or reviewType is not null
1700        or exists (select * from PaperComment where paperId=$this->paperId and contactId=ContactInfo.contactId)
1701        group by ContactInfo.contactId");
1702
1703        $watchers = [];
1704        $lastContactId = 0;
1705        while (($minic = Contact::fetch($result, $this->conf))) {
1706            if ($minic->contactId == $lastContactId
1707                || ($sending_user && $minic->contactId == $sending_user->contactId)
1708                || Contact::is_anonymous_email($minic->email))
1709                continue;
1710            $lastContactId = $minic->contactId;
1711            if ($minic->following_reviews($this, $minic->watch))
1712                $watchers[$minic->contactId] = $minic;
1713        }
1714        Dbl::free($result);
1715        usort($watchers, "PaperInfo::notify_user_compare");
1716
1717        // save my current contact info map -- we are replacing it with another
1718        // map that lacks review token information and so forth
1719        $cimap = $this->replace_contact_info_map(null);
1720
1721        foreach ($watchers as $minic) {
1722            $this->load_my_contact_info($minic->contactId, $minic);
1723            call_user_func($callback, $this, $minic);
1724        }
1725
1726        $this->replace_contact_info_map($cimap);
1727    }
1728
1729    function notify_final_submit($callback, $sending_user) {
1730        $result = $this->conf->qe_raw("select ContactInfo.contactId, firstName, lastName, email,
1731                password, contactTags, roles, defaultWatch,
1732                " . self::my_review_permissions_sql() . " myReviewPermissions,
1733                conflictType, watch, preferredEmail, disabled
1734        from ContactInfo
1735        left join PaperConflict on (PaperConflict.paperId=$this->paperId and PaperConflict.contactId=ContactInfo.contactId)
1736        left join PaperWatch on (PaperWatch.paperId=$this->paperId and PaperWatch.contactId=ContactInfo.contactId)
1737        left join PaperReview on (PaperReview.paperId=$this->paperId and PaperReview.contactId=ContactInfo.contactId)
1738        where (defaultWatch&" . (Contact::WATCH_FINAL_SUBMIT_ALL) . ")!=0
1739        group by ContactInfo.contactId");
1740
1741        $watchers = [];
1742        $lastContactId = 0;
1743        while (($minic = Contact::fetch($result, $this->conf))) {
1744            if ($minic->contactId == $lastContactId
1745                || ($sending_user && $minic->contactId == $sending_user->contactId)
1746                || Contact::is_anonymous_email($minic->email))
1747                continue;
1748            $lastContactId = $minic->contactId;
1749            $watchers[$minic->contactId] = $minic;
1750        }
1751        Dbl::free($result);
1752        usort($watchers, "PaperInfo::notify_user_compare");
1753
1754        // save my current contact info map -- we are replacing it with another
1755        // map that lacks review token information and so forth
1756        $cimap = $this->replace_contact_info_map(null);
1757
1758        foreach ($watchers as $minic) {
1759            $this->load_my_contact_info($minic->contactId, $minic);
1760            call_user_func($callback, $this, $minic);
1761        }
1762
1763        $this->replace_contact_info_map($cimap);
1764    }
1765
1766    function delete_from_database(Contact $user = null) {
1767        // XXX email self?
1768        if ($this->paperId <= 0)
1769            return false;
1770        $rrows = $this->reviews_by_id();
1771
1772        $qs = [];
1773        foreach (["PaperWatch", "PaperReviewPreference", "PaperReviewRefused", "ReviewRequest", "PaperTag", "PaperComment", "PaperReview", "PaperTopic", "PaperOption", "PaperConflict", "Paper", "PaperStorage", "Capability"] as $table) {
1774            $qs[] = "delete from $table where paperId={$this->paperId}";
1775        }
1776        $mresult = Dbl::multi_qe($this->conf->dblink, join(";", $qs));
1777        $mresult->free_all();
1778
1779        if (!Dbl::$nerrors) {
1780            $this->conf->update_papersub_setting(-1);
1781            if ($this->outcome > 0)
1782                $this->conf->update_paperacc_setting(-1);
1783            if ($this->leadContactId > 0 || $this->shepherdContactId > 0)
1784                $this->conf->update_paperlead_setting(-1);
1785            if ($this->managerContactId > 0)
1786                $this->conf->update_papermanager_setting(-1);
1787            if ($rrows && array_filter($rrows, function ($rrow) { return $rrow->reviewToken > 0; }))
1788                $this->conf->update_rev_tokens_setting(-1);
1789            if ($rrows && array_filter($rrows, function ($rrow) { return $rrow->reviewType == REVIEW_META; }))
1790                $this->conf->update_metareviews_setting(-1);
1791            $this->conf->log_for($user, $user, "Deleted", $this->paperId);
1792            return true;
1793        } else {
1794            return false;
1795        }
1796    }
1797}
1798