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 => $assignees) { 238 if ($this->matchPagePattern($pattern, $page)) { 239 $assignees .= ',' . $assignees; 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 * Get all pages that a user needs to acknowledge and/or the last acknowledgement infos 472 * depending on the (optional) filter based on status of the acknowledgements. 473 * 474 * @param string $user 475 * @param array $groups 476 * @param string $status Optional status filter, can be all (default), current or due 477 * 478 * @return array|bool 479 */ 480 public function getUserAcknowledgements($user, $groups, $status = '') 481 { 482 $filterClause = $this->getFilterClause($status, 'B'); 483 484 // query 485 $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, MAX(C.ack) AS ack 486 FROM assignments A 487 JOIN pages B 488 ON A.page = B.page 489 LEFT JOIN acks C 490 ON A.page = C.page AND C.user = ? 491 WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees, ? , ?) 492 GROUP BY A.page"; 493 $sql .= $filterClause; 494 $sql .= " 495 ORDER BY A.page"; 496 497 return $this->db->queryAll($sql, [$user, $user, implode('///', $groups)]); 498 } 499 500 /** 501 * Get ack status for all assigned users of a given page 502 * 503 * This can be slow! 504 * 505 * @param string $page 506 * @param string $user 507 * @param string $status 508 * @param int $max 509 * 510 * @return array 511 */ 512 public function getPageAcknowledgements($page, $user = '', $status = '', $max = 0) 513 { 514 $userClause = ''; 515 $filterClause = ''; 516 $params[] = $page; 517 518 // filtering for user from input or using saved assignees? 519 if ($user) { 520 $users = [$user]; 521 $userClause = ' AND (B.user = ? OR B.user IS NULL) '; 522 $params[] = $user; 523 } else { 524 $users = $this->getPageAssignees($page); 525 if (!$users) return []; 526 } 527 528 if ($status === 'current') { 529 $filterClause = ' AND ACK >= A.lastmod '; 530 } 531 532 $ulist = implode(',', array_map([$this->db->getPdo(), 'quote'], $users)); 533 $sql = "SELECT A.page, A.lastmod, B.user, MAX(B.ack) AS ack 534 FROM pages A 535 LEFT JOIN acks B 536 ON A.page = B.page 537 AND B.user IN ($ulist) 538 WHERE A.page = ? $userClause $filterClause"; 539 $sql .= " GROUP BY A.page, B.user "; 540 if ($max) $sql .= " LIMIT $max"; 541 542 $acknowledgements = $this->db->queryAll($sql, $params); 543 544 if ($status === 'current') { 545 return $acknowledgements; 546 } 547 548 // there should be at least one result, unless the page is unknown 549 if (!count($acknowledgements)) return $acknowledgements; 550 551 $baseinfo = [ 552 'page' => $acknowledgements[0]['page'], 553 'lastmod' => $acknowledgements[0]['lastmod'], 554 'user' => null, 555 'ack' => null, 556 ]; 557 558 // fill up the result with all users that never acknowledged the page 559 $combined = []; 560 foreach ($acknowledgements as $ack) { 561 if ($ack['user'] !== null) { 562 $combined[$ack['user']] = $ack; 563 } 564 } 565 foreach ($users as $user) { 566 if (!isset($combined[$user])) { 567 $combined[$user] = array_merge($baseinfo, ['user' => $user]); 568 } 569 } 570 571 // finally remove current acknowledgements if filter is used 572 // this cannot be done in SQL without loss of data, 573 // filtering must happen last, otherwise removed current acks will be re-added as due 574 if ($status === 'due') { 575 $combined = array_filter($combined, function ($info) { 576 return $info['ack'] < $info['lastmod']; 577 }); 578 } 579 580 ksort($combined); 581 return array_values($combined); 582 } 583 584 /** 585 * Returns all acknowledgements 586 * 587 * @param int $limit maximum number of results 588 * @return array 589 */ 590 public function getAcknowledgements($limit = 100) 591 { 592 $sql = ' 593 SELECT A.page, A.user, B.lastmod, max(A.ack) AS ack 594 FROM acks A, pages B 595 WHERE A.page = B.page 596 GROUP BY A.user, A.page 597 ORDER BY ack DESC 598 LIMIT ? 599 '; 600 return $this->db->queryAll($sql, $limit); 601 } 602 603 /** 604 * Returns a filter clause for acknowledgement queries depending on wanted status. 605 * 606 * @param string $status 607 * @param string $alias Table alias used in the SQL query 608 * @return string 609 */ 610 protected function getFilterClause($status, $alias) 611 { 612 switch ($status) { 613 case 'current': 614 $filterClause = " HAVING ack >= $alias.lastmod "; 615 break; 616 case 'due': 617 $filterClause = " HAVING (ack IS NULL) OR (ack < $alias.lastmod) "; 618 break; 619 case 'outdated': 620 $filterClause = " HAVING ack < $alias.lastmod "; 621 break; 622 case 'all': 623 default: 624 $filterClause = ''; 625 break; 626 } 627 return $filterClause; 628 } 629 630 // endregion 631} 632