1<?php 2 3namespace dokuwiki\plugin\issuelinks\classes; 4 5/** 6 * 7 * Class Issue 8 */ 9class Issue extends \DokuWiki_Plugin implements \JsonSerializable 10{ 11 12 /** @var Issue[] */ 13 private static $instances = []; 14 protected $issueId; 15 protected $projectId; 16 protected $isMergeRequest; 17 protected $files = []; 18 protected $serviceID = ''; 19 private $summary = ''; 20 private $description = ''; 21 private $status = ''; 22 private $type = 'unknown'; 23 private $components = []; 24 private $labels = []; 25 private $priority = ''; 26 private $assignee = []; 27 private $labelData = []; 28 private $versions = []; 29 private $duedate; 30 private $updated; 31 private $parent; 32 private $errors = []; 33 private $isValid; 34 35 /** 36 * @param $serviceName 37 * @param string $projectKey The shortkey of the project, e.g. SPR 38 * @param int $issueId The id of the issue, e.g. 42 39 * 40 * @param $isMergeRequest 41 */ 42 private function __construct($serviceName, $projectKey, $issueId, $isMergeRequest) 43 { 44 if (empty($serviceName) || empty($projectKey) || empty($issueId) || !is_numeric($issueId)) { 45 throw new \InvalidArgumentException('Empty value passed to Issue constructor'); 46 } 47 48 $this->issueId = $issueId; 49 $this->projectId = $projectKey; 50 $this->isMergeRequest = $isMergeRequest; 51 $this->serviceID = $serviceName; 52 53// $this->getFromDB(); 54 } 55 56 /** 57 * Get the singleton instance of a issue 58 * 59 * @param $serviceName 60 * @param $projectKey 61 * @param $issueId 62 * @param bool $isMergeRequest 63 * @param bool $forcereload create a new instace 64 * 65 * @return Issue 66 */ 67 public static function getInstance( 68 $serviceName, 69 $projectKey, 70 $issueId, 71 $isMergeRequest = false, 72 $forcereload = false 73 ) { 74 $issueHash = $serviceName . $projectKey . $issueId . '!' . $isMergeRequest; 75 if (empty(self::$instances[$issueHash]) || $forcereload) { 76 self::$instances[$issueHash] = new Issue($serviceName, $projectKey, $issueId, $isMergeRequest); 77 } 78 return self::$instances[$issueHash]; 79 } 80 81 /** 82 * @return bool true if issue was found in database, false otherwise 83 */ 84 public function getFromDB() 85 { 86 /** @var \helper_plugin_issuelinks_db $db */ 87 $db = plugin_load('helper', 'issuelinks_db'); 88 $issue = $db->loadIssue($this->serviceID, $this->projectId, $this->issueId, $this->isMergeRequest); 89 if (empty($issue)) { 90 return false; 91 } 92 $this->summary = $issue['summary'] ?: ''; 93 $this->status = $issue['status']; 94 $this->type = $issue['type']; 95 $this->description = $issue['description']; 96 $this->setComponents($issue['components']); 97 $this->setLabels($issue['labels']); 98 $this->priority = $issue['priority']; 99 $this->duedate = $issue['duedate']; 100 $this->setVersions($issue['versions']); 101 $this->setUpdated($issue['updated']); 102 return true; 103 } 104 105 public function __toString() 106 { 107 $sep = $this->pmService->getProjectIssueSeparator($this->isMergeRequest); 108 return $this->projectId . $sep . $this->issueId; 109 } 110 111 /** 112 * @return \Exception|null 113 */ 114 public function getLastError() 115 { 116 if (!end($this->errors)) { 117 return null; 118 } 119 return end($this->errors); 120 } 121 122 /** 123 * @return bool|self 124 */ 125 public function isMergeRequest($isMergeRequest = null) 126 { 127 if ($isMergeRequest === null) { 128 return $this->isMergeRequest; 129 } 130 131 $this->isMergeRequest = $isMergeRequest; 132 return $this; 133 } 134 135 /** 136 * Specify data which should be serialized to JSON 137 * 138 * @link http://php.net/manual/en/jsonserializable.jsonserialize.php 139 * @return mixed data which can be serialized by <b>json_encode</b>, 140 * which is a value of any type other than a resource. 141 * @since 5.4.0 142 * 143 * @link http://stackoverflow.com/a/4697671/3293343 144 */ 145 public function jsonSerialize() 146 { 147 return [ 148 'service' => $this->serviceID, 149 'project' => $this->getProject(), 150 'id' => $this->getKey(), 151 'isMergeRequest' => $this->isMergeRequest ? '1' : '0', 152 'summary' => $this->getSummary(), 153 'description' => $this->getDescription(), 154 'type' => $this->getType(), 155 'status' => $this->getStatus(), 156 'parent' => $this->getParent(), 157 'components' => $this->getComponents(), 158 'labels' => $this->getLabels(), 159 'priority' => $this->getPriority(), 160 'duedate' => $this->getDuedate(), 161 'versions' => $this->getVersions(), 162 'updated' => $this->getUpdated(), 163 ]; 164 } 165 166 /** 167 * @return string 168 */ 169 public function getProject() 170 { 171 return $this->projectId; 172 } 173 174 /** 175 * Returns the key, i.e. number, of the issue 176 * 177 * @param bool $annotateMergeRequest If true, prepends a `!` to the key of a merge requests 178 * 179 * @return int|string 180 */ 181 public function getKey($annotateMergeRequest = false) 182 { 183 if ($annotateMergeRequest && $this->isMergeRequest) { 184 return '!' . $this->issueId; 185 } 186 return $this->issueId; 187 } 188 189 public function getSummary() 190 { 191 return $this->summary; 192 } 193 194 /** 195 * @param string $summary 196 * 197 * @return Issue 198 * 199 * todo: decide if we should test for non-empty string here 200 */ 201 public function setSummary($summary) 202 { 203 $this->summary = $summary; 204 return $this; 205 } 206 207 /** 208 * @return string 209 */ 210 public function getDescription() 211 { 212 return $this->description; 213 } 214 215 /** 216 * @param string $description 217 * 218 * @return Issue 219 */ 220 public function setDescription($description) 221 { 222 $this->description = $description; 223 return $this; 224 } 225 226 /** 227 * @return string 228 */ 229 public function getType() 230 { 231 return $this->type; 232 } 233 234 /** 235 * @param string $type 236 * 237 * @return Issue 238 */ 239 public function setType($type) 240 { 241 $this->type = $type; 242 return $this; 243 } 244 245 /** 246 * @return string 247 */ 248 public function getStatus() 249 { 250 return $this->status; 251 } 252 253 /** 254 * @param string $status 255 * 256 * @return Issue 257 */ 258 public function setStatus($status) 259 { 260 $this->status = $status; 261 return $this; 262 } 263 264 /** 265 * @return mixed 266 */ 267 public function getParent() 268 { 269 return $this->parent; 270 } 271 272 public function setParent($key) 273 { 274 $this->parent = $key; 275 return $this; 276 } 277 278 /** 279 * @return array 280 */ 281 public function getComponents() 282 { 283 return $this->components; 284 } 285 286 /** 287 * @param array|string $components 288 * 289 * @return Issue 290 */ 291 public function setComponents($components) 292 { 293 if (is_string($components)) { 294 $components = array_filter(array_map('trim', explode(',', $components))); 295 } 296 if (!empty($components[0]['name'])) { 297 $components = array_column($components, 'name'); 298 } 299 $this->components = $components; 300 return $this; 301 } 302 303 /** 304 * @return array 305 */ 306 public function getLabels() 307 { 308 return $this->labels; 309 } 310 311 /** 312 * @param array|string $labels 313 * 314 * @return Issue 315 */ 316 public function setLabels($labels) 317 { 318 if (!is_array($labels)) { 319 $labels = array_filter(array_map('trim', explode(',', $labels))); 320 } 321 $this->labels = $labels; 322 return $this; 323 } 324 325 /** 326 * @return string 327 */ 328 public function getPriority() 329 { 330 return $this->priority; 331 } 332 333 /** 334 * @param string $priority 335 * 336 * @return Issue 337 */ 338 public function setPriority($priority) 339 { 340 $this->priority = $priority; 341 return $this; 342 } 343 344 /** 345 * @return string 346 */ 347 public function getDuedate() 348 { 349 return $this->duedate; 350 } 351 352 /** 353 * Set the issues duedate 354 * 355 * @param string $duedate 356 * 357 * @return Issue 358 */ 359 public function setDuedate($duedate) 360 { 361 $this->duedate = $duedate; 362 return $this; 363 } 364 365 /** 366 * @return array 367 */ 368 public function getVersions() 369 { 370 return $this->versions; 371 } 372 373 /** 374 * @param array|string $versions 375 * 376 * @return Issue 377 */ 378 public function setVersions($versions) 379 { 380 if (!is_array($versions)) { 381 $versions = array_map('trim', explode(',', $versions)); 382 } 383 if (!empty($versions[0]['name'])) { 384 $versions = array_map(function ($version) { 385 return $version['name']; 386 }, $versions); 387 } 388 $this->versions = $versions; 389 return $this; 390 } 391 392 /** 393 * @return int 394 */ 395 public function getUpdated() 396 { 397 return $this->updated; 398 } 399 400 /** 401 * @param string|int $updated 402 * 403 * @return Issue 404 */ 405 public function setUpdated($updated) 406 { 407 /** @var \helper_plugin_issuelinks_util $util */ 408 $util = plugin_load('helper', 'issuelinks_util'); 409 if (!$util->isValidTimeStamp($updated)) { 410 $updated = strtotime($updated); 411 } 412 $this->updated = (int)$updated; 413 return $this; 414 } 415 416 /** 417 * Get a fancy HTML-link to issue 418 * 419 * @param bool $addSummary add the issue's summary after it status 420 * 421 * @return string 422 */ 423 public function getIssueLinkHTML($addSummary = false) 424 { 425 $serviceProvider = ServiceProvider::getInstance(); 426 $service = $serviceProvider->getServices()[$this->serviceID]; 427 $name = $this->projectId . $service::getProjectIssueSeparator($this->isMergeRequest) . $this->issueId; 428 $url = $this->getIssueURL(); 429 430 $status = cleanID($this->getStatus()); 431 if ($status) { 432 $name .= $this->getformattedIssueStatus(); 433 } 434 if ($addSummary) { 435 $name .= ' ' . $this->getSummary(); 436 } 437 438 $classes = 'issuelink ' . cleanID($this->getType()) . ($this->isMergeRequest ? ' mergerequest' : ''); 439 440 $attributes = [ 441 'class' => $classes, 442 'target' => '_blank', 443 'rel' => 'noopener', 444 'data-service' => $this->serviceID, 445 'data-project' => $this->projectId, 446 'data-issueid' => $this->issueId, 447 'data-ismergerequest' => $this->isMergeRequest ? '1' : '0' 448 ]; 449 return "<a href=\"$url\" " . buildAttributes($attributes, true) . '>' . $this->getTypeHTML() . "$name</a>"; 450 } 451 452 /** 453 * @return string 454 */ 455 public function getIssueURL() 456 { 457 $serviceProvider = ServiceProvider::getInstance(); 458 $serviceClassName = $serviceProvider->getServices()[$this->serviceID]; 459 $service = $serviceClassName::getInstance(); 460 return $service->getIssueURL($this->projectId, $this->issueId, $this->isMergeRequest); 461 } 462 463 /** 464 * get the status of the issue as HTML string 465 * 466 * @param string|null $status 467 * 468 * @return string 469 */ 470 public function getformattedIssueStatus($status = null) 471 { 472 if ($status === null) { 473 $status = $this->getStatus(); 474 } 475 $status = strtolower($status); 476 return "<span class='mm__status " . cleanID($status) . "'>$status</span>"; 477 } 478 479 /** 480 * @return string 481 */ 482 public function getTypeHTML() 483 { 484 if ($this->isMergeRequest) { 485 return inlineSVG(__DIR__ . '/../images/mdi-source-pull.svg'); 486 } 487 $image = $this->getMaterialDesignTypeIcon(); 488 return "<img src='$image' alt='$this->type' />"; 489 } 490 491 /** 492 * ToDo: replace all with SVG 493 * 494 * @return string the path to the icon / base64 image if type unknown 495 */ 496 protected function getMaterialDesignTypeIcon() 497 { 498 $typeIcon = [ 499 'bug' => 'mdi-bug.png', 500 'story' => 'mdi-bookmark.png', 501 'epic' => 'mdi-flash.png', 502 'change_request' => 'mdi-plus.png', 503 'improvement' => 'mdi-arrow-up-thick.png', 504 'organisation_task' => 'mdi-calendar-text.png', 505 'technical_task' => 'mdi-source-branch.png', 506 'task' => 'mdi-check.png', 507 ]; 508 509 if (!isset($typeIcon[cleanID($this->type)])) { 510 return DOKU_URL . '/lib/plugins/issuelinks/images/mdi-help-circle-outline.png'; 511 } 512 513 return DOKU_URL . '/lib/plugins/issuelinks/images/' . $typeIcon[cleanID($this->type)]; 514 } 515 516 public function setAssignee($name, $avatar_url) 517 { 518 $this->assignee['name'] = $name; 519 $this->assignee['avatarURL'] = $avatar_url; 520 } 521 522 public function getAdditionalDataHTML() 523 { 524 $this->getFromService(); 525 $data = []; 526 if (!empty($this->assignee)) { 527 $data['avatarHTML'] = "<img src=\"{$this->assignee['avatarURL']}\" alt=\"{$this->assignee['name']}\">"; 528 } 529 if (!empty($this->labelData)) { 530 $labels = $this->getLabels(); 531 $data['fancyLabelsHTML'] = ''; 532 foreach ($labels as $label) { 533 $colors = ''; 534 $classes = 'label'; 535 if (isset($this->labelData[$label])) { 536 $colors = "style=\"background-color: {$this->labelData[$label]['background-color']};"; 537 $colors .= " color: {$this->labelData[$label]['color']};\""; 538 $classes .= ' color'; 539 } 540 $data['fancyLabelsHTML'] .= "<span class=\"$classes\" $colors>$label</span>"; 541 } 542 } 543 return $data; 544 } 545 546 public function getFromService() 547 { 548 $serviceProvider = ServiceProvider::getInstance(); 549 $serviceClassName = $serviceProvider->getServices()[$this->serviceID]; 550 $service = $serviceClassName::getInstance(); 551 552 try { 553 $service->retrieveIssue($this); 554 if ($this->isValid(true)) { 555 $this->saveToDB(); 556 } 557 } catch (IssueLinksException $e) { 558 $this->errors[] = $e; 559 $this->isValid = false; 560 return false; 561 } 562 return true; 563 } 564 565 /** 566 * Check if an issue is valid. 567 * 568 * The specific rules depend on the service and the cached value may also be set by other functions. 569 * 570 * @param bool $recheck force a validity check instead of using cached value if available 571 * 572 * @return bool 573 */ 574 public function isValid($recheck = false) 575 { 576 if ($recheck || $this->isValid === null) { 577 $serviceProvider = ServiceProvider::getInstance(); 578 $service = $serviceProvider->getServices()[$this->serviceID]; 579 $this->isValid = $service::isIssueValid($this); 580 } 581 return $this->isValid; 582 } 583 584 public function saveToDB() 585 { 586 /** @var \helper_plugin_issuelinks_db $db */ 587 $db = plugin_load('helper', 'issuelinks_db'); 588 return $db->saveIssue($this); 589 } 590 591 public function buildTooltipHTML() 592 { 593 $html = '<aside class="issueTooltip">'; 594 $html .= "<h1 class=\"issueTitle\">{$this->getSummary()}</h1>"; 595 $html .= "<div class='assigneeAvatar waiting'></div>"; 596 597 /** @var \helper_plugin_issuelinks_util $util */ 598 $util = plugin_load('helper', 'issuelinks_util'); 599 600 $components = $this->getComponents(); 601 if (!empty($components)) { 602 $html .= '<p class="components">'; 603 foreach ($components as $component) { 604 $html .= "<span class=\"component\">$component</span>"; 605 } 606 $html .= '</p>'; 607 } 608 609 $labels = $this->getLabels(); 610 if (!empty($labels)) { 611 $html .= '<p class="labels">'; 612 foreach ($labels as $label) { 613 $html .= "<span class=\"label\">$label</span>"; 614 } 615 $html .= '</p>'; 616 } 617 618 $html .= '<p class="descriptionTeaser">'; 619 $description = $this->getDescription(); 620 if ($description) { 621 $lines = explode("\n", $description); 622 $cnt = min(count($lines), 5); 623 for ($i = 0; $i < $cnt; $i += 1) { 624 $html .= hsc($lines[$i]) . "\n"; 625 } 626 } else { 627 $html .= $util->getLang('no issue description'); 628 } 629 $html .= '</p>'; 630 631 /** @var \helper_plugin_issuelinks_data $data */ 632 $data = $this->loadHelper('issuelinks_data'); 633 634 if (!$this->isMergeRequest) { 635 // show merge requests referencing this Issues 636 $mrs = $data->getMergeRequestsForIssue( 637 $this->getServiceName(), 638 $this->getProject(), 639 $this->issueId, 640 $this->isMergeRequest 641 ); 642 if (!empty($mrs)) { 643 $html .= '<div class="mergeRequests">'; 644 $html .= '<h2>Merge Requests</h2>'; 645 $html .= '<ul>'; 646 foreach ($mrs as $mr) { 647 $html .= '<li>'; 648 $a = "<a href=\"$mr[url]\">$mr[summary]</a>"; 649 $html .= $this->getformattedIssueStatus($mr['status']) . ' ' . $a; 650 $html .= '</li>'; 651 } 652 $html .= '</ul>'; 653 $html .= '</div>'; 654 } 655 } 656 657 $linkingPages = $data->getLinkingPages( 658 $this->getServiceName(), 659 $this->getProject(), 660 $this->issueId, 661 $this->isMergeRequest 662 ); 663 if (count($linkingPages)) { 664 $html .= '<div class="relatedPages">'; 665 $html .= '<h2>' . $util->getLang('linking pages') . '</h2>'; 666 $html .= '<ul>'; 667 foreach ($linkingPages as $linkingPage) { 668 $html .= '<li>'; 669 $html .= html_wikilink($linkingPage['page']); 670 $html .= '</li>'; 671 } 672 $html .= '</ul>'; 673 $html .= '</div>'; 674 } 675 676 $html .= '</aside>'; 677 return $html; 678 } 679 680 public function getServiceName() 681 { 682 return $this->serviceID; 683 } 684 685 /** 686 * @param string $labelName the background color without the leading # 687 * @param string $color 688 */ 689 public function setLabelData($labelName, $color) 690 { 691 $this->labelData[$labelName] = [ 692 'background-color' => $color, 693 'color' => $this->calculateColor($color), 694 ]; 695 } 696 697 /** 698 * Calculate if a white or black font-color should be with the given background color 699 * 700 * https://www.w3.org/TR/WCAG20/#relativeluminancedef 701 * http://stackoverflow.com/a/3943023/3293343 702 * 703 * @param string $color the background-color, without leading # 704 * 705 * @return string 706 */ 707 private function calculateColor($color) 708 { 709 /** @noinspection PrintfScanfArgumentsInspection */ 710 list($r, $g, $b) = array_map(function ($color8bit) { 711 $c = $color8bit / 255; 712 if ($c <= 0.03928) { 713 $cl = $c / 12.92; 714 } else { 715 $cl = pow(($c + 0.055) / 1.055, 2.4); 716 } 717 return $cl; 718 }, sscanf($color, "%02x%02x%02x")); 719 if ($r * 0.2126 + $g * 0.7152 + $b * 0.0722 > 0.179) { 720 return '#000000'; 721 } 722 return '#FFFFFF'; 723 } 724} 725