1<?php 2 3use dokuwiki\Extension\Plugin; 4use dokuwiki\ChangeLog\PageChangeLog; 5use dokuwiki\ErrorHandler; 6use dokuwiki\Extension\AuthPlugin; 7use dokuwiki\plugin\sqlite\SQLiteDB; 8 9/** 10 * DokuWiki Plugin acknowledge (Helper Component) 11 * 12 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 13 * @author Andreas Gohr, Anna Dabrowska <dokuwiki@cosmocode.de> 14 */ 15class helper_plugin_acknowledge extends Plugin 16{ 17 protected $db; 18 19 // region Database Management 20 21 /** 22 * Constructor 23 * 24 * @return void 25 * @throws Exception 26 */ 27 public function __construct() 28 { 29 if ($this->db === null) { 30 try { 31 $this->db = new SQLiteDB('acknowledgement', __DIR__ . '/db'); 32 33 // register our custom functions 34 $this->db->getPdo()->sqliteCreateFunction('AUTH_ISMEMBER', [$this, 'auth_isMember'], -1); 35 $this->db->getPdo()->sqliteCreateFunction('MATCHES_PAGE_PATTERN', [$this, 'matchPagePattern'], 2); 36 } catch (\Exception $exception) { 37 if (defined('DOKU_UNITTEST')) throw new \RuntimeException('Could not load SQLite', 0, $exception); 38 ErrorHandler::logException($exception); 39 msg($this->getLang('error sqlite plugin missing'), -1); 40 throw $exception; 41 } 42 } 43 } 44 45 /** 46 * Wrapper for test DB access 47 * 48 * @return SQLiteDB 49 */ 50 public function getDB() 51 { 52 return $this->db; 53 } 54 55 /** 56 * Wrapper function for auth_isMember which accepts groups as string 57 * 58 * @param string $memberList 59 * @param string $user 60 * @param string $groups 61 * 62 * @return bool 63 */ 64 // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps 65 public function auth_isMember($memberList, $user, $groups) 66 { 67 return auth_isMember($memberList, $user, explode('///', $groups)); 68 } 69 70 /** 71 * Fills the page index with all unknown pages from the fulltext index 72 * @return void 73 */ 74 public function updatePageIndex() 75 { 76 $pages = idx_getIndex('page', ''); 77 $sql = "INSERT OR IGNORE INTO pages (page, lastmod) VALUES (?,?)"; 78 79 $this->db->getPdo()->beginTransaction(); 80 foreach ($pages as $page) { 81 $page = trim($page); 82 $lastmod = @filemtime(wikiFN($page)); 83 if ($lastmod) { 84 try { 85 $this->db->exec($sql, [$page, $lastmod]); 86 } catch (\Exception $exception) { 87 $this->db->getPdo()->rollBack(); 88 throw $exception; 89 } 90 } 91 } 92 $this->db->getPdo()->commit(); 93 } 94 95 /** 96 * Check if the given pattern matches the given page 97 * 98 * @param string $pattern the pattern to check against 99 * @param string $page the cleaned pageid to check 100 * @return bool 101 */ 102 public function matchPagePattern($pattern, $page) 103 { 104 if (trim($pattern, ':') == '**') return true; // match all 105 106 // regex patterns 107 if ($pattern[0] == '/') { 108 return (bool)preg_match($pattern, ":$page"); 109 } 110 111 $pns = ':' . getNS($page) . ':'; 112 113 $ans = ':' . cleanID($pattern) . ':'; 114 if (substr($pattern, -2) == '**') { 115 // upper namespaces match 116 if (strpos($pns, $ans) === 0) { 117 return true; 118 } 119 } elseif (substr($pattern, -1) == '*') { 120 // namespaces match exact 121 if ($ans === $pns) { 122 return true; 123 } 124 } elseif (cleanID($pattern) == $page) { 125 // exact match 126 return true; 127 } 128 129 return false; 130 } 131 132 /** 133 * Returns all users, formatted for autocomplete 134 * 135 * @return array 136 */ 137 public function getUsers() 138 { 139 /** @var AuthPlugin $auth */ 140 global $auth; 141 142 if (!$auth->canDo('getUsers')) { 143 return []; 144 } 145 146 $cb = function ($k, $v) { 147 return [ 148 'value' => $k, 149 'label' => $k . ' (' . $v['name'] . ')' 150 ]; 151 }; 152 $users = $auth->retrieveUsers(); 153 $users = array_map($cb, array_keys($users), array_values($users)); 154 155 return $users; 156 } 157 158 // endregion 159 // region Page Data 160 161 /** 162 * Delete a page 163 * 164 * Cascades to delete all assigned data, etc. 165 * 166 * @param string $page Page ID 167 */ 168 public function removePage($page) 169 { 170 $sql = "DELETE FROM pages WHERE page = ?"; 171 $this->db->exec($sql, $page); 172 } 173 174 /** 175 * Update last modified date of page if content has changed 176 * 177 * @param string $page Page ID 178 * @param int $lastmod timestamp of last non-minor change 179 */ 180 public function storePageDate($page, $lastmod, $newContent) 181 { 182 $changelog = new PageChangeLog($page); 183 $revs = $changelog->getRevisions(0, 1); 184 185 // compare content 186 if (!empty($revs)) { 187 $oldContent = str_replace(NL, '', io_readFile(wikiFN($page, $revs[0]))); 188 $newContent = str_replace(NL, '', $newContent); 189 if ($oldContent === $newContent) return; 190 } 191 192 $sql = "REPLACE INTO pages (page, lastmod) VALUES (?,?)"; 193 $this->db->exec($sql, [$page, $lastmod]); 194 } 195 196 // endregion 197 // region Assignments 198 199 /** 200 * Clears direct assignments for a page 201 * 202 * @param string $page Page ID 203 */ 204 public function clearPageAssignments($page) 205 { 206 $sql = "UPDATE assignments SET pageassignees = '' WHERE page = ?"; 207 $this->db->exec($sql, $page); 208 } 209 210 /** 211 * Set assignees for a given page as manually specified 212 * 213 * @param string $page Page ID 214 * @param string $assignees 215 * @return void 216 */ 217 public function setPageAssignees($page, $assignees) 218 { 219 $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees))))); 220 221 $sql = "REPLACE INTO assignments ('page', 'pageassignees') VALUES (?,?)"; 222 $this->db->exec($sql, [$page, $assignees]); 223 } 224 225 /** 226 * Set assignees for a given page from the patterns 227 * @param string $page Page ID 228 */ 229 public function setAutoAssignees($page) 230 { 231 $patterns = $this->getAssignmentPatterns(); 232 233 // given assignees 234 $assignees = ''; 235 236 // find all patterns that match the page and add the configured assignees 237 foreach ($patterns as $pattern => $assign) { 238 if ($this->matchPagePattern($pattern, $page)) { 239 $assignees .= ',' . $assign; 240 } 241 } 242 243 // remove duplicates and empty entries 244 $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees))))); 245 246 // store the assignees 247 $sql = "REPLACE INTO assignments ('page', 'autoassignees') VALUES (?,?)"; 248 $this->db->exec($sql, [$page, $assignees]); 249 } 250 251 /** 252 * Is the given user one of the assignees for this page 253 * 254 * @param string $page Page ID 255 * @param string $user user name to check 256 * @param string[] $groups groups this user is in 257 * @return bool 258 */ 259 public function isUserAssigned($page, $user, $groups) 260 { 261 $sql = "SELECT pageassignees,autoassignees FROM assignments WHERE page = ?"; 262 $record = $this->db->queryRecord($sql, $page); 263 if (!$record) return false; 264 $assignees = $record['pageassignees'] . ',' . $record['autoassignees']; 265 return auth_isMember($assignees, $user, $groups); 266 } 267 268 /** 269 * Fetch all assignments for a given user, with additional page information, 270 * by default filtering already granted acknowledgements. 271 * Filter can be switched off via $includeDone 272 * 273 * @param string $user 274 * @param array $groups 275 * @param bool $includeDone 276 * 277 * @return array|bool 278 */ 279 public function getUserAssignments($user, $groups, $includeDone = false) 280 { 281 $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, C.ack FROM assignments A 282 JOIN pages B 283 ON A.page = B.page 284 LEFT JOIN acks C 285 ON A.page = C.page AND ( (C.user = ? AND C.ack > B.lastmod) ) 286 WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees , ? , ?)"; 287 288 if (!$includeDone) { 289 $sql .= ' AND ack IS NULL'; 290 } 291 292 return $this->db->queryAll($sql, $user, $user, implode('///', $groups)); 293 } 294 295 /** 296 * Resolve names of users assigned to a given page 297 * 298 * This can be slow on huge user bases! 299 * 300 * @param string $page 301 * @return array|false 302 */ 303 public function getPageAssignees($page) 304 { 305 /** @var AuthPlugin $auth */ 306 global $auth; 307 308 $sql = "SELECT pageassignees || ',' || autoassignees AS 'assignments' 309 FROM assignments 310 WHERE page = ?"; 311 $assignments = $this->db->queryValue($sql, $page); 312 313 $users = []; 314 foreach (explode(',', $assignments) as $item) { 315 $item = trim($item); 316 if ($item === '') continue; 317 if ($item[0] == '@') { 318 $users = array_merge( 319 $users, 320 array_keys($auth->retrieveUsers(0, 0, ['grps' => substr($item, 1)])) 321 ); 322 } else { 323 $users[] = $item; 324 } 325 } 326 327 return array_unique($users); 328 } 329 330 // endregion 331 // region Assignment Patterns 332 333 /** 334 * Get all the assignment patterns 335 * @return array (pattern => assignees) 336 */ 337 public function getAssignmentPatterns() 338 { 339 $sql = "SELECT pattern, assignees FROM assignments_patterns"; 340 return $this->db->queryKeyValueList($sql); 341 } 342 343 /** 344 * Save new assignment patterns 345 * 346 * This resaves all patterns and reapplies them 347 * 348 * @param array $patterns (pattern => assignees) 349 */ 350 public function saveAssignmentPatterns($patterns) 351 { 352 $this->db->getPdo()->beginTransaction(); 353 try { 354 355 /** @noinspection SqlWithoutWhere Remove all assignments */ 356 $sql = "UPDATE assignments SET autoassignees = ''"; 357 $this->db->exec($sql); 358 359 /** @noinspection SqlWithoutWhere Remove all patterns */ 360 $sql = "DELETE FROM assignments_patterns"; 361 $this->db->exec($sql); 362 363 // insert new patterns and gather affected pages 364 $pages = []; 365 366 $sql = "REPLACE INTO assignments_patterns (pattern, assignees) VALUES (?,?)"; 367 foreach ($patterns as $pattern => $assignees) { 368 $pattern = trim($pattern); 369 $assignees = trim($assignees); 370 if (!$pattern || !$assignees) continue; 371 $this->db->exec($sql, [$pattern, $assignees]); 372 373 // patterns may overlap, so we need to gather all affected pages first 374 $affectedPages = $this->getPagesMatchingPattern($pattern); 375 foreach ($affectedPages as $page) { 376 if (isset($pages[$page])) { 377 $pages[$page] .= ',' . $assignees; 378 } else { 379 $pages[$page] = $assignees; 380 } 381 } 382 } 383 384 $sql = "INSERT INTO assignments (page, autoassignees) VALUES (?, ?) 385 ON CONFLICT(page) 386 DO UPDATE SET autoassignees = ?"; 387 foreach ($pages as $page => $assignees) { 388 // remove duplicates and empty entries 389 $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees))))); 390 $this->db->exec($sql, [$page, $assignees, $assignees]); 391 } 392 } catch (Exception $e) { 393 $this->db->getPdo()->rollBack(); 394 throw $e; 395 } 396 $this->db->getPdo()->commit(); 397 } 398 399 /** 400 * Get all known pages that match the given pattern 401 * 402 * @param $pattern 403 * @return string[] 404 */ 405 public function getPagesMatchingPattern($pattern) 406 { 407 $sql = "SELECT page FROM pages WHERE MATCHES_PAGE_PATTERN(?, page)"; 408 $pages = $this->db->queryAll($sql, $pattern); 409 410 return array_column($pages, 'page'); 411 } 412 413 // endregion 414 // region Acknowledgements 415 416 /** 417 * Has the given user acknowledged the given page? 418 * 419 * @param string $page 420 * @param string $user 421 * @return bool|int timestamp of acknowledgement or false 422 */ 423 public function hasUserAcknowledged($page, $user) 424 { 425 $sql = "SELECT ack 426 FROM acks A, pages B 427 WHERE A.page = B.page 428 AND A.page = ? 429 AND A.user = ? 430 AND A.ack >= B.lastmod"; 431 432 $acktime = $this->db->queryValue($sql, $page, $user); 433 434 return $acktime ? (int)$acktime : false; 435 } 436 437 /** 438 * Timestamp of the latest acknowledgment of the given page 439 * by the given user 440 * 441 * @param string $page 442 * @param string $user 443 * @return bool|string 444 */ 445 public function getLatestUserAcknowledgement($page, $user) 446 { 447 $sql = "SELECT MAX(ack) 448 FROM acks 449 WHERE page = ? 450 AND user = ?"; 451 452 return $this->db->queryValue($sql, [$page, $user]); 453 } 454 455 /** 456 * Save user's acknowledgement for a given page 457 * 458 * @param string $page 459 * @param string $user 460 * @return bool 461 */ 462 public function saveAcknowledgement($page, $user) 463 { 464 $sql = "INSERT INTO acks (page, user, ack) VALUES (?,?, strftime('%s','now'))"; 465 466 $this->db->exec($sql, $page, $user); 467 return true; 468 } 469 470 /** 471 * Save an acknowledgement with an explicit timestamp. 472 * Useful for imports and tests. Ignores duplicates. 473 * 474 * @param string $page 475 * @param string $user 476 * @param int $time 477 * @return void 478 */ 479 public function importAcknowledgement($page, $user, $time) 480 { 481 $sql = "INSERT OR IGNORE INTO acks (page, user, ack) VALUES (?,?,?)"; 482 $this->db->exec($sql, [$page, $user, $time]); 483 } 484 485 /** 486 * Get all pages that a user needs to acknowledge and/or the last acknowledgement infos 487 * depending on the (optional) filter based on status of the acknowledgements. 488 * 489 * @param string $user 490 * @param array $groups 491 * @param string $status Optional status filter, can be all (default), current or due 492 * 493 * @return array|bool 494 */ 495 public function getUserAcknowledgements($user, $groups, $status = '') 496 { 497 $filterClause = $this->getFilterClause($status, 'B'); 498 499 // query 500 $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, MAX(C.ack) AS ack 501 FROM assignments A 502 JOIN pages B 503 ON A.page = B.page 504 LEFT JOIN acks C 505 ON A.page = C.page AND C.user = ? 506 WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees, ? , ?) 507 GROUP BY A.page"; 508 $sql .= $filterClause; 509 $sql .= " 510 ORDER BY A.page"; 511 512 return $this->db->queryAll($sql, [$user, $user, implode('///', $groups)]); 513 } 514 515 /** 516 * Get ack status for all assigned users of a given page 517 * 518 * This can be slow! 519 * 520 * @param string $page 521 * @param string $user 522 * @param string $status 523 * @param int $max 524 * 525 * @return array 526 */ 527 public function getPageAcknowledgements($page, $user = '', $status = '', $max = 0) 528 { 529 $userClause = ''; 530 $filterClause = ''; 531 $params[] = $page; 532 533 // filtering for user from input or using saved assignees? 534 if ($user) { 535 $users = [$user]; 536 $userClause = ' AND (B.user = ? OR B.user IS NULL) '; 537 $params[] = $user; 538 } else { 539 $users = $this->getPageAssignees($page); 540 if (!$users) return []; 541 } 542 543 if ($status === 'current') { 544 $filterClause = ' AND ACK >= A.lastmod '; 545 } 546 547 $ulist = implode(',', array_map([$this->db->getPdo(), 'quote'], $users)); 548 $sql = "SELECT A.page, A.lastmod, B.user, MAX(B.ack) AS ack 549 FROM pages A 550 LEFT JOIN acks B 551 ON A.page = B.page 552 AND B.user IN ($ulist) 553 WHERE A.page = ? $userClause $filterClause"; 554 $sql .= " GROUP BY A.page, B.user "; 555 if ($max) $sql .= " LIMIT $max"; 556 557 $acknowledgements = $this->db->queryAll($sql, $params); 558 559 if ($status === 'current') { 560 return $acknowledgements; 561 } 562 563 // there should be at least one result, unless the page is unknown 564 if (!count($acknowledgements)) return $acknowledgements; 565 566 $baseinfo = [ 567 'page' => $acknowledgements[0]['page'], 568 'lastmod' => $acknowledgements[0]['lastmod'], 569 'user' => null, 570 'ack' => null, 571 ]; 572 573 // fill up the result with all users that never acknowledged the page 574 $combined = []; 575 foreach ($acknowledgements as $ack) { 576 if ($ack['user'] !== null) { 577 $combined[$ack['user']] = $ack; 578 } 579 } 580 foreach ($users as $user) { 581 if (!isset($combined[$user])) { 582 $combined[$user] = array_merge($baseinfo, ['user' => $user]); 583 } 584 } 585 586 // finally remove current acknowledgements if filter is used 587 // this cannot be done in SQL without loss of data, 588 // filtering must happen last, otherwise removed current acks will be re-added as due 589 if ($status === 'due') { 590 $combined = array_filter($combined, function ($info) { 591 return $info['ack'] < $info['lastmod']; 592 }); 593 } 594 595 ksort($combined); 596 return array_values($combined); 597 } 598 599 /** 600 * Count up-to-date and due acknowledgements for a given page 601 * 602 * Uses potentially slow getPageAssignees() 603 * 604 * @param string $page 605 * @return array{required:int, current:int, due:int} 606 */ 607 public function getPageAcknowledgementCounts($page) 608 { 609 $users = $this->getPageAssignees($page); 610 if (!$users) { 611 return ['required' => 0, 'current' => 0, 'due' => 0]; 612 } 613 614 $ulist = implode(',', array_map([$this->db->getPdo(), 'quote'], $users)); 615 $sql = "SELECT COUNT(*) FROM ( 616 SELECT B.user 617 FROM pages A 618 LEFT JOIN acks B 619 ON A.page = B.page 620 AND B.user IN ($ulist) 621 WHERE A.page = ? 622 GROUP BY B.user 623 HAVING MAX(B.ack) >= A.lastmod 624 )"; 625 $current = (int)$this->db->queryValue($sql, $page); 626 627 $required = count($users); 628 return [ 629 'required' => $required, 630 'current' => $current, 631 'due' => $required - $current, 632 ]; 633 } 634 635 /** 636 * Returns all acknowledgements 637 * 638 * @param int $limit maximum number of results 639 * @return array 640 */ 641 public function getAcknowledgements($limit = 100) 642 { 643 $sql = ' 644 SELECT A.page, A.user, B.lastmod, max(A.ack) AS ack 645 FROM acks A, pages B 646 WHERE A.page = B.page 647 GROUP BY A.user, A.page 648 ORDER BY ack DESC 649 LIMIT ? 650 '; 651 return $this->db->queryAll($sql, $limit); 652 } 653 654 /** 655 * Aggregate acknowledgement statistics as a drill-down into a namespace, plus a wiki-wide 656 * total. 657 * 658 * @param string $ns namespace to drill into ('' = wiki root / top level) 659 * @return array { 660 * namespaces: array<string, array{required:int, acked:int, pages:int, haschildren:bool}>, 661 * keyed by the immediate child namespace within $ns ('' = root pages), 662 * total: array{required:int, acked:int, pages:int} wiki-wide total 663 * } 664 */ 665 public function getStatistics($ns = '') 666 { 667 $depth = $ns === '' ? 0 : count(explode(':', $ns)); 668 669 $sql = "SELECT page FROM assignments WHERE TRIM(pageassignees || autoassignees) != ''"; 670 $pages = $this->db->queryAll($sql); 671 672 $namespaces = []; 673 $total = ['required' => 0, 'acked' => 0, 'pages' => 0]; 674 675 foreach ($pages as $row) { 676 $page = $row['page']; 677 678 $acknowledgements = $this->getPageAcknowledgements($page); 679 $required = count($acknowledgements); 680 if (!$required) continue; 681 682 $acked = 0; 683 foreach ($acknowledgements as $ack) { 684 if ($ack['ack'] !== null && $ack['ack'] >= $ack['lastmod']) { 685 $acked++; 686 } 687 } 688 689 // total is always wiki-wide 690 $total['required'] += $required; 691 $total['acked'] += $acked; 692 $total['pages'] += 1; 693 694 $pageNS = getNS($page); 695 $pageNS = ($pageNS === false) ? '' : $pageNS; 696 697 // not in a namespace, or in current ns or it's subnamespaces 698 $inScope = $ns === '' || $pageNS === $ns || str_starts_with($pageNS . ':', $ns . ':'); 699 if (!$inScope) continue; 700 701 // group namespaces 702 $segments = $pageNS === '' ? [] : explode(':', $pageNS); 703 $key = implode(':', array_slice($segments, 0, $depth + 1)); 704 705 if (!isset($namespaces[$key])) { 706 $namespaces[$key] = ['required' => 0, 'acked' => 0, 'pages' => 0, 'haschildren' => false]; 707 } 708 $namespaces[$key]['required'] += $required; 709 $namespaces[$key]['acked'] += $acked; 710 $namespaces[$key]['pages'] += 1; 711 if (count($segments) > $depth + 1) { 712 $namespaces[$key]['haschildren'] = true; 713 } 714 } 715 716 ksort($namespaces); 717 718 return ['namespaces' => $namespaces, 'total' => $total]; 719 } 720 721 /** 722 * Returns a filter clause for acknowledgement queries depending on wanted status. 723 * 724 * @param string $status 725 * @param string $alias Table alias used in the SQL query 726 * @return string 727 */ 728 protected function getFilterClause($status, $alias) 729 { 730 switch ($status) { 731 case 'current': 732 $filterClause = " HAVING ack >= $alias.lastmod "; 733 break; 734 case 'due': 735 $filterClause = " HAVING (ack IS NULL) OR (ack < $alias.lastmod) "; 736 break; 737 case 'outdated': 738 $filterClause = " HAVING ack < $alias.lastmod "; 739 break; 740 case 'all': 741 default: 742 $filterClause = ''; 743 break; 744 } 745 return $filterClause; 746 } 747 748 // endregion 749 // region Plugin Integrations 750 751 /** 752 * Check status of the approve plugin (if installed and integration enabled in config) 753 * 754 * @param string $page page id 755 * @return bool true only if page is tracked by approve plugin but not in 'approved' status 756 */ 757 public function isBlockedByApprove($page) 758 { 759 if (!$this->getConf('approve_integration')) return false; 760 761 /** @var helper_plugin_approve_db $approve */ 762 $approve = plugin_load('helper', 'approve_db'); 763 if (!$approve) return false; 764 765 // page not handled by approve 766 if ($approve->getPageMetadata($page) === null) return false; 767 768 // check if current revision is approved 769 $currentRev = (int)@filemtime(wikiFN($page)); 770 $approveRev = $approve->getPageRevision($page, $currentRev); 771 772 return $approveRev['status'] !== 'approved'; 773 } 774 775 // endregion 776} 777