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