1<?php
2// tagger.php -- HotCRP helper class for dealing with tags
3// Copyright (c) 2006-2018 Eddie Kohler; see LICENSE.
4
5// Note that tags MUST NOT contain HTML or URL special characters:
6// no "'&<>.  If you add PHP-protected characters, such as $, make sure you
7// check for uses of eval().
8
9class TagMapItem {
10    public $tag;
11    public $conf;
12    public $pattern = false;
13    public $pattern_instance = false;
14    public $pattern_version = 0;
15    public $is_private = false;
16    public $chair = false;
17    public $readonly = false;
18    public $hidden = false;
19    public $track = false;
20    public $votish = false;
21    public $vote = false;
22    public $approval = false;
23    public $sitewide = false;
24    public $rank = false;
25    public $order_anno = false;
26    private $order_anno_list = false;
27    public $colors = null;
28    public $basic_color = false;
29    public $badges = null;
30    public $emoji = null;
31    public $autosearch = null;
32    function __construct($tag, TagMap $tagmap) {
33        $this->conf = $tagmap->conf;
34        $this->set_tag($tag, $tagmap);
35    }
36    function set_tag($tag, TagMap $tagmap) {
37        $this->tag = $tag;
38        if (($color = $tagmap->known_style($tag))) {
39            $this->colors[] = $color;
40            $this->basic_color = true;
41        }
42        if ($tag[0] === "~") {
43            if ($tag[1] !== "~")
44                $this->is_private = true;
45            else
46                $this->chair = true;
47        }
48    }
49    function merge(TagMapItem $t) {
50        foreach (["chair", "readonly", "hidden", "track", "votish", "vote", "approval", "sitewide", "rank", "autosearch"] as $property)
51            if ($t->$property)
52                $this->$property = $t->$property;
53        foreach (["colors", "badges", "emoji"] as $property)
54            if ($t->$property)
55                $this->$property = array_unique(array_merge($this->$property ? : [], $t->$property));
56    }
57    function tag_regex() {
58        $t = preg_quote($this->tag);
59        if ($this->pattern)
60            $t = str_replace("\\*", "[^\\s#]*", $t);
61        if ($this->is_private)
62            $t = "\\d*" . $t;
63        return $t;
64    }
65    function order_anno_list() {
66        if ($this->order_anno_list == false) {
67            $this->order_anno_list = [];
68            $result = $this->conf->qe("select * from PaperTagAnno where tag=?", $this->tag);
69            while (($ta = TagAnno::fetch($result, $this->conf)))
70                $this->order_anno_list[] = $ta;
71            Dbl::free($result);
72            $this->order_anno_list[] = TagAnno::make_tag_fencepost($this->tag);
73            usort($this->order_anno_list, function ($a, $b) {
74                if ($a->tagIndex != $b->tagIndex)
75                    return $a->tagIndex < $b->tagIndex ? -1 : 1;
76                else if (($x = strcasecmp($a->heading, $b->heading)) != 0)
77                    return $x;
78                else
79                    return $a->annoId < $b->annoId ? -1 : 1;
80            });
81        }
82        return $this->order_anno_list;
83    }
84    function order_anno_entry($i) {
85        return get($this->order_anno_list(), $i);
86    }
87    function has_order_anno() {
88        return count($this->order_anno_list()) > 1;
89    }
90}
91
92class TagAnno implements JsonSerializable {
93    public $tag = null;
94    public $annoId = null;
95    public $tagIndex = null;
96    public $heading = null;
97    public $annoFormat = null;
98    public $infoJson = null;
99    public $count = null;
100    public $pos = null;
101
102    function is_empty() {
103        return $this->heading === null || strcasecmp($this->heading, "none") == 0;
104    }
105    static function fetch($result, Conf $conf) {
106        $ta = $result ? $result->fetch_object("TagAnno") : null;
107        if ($ta) {
108            $ta->annoId = (int) $ta->annoId;
109            $ta->tagIndex = (float) $ta->tagIndex;
110            if ($ta->annoFormat !== null)
111                $ta->annoFormat = (int) $ta->annoFormat;
112        }
113        return $ta;
114    }
115    static function make_empty() {
116        return new TagAnno;
117    }
118    static function make_heading($h) {
119        $ta = new TagAnno;
120        $ta->heading = $h;
121        return $ta;
122    }
123    static function make_tag_fencepost($tag) {
124        $ta = new TagAnno;
125        $ta->tag = $tag;
126        $ta->tagIndex = (float) TAG_INDEXBOUND;
127        $ta->heading = "Untagged";
128        return $ta;
129    }
130    function jsonSerialize() {
131        global $Conf;
132        $j = [];
133        if ($this->pos !== null)
134            $j["pos"] = $this->pos;
135        $j["annoid"] = $this->annoId;
136        if ($this->tag)
137            $j["tag"] = $this->tag;
138        if ($this->tagIndex !== null)
139            $j["tagval"] = $this->tagIndex;
140        if ($this->is_empty())
141            $j["empty"] = true;
142        if ($this->heading !== null)
143            $j["heading"] = $this->heading;
144        if ($this->heading !== null && $this->heading !== ""
145            && ($format = $Conf->check_format($this->annoFormat, $this->heading)))
146            $j["format"] = +$format;
147        return $j;
148    }
149}
150
151class TagMap implements IteratorAggregate {
152    public $conf;
153    public $has_pattern = false;
154    public $has_chair = true;
155    public $has_readonly = true;
156    public $has_hidden = false;
157    public $has_votish = false;
158    public $has_vote = false;
159    public $has_approval = false;
160    public $has_sitewide = false;
161    public $has_rank = false;
162    public $has_colors = false;
163    public $has_badges = false;
164    public $has_emoji = false;
165    public $has_decoration = false;
166    public $has_order_anno = false;
167    public $has_autosearch = false;
168    private $storage = array();
169    private $sorted = false;
170    private $pattern_re = null;
171    private $pattern_storage = [];
172    private $pattern_version = 0; // = count($pattern_storage)
173    private $color_re = null;
174    private $badge_re = null;
175    private $emoji_re = null;
176    private $hidden_re = null;
177    private $sitewide_re_part = null;
178
179    const STYLE_FG = 1;
180    const STYLE_BG = 2;
181    const STYLE_FG_BG = 3;
182    const STYLE_SYNONYM = 4;
183    private $style_info_lmap = [];
184    private $canonical_style_lmap = [];
185    private $basic_badges;
186
187    private static $emoji_code_map = null;
188    private static $multicolor_map = [];
189
190    function __construct(Conf $conf) {
191        $this->conf = $conf;
192
193        $basic_colors = "red&|orange&|yellow&|green&|blue&|purple&|violet=purple|gray&|grey=gray|white&|bold|italic|underline|strikethrough|big|small|dim";
194        if (($o = $conf->opt("tagBasicColors"))) {
195            if (str_starts_with($o, "|"))
196                $basic_colors .= $o;
197            else
198                $basic_colors = $o;
199        }
200        preg_match_all('/([a-z@_.][-a-z0-9!@_:.\/]*)(\&?)(?:=([a-z@_.][-a-z0-9!@_:.\/]*))?/', strtolower($basic_colors), $ms, PREG_SET_ORDER);
201        foreach ($ms as $m) {
202            $m[3] = isset($m[3]) ? $m[3] : $m[1];
203            while (isset($this->style_info_lmap[$m[3]])
204                   && ($this->style_info_lmap[$m[3]] & self::STYLE_SYNONYM)) {
205                $m[3] = $this->canonical_style_lmap[$m[3]];
206            }
207            if ($m[3] !== $m[1] && isset($this->style_info_lmap[$m[3]])) {
208                $this->style_info_lmap[$m[1]] = $this->style_info_lmap[$m[3]] | self::STYLE_SYNONYM;
209                $this->canonical_style_lmap[$m[1]] = $m[3];
210            } else {
211                $this->style_info_lmap[$m[1]] = $m[2] ? self::STYLE_BG : self::STYLE_FG;
212                $this->canonical_style_lmap[$m[1]] = $m[1];
213            }
214        }
215
216        $this->basic_badges = "normal|red|orange|yellow|green|blue|purple|white|pink|gray";
217        if (($o = $conf->opt("tagBasicBadges"))) {
218            if (str_starts_with($o, "|"))
219                $this->basic_badges .= $o;
220            else
221                $this->basic_badges = $o;
222        }
223    }
224    function check_emoji_code($ltag) {
225        $len = strlen($ltag);
226        if ($len < 3 || $ltag[0] !== ":" || $ltag[$len - 1] !== ":")
227            return false;
228        return get($this->conf->emoji_code_map(), substr($ltag, 1, $len - 2), false);
229    }
230    private function update_patterns($tag, $ltag, TagMapItem $t = null) {
231        if (!$this->pattern_re) {
232            $a = [];
233            foreach ($this->pattern_storage as $p)
234                $a[] = strtolower($p->tag_regex());
235            $this->pattern_re = "{\A(?:" . join("|", $a) . ")\z}";
236        }
237        if (preg_match($this->pattern_re, $ltag)) {
238            $version = $t ? $t->pattern_version : 0;
239            foreach ($this->pattern_storage as $i => $p)
240                if ($i >= $version && preg_match($p->pattern, $ltag)) {
241                    if (!$t) {
242                        $t = clone $p;
243                        $t->set_tag($tag, $this);
244                        $t->pattern = false;
245                        $t->pattern_instance = true;
246                        $this->storage[$ltag] = $t;
247                        $this->sorted = false;
248                    } else
249                        $t->merge($p);
250                }
251        }
252        if ($t)
253            $t->pattern_version = $this->pattern_version;
254        return $t;
255    }
256    function check($tag) {
257        $ltag = strtolower($tag);
258        $t = get($this->storage, $ltag);
259        if (!$t && $ltag && $ltag[0] === ":" && $this->check_emoji_code($ltag))
260            $t = $this->add($tag);
261        if ($this->has_pattern
262            && (!$t || $t->pattern_version < $this->pattern_version))
263            $t = $this->update_patterns($tag, $ltag, $t);
264        return $t;
265    }
266    function check_base($tag) {
267        return $this->check(TagInfo::base($tag));
268    }
269    function add($tag) {
270        $ltag = strtolower($tag);
271        $t = get($this->storage, $ltag);
272        if (!$t) {
273            $t = new TagMapItem($tag, $this);
274            if (!TagInfo::basic_check($ltag))
275                return $t;
276            $this->storage[$ltag] = $t;
277            $this->sorted = false;
278            if ($ltag[0] === ":" && ($e = $this->check_emoji_code($ltag))) {
279                $t->emoji[] = $e;
280                $this->has_emoji = $this->has_decoration = true;
281            }
282            if (strpos($ltag, "*") !== false) {
283                $t->pattern = "{\A" . strtolower(str_replace("\\*", "[^\\s#]*", $t->tag_regex())) . "\z}";
284                $this->has_pattern = true;
285                $this->pattern_storage[] = $t;
286                $this->pattern_re = null;
287                ++$this->pattern_version;
288            }
289        }
290        if ($this->has_pattern && !$t->pattern
291            && $t->pattern_version < $this->pattern_version)
292            $t = $this->update_patterns($tag, $ltag, $t);
293        return $t;
294    }
295    private function sort() {
296        ksort($this->storage);
297        $this->sorted = true;
298    }
299    function getIterator() {
300        $this->sorted || $this->sort();
301        return new ArrayIterator($this->storage);
302    }
303    function filter($property) {
304        $k = "has_{$property}";
305        if (!$this->$k)
306            return [];
307        $this->sorted || $this->sort();
308        return array_filter($this->storage, function ($t) use ($property) { return $t->$property; });
309    }
310    function filter_by($f) {
311        $this->sorted || $this->sort();
312        return array_filter($this->storage, $f);
313    }
314    function check_property($tag, $property) {
315        $k = "has_{$property}";
316        return $this->$k
317            && ($t = $this->check(TagInfo::base($tag)))
318            && $t->$property
319            ? $t : null;
320    }
321
322
323    function is_chair($tag) {
324        if ($tag[0] === "~")
325            return $tag[1] === "~";
326        else
327            return !!$this->check_property($tag, "chair");
328    }
329    function is_readonly($tag) {
330        return !!$this->check_property($tag, "readonly");
331    }
332    function is_hidden($tag) {
333        return !!$this->check_property($tag, "hidden");
334    }
335    function is_sitewide($tag) {
336        return !!$this->check_property($tag, "sitewide");
337    }
338    function is_votish($tag) {
339        return !!$this->check_property($tag, "votish");
340    }
341    function is_vote($tag) {
342        return !!$this->check_property($tag, "vote");
343    }
344    function is_approval($tag) {
345        return !!$this->check_property($tag, "approval");
346    }
347    function votish_base($tag) {
348        if (!$this->has_votish
349            || ($twiddle = strpos($tag, "~")) === false)
350            return false;
351        $tbase = substr(TagInfo::base($tag), $twiddle + 1);
352        $t = $this->check($tbase);
353        return $t && $t->votish ? $tbase : false;
354    }
355    function is_rank($tag) {
356        return !!$this->check_property($tag, "rank");
357    }
358    function is_emoji($tag) {
359        return !!$this->check_property($tag, "emoji");
360    }
361    function is_autosearch($tag) {
362        return !!$this->check_property($tag, "autosearch");
363    }
364
365    function sitewide_regex_part() {
366        if ($this->sitewide_re_part === null) {
367            $x = ["\\&"];
368            foreach ($this->filter("sitewide") as $t)
369                $x[] = $t->tag_regex() . "[ #=]";
370            $this->sitewide_re_part = join("|", $x);
371        }
372        return $this->sitewide_re_part;
373    }
374
375    function hidden_regex_part() {
376        if ($this->hidden_re === null) {
377            $x = [];
378            foreach ($this->filter("hidden") as $t)
379                $x[] = $t->tag_regex();
380            $this->hidden_re = join("|", $x);
381        }
382        return $this->hidden_re;
383    }
384
385
386    function known_styles() {
387        return array_keys($this->style_info_lmap);
388    }
389    function known_style($tag) {
390        return get($this->canonical_style_lmap, strtolower($tag), false);
391    }
392    function is_known_style($tag, $match = self::STYLE_FG_BG) {
393        return (get($this->style_info_lmap, strtolower($tag), 0) & $match) !== 0;
394    }
395    function is_style($tag, $match = self::STYLE_FG_BG) {
396        $ltag = strtolower($tag);
397        if (($t = $this->check($ltag))) {
398            foreach ($t->colors ? : [] as $k)
399                if ($this->style_info_lmap[$k] & $match)
400                    return true;
401            return false;
402        } else
403            return (get($this->style_info_lmap, $ltag, 0) & $match) !== 0;
404    }
405
406    function color_regex() {
407        if (!$this->color_re) {
408            $re = "{(?:\\A| )(?:\\d*~|~~|)(" . join("|", array_keys($this->style_info_lmap));
409            foreach ($this->filter("colors") as $t)
410                $re .= "|" . $t->tag_regex();
411            $this->color_re = $re . ")(?=\\z|[# ])}i";
412        }
413        return $this->color_re;
414    }
415
416    function styles($tags, $match = self::STYLE_FG_BG) {
417        if (is_array($tags))
418            $tags = join(" ", $tags);
419        if (!$tags || $tags === " " || !preg_match_all($this->color_regex(), $tags, $m))
420            return null;
421        $classes = null;
422        $info = 0;
423        foreach ($m[1] as $tag) {
424            $ltag = strtolower($tag);
425            $t = $this->check($ltag);
426            $ks = $t ? $t->colors : [$ltag];
427            foreach ($ks as $k) {
428                if ($this->style_info_lmap[$k] & $match) {
429                    $classes[] = $this->canonical_style_lmap[$k] . "tag";
430                    $info |= $this->style_info_lmap[$k];
431                }
432            }
433        }
434        if (empty($classes))
435            return null;
436        if (count($classes) > 1) {
437            sort($classes);
438            $classes = array_unique($classes);
439        }
440        if ($info & self::STYLE_BG)
441            $classes[] = "tagbg";
442        return $classes;
443    }
444
445    static function mark_pattern_fill($classes) {
446        $key = is_array($classes) ? join(" ", $classes) : $classes;
447        if (!isset(self::$multicolor_map[$key]) && strpos($key, " ") !== false) {
448            Ht::stash_script("make_pattern_fill(" . json_encode_browser($key) . ")");
449            self::$multicolor_map[$key] = true;
450        }
451    }
452
453    function color_classes($tags, $no_pattern_fill = false) {
454        $classes = $this->styles($tags);
455        if (!$classes)
456            return "";
457        $key = join(" ", $classes);
458        // This seems out of place---it's redundant if we're going to
459        // generate JSON, for example---but it is convenient.
460        if (!$no_pattern_fill && count($classes) > 1)
461            self::mark_pattern_fill($classes);
462        return $key;
463    }
464
465    function canonical_colors() {
466        $colors = [];
467        foreach ($this->canonical_style_lmap as $ltag => $canon_ltag)
468            if ($ltag === $canon_ltag)
469                $colors[] = $ltag;
470        return $colors;
471    }
472
473
474    function badge_regex() {
475        if (!$this->badge_re) {
476            $re = "{(?:\\A| )(?:\\d*~|)(";
477            foreach ($this->filter("badges") as $t)
478                $re .= $t->tag_regex() . "|";
479            $this->badge_re = substr($re, 0, -1) . ")(?:#[-\\d.]+)?(?=\\z| )}i";
480        }
481        return $this->badge_re;
482    }
483
484    function canonical_badges() {
485        return explode("|", $this->basic_badges);
486    }
487
488    function emoji_regex() {
489        if (!$this->badge_re) {
490            $re = "{(?:\\A| )(?:\\d*~|~~|)(:\\S+:";
491            foreach ($this->filter("emoji") as $t)
492                $re .= "|" . $t->tag_regex();
493            $this->emoji_re = $re . ")(?:#[\\d.]+)?(?=\\z| )}i";
494        }
495        return $this->emoji_re;
496    }
497
498
499    function strip_nonviewable($tags, Contact $user = null, PaperInfo $prow = null) {
500        if ($this->has_hidden || strpos($tags, "~") !== false) {
501            $re = "(?:";
502            if ($user && $user->contactId)
503                $re .= "(?!" . $user->contactId . "~)";
504            $re .= "\\d+~";
505            if (!($user && $user->privChair))
506                $re .= "|~+";
507            $re .= ")\\S+";
508            if ($this->has_hidden
509                && $user
510                && !($prow ? $user->can_view_hidden_tags($prow) : $user->privChair))
511                $re = "(?:" . $re . "|(?:" . $this->hidden_regex_part() . ")(?:#\\S+|(?= )))";
512            $tags = trim(preg_replace("{ " . $re . "}i", "", " $tags "));
513        }
514        return $tags;
515    }
516
517
518    static function make(Conf $conf) {
519        $map = new TagMap($conf);
520        $ct = $conf->setting_data("tag_chair", "");
521        foreach (TagInfo::split_unpack($ct) as $ti) {
522            $t = $map->add($ti[0]);
523            $t->chair = $t->readonly = true;
524        }
525        foreach ($conf->track_tags() as $tn) {
526            $t = $map->add(TagInfo::base($tn));
527            $t->chair = $t->readonly = $t->track = true;
528        }
529        $ct = $conf->setting_data("tag_hidden", "");
530        foreach (TagInfo::split_unpack($ct) as $ti)
531            $map->add($ti[0])->hidden = $map->has_hidden = true;
532        $ct = $conf->setting_data("tag_sitewide", "");
533        foreach (TagInfo::split_unpack($ct) as $ti)
534            $map->add($ti[0])->sitewide = $map->has_sitewide = true;
535        $vt = $conf->setting_data("tag_vote", "");
536        foreach (TagInfo::split_unpack($vt) as $ti) {
537            $t = $map->add($ti[0]);
538            $t->vote = ($ti[1] ? : 1);
539            $t->votish = $map->has_vote = $map->has_votish = true;
540        }
541        $vt = $conf->setting_data("tag_approval", "");
542        foreach (TagInfo::split_unpack($vt) as $ti) {
543            $t = $map->add($ti[0]);
544            $t->approval = $t->votish = $map->has_approval = $map->has_votish = true;
545        }
546        $rt = $conf->setting_data("tag_rank", "");
547        foreach (TagInfo::split_unpack($rt) as $ti)
548            $map->add($ti[0])->rank = $map->has_rank = true;
549        $ct = $conf->setting_data("tag_color", "");
550        if ($ct !== "")
551            foreach (explode(" ", $ct) as $k)
552                if ($k !== "" && ($p = strpos($k, "=")) !== false
553                    && ($kk = $map->known_style(substr($k, $p + 1)))) {
554                    $map->add(substr($k, 0, $p))->colors[] = $kk;
555                    $map->has_colors = true;
556                }
557        $bt = $conf->setting_data("tag_badge", "");
558        if ($bt !== "")
559            foreach (explode(" ", $bt) as $k)
560                if ($k !== "" && ($p = strpos($k, "=")) !== false) {
561                    $map->add(substr($k, 0, $p))->badges[] = substr($k, $p + 1);
562                    $map->has_badges = true;
563                }
564        $bt = $conf->setting_data("tag_emoji", "");
565        if ($bt !== "")
566            foreach (explode(" ", $bt) as $k)
567                if ($k !== "" && ($p = strpos($k, "=")) !== false) {
568                    $map->add(substr($k, 0, $p))->emoji[] = substr($k, $p + 1);
569                    $map->has_emoji = true;
570                }
571        $tx = $conf->setting_data("tag_autosearch", "");
572        if ($tx !== "") {
573            foreach (json_decode($tx) ? : [] as $tag => $search) {
574                $map->add($tag)->autosearch = $search->q;
575                $map->has_autosearch = true;
576            }
577        }
578        if (($od = $conf->opt("definedTags"))) {
579            foreach (is_string($od) ? [$od] : $od as $ods)
580                foreach (json_decode($ods) as $tag => $data) {
581                    $t = $map->add($tag);
582                    if (get($data, "chair"))
583                        $t->chair = $t->readonly = true;
584                    if (get($data, "readonly"))
585                        $t->readonly = true;
586                    if (get($data, "hidden"))
587                        $t->hidden = $map->has_hidden = true;
588                    if (get($data, "sitewide"))
589                        $t->sitewide = $map->has_sitewide = true;
590                    if (($x = get($data, "autosearch"))) {
591                        $t->autosearch = $x;
592                        $map->has_autosearch = true;
593                    }
594                    if (($x = get($data, "color")))
595                        foreach (is_string($x) ? [$x] : $x as $c) {
596                            if (($kk = $this->known_style($c))) {
597                                $t->colors[] = $kk;
598                                $map->has_colors = true;
599                            }
600                        }
601                    if (($x = get($data, "badge")))
602                        foreach (is_string($x) ? [$x] : $x as $c) {
603                            $t->badges[] = $c;
604                            $map->has_badges = true;
605                        }
606                    if (($x = get($data, "emoji")))
607                        foreach (is_string($x) ? [$x] : $x as $c) {
608                            $t->emoji[] = $c;
609                            $map->has_emoji = true;
610                        }
611                }
612        }
613        if ($map->has_badges || $map->has_emoji || $conf->setting("has_colontag"))
614            $map->has_decoration = true;
615        return $map;
616    }
617}
618
619class TagInfo {
620    static function base($tag) {
621        if ($tag && (($pos = strpos($tag, "#")) > 0
622                     || ($pos = strpos($tag, "=")) > 0))
623            return substr($tag, 0, $pos);
624        else
625            return $tag;
626    }
627
628    static function unpack($tag) {
629        if (!$tag)
630            return [false, false];
631        else if (!($pos = strpos($tag, "#")) && !($pos = strpos($tag, "=")))
632            return [$tag, false];
633        else if ($pos == strlen($tag) - 1)
634            return [substr($tag, 0, $pos), false];
635        else
636            return [substr($tag, 0, $pos), (float) substr($tag, $pos + 1)];
637    }
638
639    static function split($taglist) {
640        preg_match_all(',\S+,', $taglist, $m);
641        return $m[0];
642    }
643
644    static function split_unpack($taglist) {
645        return array_map("TagInfo::unpack", self::split($taglist));
646    }
647
648    static function basic_check($tag) {
649        return $tag !== "" && strlen($tag) <= TAG_MAXLEN
650            && preg_match('{\A' . TAG_REGEX . '\z}', $tag);
651    }
652
653
654    private static $value_increment_map = array(1, 1, 1, 1, 1, 2, 2, 2, 3, 4);
655
656    static function value_increment($mode) {
657        if (strlen($mode) == 2)
658            return self::$value_increment_map[mt_rand(0, 9)];
659        else
660            return 1;
661    }
662
663
664    static function id_index_compar($a, $b) {
665        if ($a[1] != $b[1])
666            return $a[1] < $b[1] ? -1 : 1;
667        else
668            return $a[0] - $b[0];
669    }
670}
671
672class Tagger {
673    const ALLOWRESERVED = 1;
674    const NOPRIVATE = 2;
675    const NOVALUE = 4;
676    const NOCHAIR = 8;
677    const ALLOWSTAR = 16;
678    const ALLOWCONTACTID = 32;
679    const NOTAGKEYWORD = 64;
680
681    public $error_html = false;
682    private $conf;
683    private $contact;
684    private $_contactId = 0;
685
686    function __construct($contact = null) {
687        global $Conf, $Me;
688        $this->contact = ($contact ? : $Me);
689        if ($this->contact && $this->contact->contactId > 0)
690            $this->_contactId = $this->contact->contactId;
691        $this->conf = $this->contact ? $this->contact->conf : $Conf;
692    }
693
694    private function set_error_html($e) {
695        $this->error_html = $e;
696        return false;
697    }
698
699    function check($tag, $flags = 0) {
700        if (!($this->contact && $this->contact->privChair))
701            $flags |= self::NOCHAIR;
702        if ($tag !== "" && $tag[0] === "#")
703            $tag = substr($tag, 1);
704        if ((string) $tag === "")
705            return $this->set_error_html("Tag missing.");
706        if (!preg_match('/\A(|~|~~|[1-9][0-9]*~)(' . TAG_REGEX_NOTWIDDLE . ')(|[#=](?:-?\d+(?:\.\d*)?|-?\.\d+|))\z/', $tag, $m)) {
707            if (preg_match('/\A([-a-zA-Z0-9!@*_:.\/#=]+)[\s,]+\S+/', $tag, $m)
708                && $this->check($m[1], $flags))
709                return $this->set_error_html("Expected a single tag.");
710            else
711                return $this->set_error_html("Invalid tag.");
712        }
713        if (!($flags & self::ALLOWSTAR) && strpos($tag, "*") !== false)
714            return $this->set_error_html("Wildcards aren’t allowed in tag names.");
715        // After this point we know `$tag` contains no HTML specials
716        if ($m[1] === "")
717            /* OK */;
718        else if ($m[1] === "~~") {
719            if ($flags & self::NOCHAIR)
720                return $this->set_error_html("Tag #{$tag} is exclusively for chairs.");
721        } else {
722            if ($flags & self::NOPRIVATE)
723                return $this->set_error_html("Twiddle tags aren’t allowed here.");
724            if ($m[1] === "~" && $this->_contactId)
725                $m[1] = $this->_contactId . "~";
726            if ($m[1] !== "~" && $m[1] !== $this->_contactId . "~"
727                && !($flags & self::ALLOWCONTACTID))
728                return $this->set_error_html("Other users’ twiddle tags are off limits.");
729        }
730        if ($m[3] !== "" && ($flags & self::NOVALUE))
731            return $this->set_error_html("Tag values aren’t allowed here.");
732        if (!($flags & self::ALLOWRESERVED)
733            && (!strcasecmp("none", $m[2]) || !strcasecmp("any", $m[2])))
734            return $this->set_error_html("Tag #{$m[2]} is reserved.");
735        $t = $m[1] . $m[2];
736        if (strlen($t) > TAG_MAXLEN)
737            return $this->set_error_html("Tag #{$tag} is too long.");
738        if ($m[3] !== "")
739            $t .= "#" . substr($m[3], 1);
740        return $t;
741    }
742
743    function expand($tag) {
744        if (strlen($tag) > 2 && $tag[0] === "~" && $tag[1] !== "~" && $this->_contactId)
745            return $this->_contactId . $tag;
746        else
747            return $tag;
748    }
749
750    static function check_tag_keyword($text, Contact $user, $flags = 0) {
751        $re = '/\A(?:#|tagval:\s*'
752            . ($flags & self::NOTAGKEYWORD ? '' : '|tag:\s*')
753            . ')(\S+)\z/i';
754        if (preg_match($re, $text, $m)) {
755            $tagger = new Tagger($user);
756            return $tagger->check($m[1], $flags);
757        } else
758            return false;
759    }
760
761    function view_score($tag) {
762        if ($tag === false)
763            return VIEWSCORE_FALSE;
764        else if (($pos = strpos($tag, "~")) !== false) {
765            if (($pos == 0 && $tag[1] === "~")
766                || substr($tag, 0, $pos) != $this->_contactId)
767                return VIEWSCORE_ADMINONLY;
768            else
769                return VIEWSCORE_REVIEWERONLY;
770        } else
771            return VIEWSCORE_PC;
772    }
773
774
775    static function strip_nonsitewide($tags, Contact $user) {
776        $re = "{ (?:(?!" . $user->contactId . "~)\\d+~|~+|(?!"
777            . $user->conf->tags()->sitewide_regex_part() . ")\\S)\\S*}i";
778        return trim(preg_replace($re, "", " $tags "));
779    }
780
781    function unparse($tags) {
782        if ($tags === "" || (is_array($tags) && count($tags) == 0))
783            return "";
784        if (is_array($tags))
785            $tags = join(" ", $tags);
786        $tags = str_replace("#0 ", " ", " $tags ");
787        if ($this->_contactId)
788            $tags = str_replace(" " . $this->_contactId . "~", " ~", $tags);
789        return trim($tags);
790    }
791
792    function unparse_hashed($tags) {
793        if (($tags = $this->unparse($tags)) !== "")
794            $tags = str_replace(" ", " #", "#" . $tags);
795        return $tags;
796    }
797
798    static function unparse_emoji_html($e, $count) {
799        if ($count == 0)
800            $count = 1;
801        $b = '<span class="tagemoji">';
802        if ($count == 0 || $count == 1)
803            $b .= $e;
804        else if ($count >= 5.0625)
805            $b .= str_repeat($e, 5) . "<sup>+</sup>";
806        else {
807            $f = floor($count + 0.0625);
808            $d = round(max($count - $f, 0) * 8);
809            $b .= str_repeat($e, $f);
810            if ($d)
811                $b .= '<span style="display:inline-block;overflow-x:hidden;vertical-align:bottom;position:relative;bottom:0;width:' . ($d / 8) . 'em">' . $e . '</span>';
812        }
813        return $b . '</span>';
814    }
815
816    function unparse_decoration_html($tags) {
817        if (is_array($tags))
818            $tags = join(" ", $tags);
819        if (!$tags || $tags === " ")
820            return "";
821        $dt = $this->conf->tags();
822        $x = "";
823        if ($dt->has_decoration
824            && preg_match_all($dt->emoji_regex(), $tags, $m, PREG_SET_ORDER)) {
825            $emoji = [];
826            foreach ($m as $mx)
827                if (($t = $dt->check($mx[1])) && $t->emoji)
828                    foreach ($t->emoji as $e)
829                        $emoji[$e][] = ltrim($mx[0]);
830            foreach ($emoji as $e => $ts) {
831                $links = [];
832                $count = 0;
833                foreach ($ts as $t) {
834                    if (($link = $this->link_base($t)))
835                        $links[] = "#" . $link;
836                    list($base, $value) = TagInfo::unpack($t);
837                    $count = max($count, (float) $value);
838                }
839                $b = self::unparse_emoji_html($e, $count);
840                if (!empty($links))
841                    $b = '<a class="qq" href="' . hoturl("search", ["q" => join(" OR ", $links)]) . '">' . $b . '</a>';
842                if ($x === "")
843                    $x = " ";
844                $x .= $b;
845            }
846        }
847        if ($dt->has_badges
848            && preg_match_all($dt->badge_regex(), $tags, $m, PREG_SET_ORDER))
849            foreach ($m as $mx)
850                if (($t = $dt->check($mx[1])) && $t->badges) {
851                    $klass = ' class="badge ' . $t->badges[0] . 'badge"';
852                    $tag = $this->unparse(trim($mx[0]));
853                    if (($link = $this->link($tag))) {
854                        $b = '<a href="' . $link . '"' . $klass . '>#' . $tag . '</a>';
855                    } else {
856                        $b = '<span' . $klass . '>#' . $tag . '</span>';
857                    }
858                    $x .= ' ' . $b;
859                }
860        return $x === "" ? "" : '<span class="tagdecoration">' . $x . '</span>';
861    }
862
863    private function trim_for_sort($x) {
864        if ($x[0] === "#")
865            $x = substr($x, 1);
866        if ($x[0] === "~" && $x[1] !== "~")
867            $x = $this->_contactId . $x;
868        else if ($x[0] === "~")
869            $x = ";" . $x;
870        return $x;
871    }
872
873    function tag_compare($a, $b) {
874        return strcasecmp($this->trim_for_sort($a), $this->trim_for_sort($b));
875    }
876
877    function sort(&$tags) {
878        usort($tags, array($this, "tag_compare"));
879    }
880
881    function link_base($tag) {
882        if (ctype_digit($tag[0])) {
883            $p = strlen($this->_contactId);
884            if (substr($tag, 0, $p) != $this->_contactId || $tag[$p] !== "~")
885                return false;
886            $tag = substr($tag, $p);
887        }
888        return TagInfo::base($tag);
889    }
890
891    function link($tag) {
892        if (ctype_digit($tag[0])) {
893            $p = strlen($this->_contactId);
894            if (substr($tag, 0, $p) != $this->_contactId || $tag[$p] !== "~")
895                return false;
896            $tag = substr($tag, $p);
897        }
898        $base = TagInfo::base($tag);
899        $dt = $this->conf->tags();
900        if ($dt->has_votish
901            && ($dt->is_votish($base)
902                || ($base[0] === "~" && $dt->is_vote(substr($base, 1)))))
903            $q = "#$base showsort:-#$base";
904        else if ($base === $tag)
905            $q = "#$base";
906        else
907            $q = "order:#$base";
908        return hoturl("search", ["q" => $q]);
909    }
910
911    function unparse_and_link($viewable) {
912        $tags = $this->unparse($viewable);
913        if ($tags === "")
914            return "";
915
916        // decorate with URL matches
917        $dt = $this->conf->tags();
918        $tt = "";
919        foreach (preg_split('/\s+/', $tags) as $tag) {
920            if (!($base = TagInfo::base($tag)))
921                continue;
922            $lbase = strtolower($base);
923            if (($link = $this->link($tag)))
924                $tx = '<a class="nn nw" href="' . $link . '"><u class="x">#'
925                    . $base . '</u>' . substr($tag, strlen($base)) . '</a>';
926            else
927                $tx = "#" . $tag;
928            if (($cc = $dt->styles($base, TagMap::STYLE_FG)))
929                $tx = '<span class="' . join(" ", $cc) . ' taghh">' . $tx . '</span>';
930            $tt .= $tx . " ";
931        }
932        return rtrim($tt);
933    }
934}
935