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 * Get SQLiteDB instance 23 * 24 * @return SQLiteDB|null 25 */ 26 public function getDB() 27 { 28 if ($this->db === null) { 29 try { 30 $this->db = new SQLiteDB('acknowledgement', __DIR__ . '/db'); 31 32 // register our custom functions 33 $this->db->getPdo()->sqliteCreateFunction('AUTH_ISMEMBER', [$this, 'auth_isMember'], -1); 34 $this->db->getPdo()->sqliteCreateFunction('MATCHES_PAGE_PATTERN', [$this, 'matchPagePattern'], 2); 35 } catch (\Exception $exception) { 36 if (defined('DOKU_UNITTEST')) throw new \RuntimeException('Could not load SQLite', 0, $exception); 37 ErrorHandler::logException($exception); 38 msg($this->getLang('error sqlite plugin missing'), -1); 39 return null; 40 } 41 } 42 return $this->db; 43 } 44 45 /** 46 * Wrapper function for auth_isMember which accepts groups as string 47 * 48 * @param string $memberList 49 * @param string $user 50 * @param string $groups 51 * 52 * @return bool 53 */ 54 // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps 55 public function auth_isMember($memberList, $user, $groups) 56 { 57 return auth_isMember($memberList, $user, explode('///', $groups)); 58 } 59 60 /** 61 * Fills the page index with all unknown pages from the fulltext index 62 * @return void 63 */ 64 public function updatePageIndex() 65 { 66 $sqlite = $this->getDB(); 67 if (!$sqlite) return; 68 69 $pages = idx_getIndex('page', ''); 70 $sql = "INSERT OR IGNORE INTO pages (page, lastmod) VALUES (?,?)"; 71 72 $sqlite->getPdo()->beginTransaction(); 73 foreach ($pages as $page) { 74 $page = trim($page); 75 $lastmod = @filemtime(wikiFN($page)); 76 if ($lastmod) { 77 try { 78 $sqlite->exec($sql, [$page, $lastmod]); 79 } catch (\Exception $exception) { 80 $sqlite->getPdo()->rollBack(); 81 throw $exception; 82 } 83 } 84 } 85 $sqlite->getPdo()->commit(); 86 } 87 88 /** 89 * Check if the given pattern matches the given page 90 * 91 * @param string $pattern the pattern to check against 92 * @param string $page the cleaned pageid to check 93 * @return bool 94 */ 95 public function matchPagePattern($pattern, $page) 96 { 97 if (trim($pattern, ':') == '**') return true; // match all 98 99 // regex patterns 100 if ($pattern[0] == '/') { 101 return (bool)preg_match($pattern, ":$page"); 102 } 103 104 $pns = ':' . getNS($page) . ':'; 105 106 $ans = ':' . cleanID($pattern) . ':'; 107 if (substr($pattern, -2) == '**') { 108 // upper namespaces match 109 if (strpos($pns, $ans) === 0) { 110 return true; 111 } 112 } elseif (substr($pattern, -1) == '*') { 113 // namespaces match exact 114 if ($ans === $pns) { 115 return true; 116 } 117 } elseif (cleanID($pattern) == $page) { 118 // exact match 119 return true; 120 } 121 122 return false; 123 } 124 125 /** 126 * Returns all users, formatted for autocomplete 127 * 128 * @return array 129 */ 130 public function getUsers() 131 { 132 /** @var AuthPlugin $auth */ 133 global $auth; 134 135 if (!$auth->canDo('getUsers')) { 136 return []; 137 } 138 139 $cb = function ($k, $v) { 140 return [ 141 'value' => $k, 142 'label' => $k . ' (' . $v['name'] . ')' 143 ]; 144 }; 145 $users = $auth->retrieveUsers(); 146 $users = array_map($cb, array_keys($users), array_values($users)); 147 148 return $users; 149 } 150 151 // endregion 152 // region Page Data 153 154 /** 155 * Delete a page 156 * 157 * Cascades to delete all assigned data, etc. 158 * 159 * @param string $page Page ID 160 */ 161 public function removePage($page) 162 { 163 $sqlite = $this->getDB(); 164 if (!$sqlite) return; 165 166 $sql = "DELETE FROM pages WHERE page = ?"; 167 $sqlite->exec($sql, $page); 168 } 169 170 /** 171 * Update last modified date of page if content has changed 172 * 173 * @param string $page Page ID 174 * @param int $lastmod timestamp of last non-minor change 175 */ 176 public function storePageDate($page, $lastmod, $newContent) 177 { 178 $changelog = new PageChangeLog($page); 179 $revs = $changelog->getRevisions(0, 1); 180 181 // compare content 182 $oldContent = str_replace(NL, '', io_readFile(wikiFN($page, $revs[0]))); 183 $newContent = str_replace(NL, '', $newContent); 184 if ($oldContent === $newContent) return; 185 186 $sqlite = $this->getDB(); 187 if (!$sqlite) return; 188 189 $sql = "REPLACE INTO pages (page, lastmod) VALUES (?,?)"; 190 $sqlite->exec($sql, [$page, $lastmod]); 191 } 192 193 // endregion 194 // region Assignments 195 196 /** 197 * Clears direct assignments for a page 198 * 199 * @param string $page Page ID 200 */ 201 public function clearPageAssignments($page) 202 { 203 $sqlite = $this->getDB(); 204 if (!$sqlite) return; 205 206 $sql = "UPDATE assignments SET pageassignees = '' WHERE page = ?"; 207 $sqlite->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 $sqlite = $this->getDB(); 220 if (!$sqlite) return; 221 222 $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees))))); 223 224 $sql = "REPLACE INTO assignments ('page', 'pageassignees') VALUES (?,?)"; 225 $sqlite->exec($sql, [$page, $assignees]); 226 } 227 228 /** 229 * Set assignees for a given page from the patterns 230 * @param string $page Page ID 231 */ 232 public function setAutoAssignees($page) 233 { 234 $sqlite = $this->getDB(); 235 if (!$sqlite) return; 236 237 $patterns = $this->getAssignmentPatterns(); 238 239 // given assignees 240 $assignees = ''; 241 242 // find all patterns that match the page and add the configured assignees 243 foreach ($patterns as $pattern => $assignees) { 244 if ($this->matchPagePattern($pattern, $page)) { 245 $assignees .= ',' . $assignees; 246 } 247 } 248 249 // remove duplicates and empty entries 250 $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees))))); 251 252 // store the assignees 253 $sql = "REPLACE INTO assignments ('page', 'autoassignees') VALUES (?,?)"; 254 $sqlite->exec($sql, [$page, $assignees]); 255 } 256 257 /** 258 * Is the given user one of the assignees for this page 259 * 260 * @param string $page Page ID 261 * @param string $user user name to check 262 * @param string[] $groups groups this user is in 263 * @return bool 264 */ 265 public function isUserAssigned($page, $user, $groups) 266 { 267 $sqlite = $this->getDB(); 268 if (!$sqlite) return false; 269 270 $sql = "SELECT pageassignees,autoassignees FROM assignments WHERE page = ?"; 271 $record = $sqlite->queryRecord($sql, $page); 272 if (!$record) return false; 273 $assignees = $record['pageassignees'] . ',' . $record['autoassignees']; 274 return auth_isMember($assignees, $user, $groups); 275 } 276 277 /** 278 * Fetch all assignments for a given user, with additional page information, 279 * by default filtering already granted acknowledgements. 280 * Filter can be switched off via $includeDone 281 * 282 * @param string $user 283 * @param array $groups 284 * @param bool $includeDone 285 * 286 * @return array|bool 287 */ 288 public function getUserAssignments($user, $groups, $includeDone = false) 289 { 290 $sqlite = $this->getDB(); 291 if (!$sqlite) return false; 292 293 $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, C.ack FROM assignments A 294 JOIN pages B 295 ON A.page = B.page 296 LEFT JOIN acks C 297 ON A.page = C.page AND ( (C.user = ? AND C.ack > B.lastmod) ) 298 WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees , ? , ?)"; 299 300 if (!$includeDone) { 301 $sql .= ' AND ack IS NULL'; 302 } 303 304 return $sqlite->queryAll($sql, $user, $user, implode('///', $groups)); 305 } 306 307 308 /** 309 * Resolve names of users assigned to a given page 310 * 311 * This can be slow on huge user bases! 312 * 313 * @param string $page 314 * @return array|false 315 */ 316 public function getPageAssignees($page) 317 { 318 $sqlite = $this->getDB(); 319 if (!$sqlite) return false; 320 /** @var AuthPlugin $auth */ 321 global $auth; 322 323 $sql = "SELECT pageassignees || ',' || autoassignees AS 'assignments' 324 FROM assignments 325 WHERE page = ?"; 326 $assignments = $sqlite->queryValue($sql, $page); 327 328 $users = []; 329 foreach (explode(',', $assignments) as $item) { 330 $item = trim($item); 331 if ($item === '') continue; 332 if ($item[0] == '@') { 333 $users = array_merge( 334 $users, 335 array_keys($auth->retrieveUsers(0, 0, ['grps' => substr($item, 1)])) 336 ); 337 } else { 338 $users[] = $item; 339 } 340 } 341 342 return array_unique($users); 343 } 344 345 // endregion 346 // region Assignment Patterns 347 348 /** 349 * Get all the assignment patterns 350 * @return array (pattern => assignees) 351 */ 352 public function getAssignmentPatterns() 353 { 354 $sqlite = $this->getDB(); 355 if (!$sqlite) return []; 356 357 $sql = "SELECT pattern, assignees FROM assignments_patterns"; 358 return $sqlite->queryKeyValueList($sql); 359 } 360 361 /** 362 * Save new assignment patterns 363 * 364 * This resaves all patterns and reapplies them 365 * 366 * @param array $patterns (pattern => assignees) 367 */ 368 public function saveAssignmentPatterns($patterns) 369 { 370 $sqlite = $this->getDB(); 371 if (!$sqlite) return; 372 373 $sqlite->getPdo()->beginTransaction(); 374 try { 375 376 /** @noinspection SqlWithoutWhere Remove all assignments */ 377 $sql = "UPDATE assignments SET autoassignees = ''"; 378 $sqlite->exec($sql); 379 380 /** @noinspection SqlWithoutWhere Remove all patterns */ 381 $sql = "DELETE FROM assignments_patterns"; 382 $sqlite->exec($sql); 383 384 // insert new patterns and gather affected pages 385 $pages = []; 386 387 $sql = "REPLACE INTO assignments_patterns (pattern, assignees) VALUES (?,?)"; 388 foreach ($patterns as $pattern => $assignees) { 389 $pattern = trim($pattern); 390 $assignees = trim($assignees); 391 if (!$pattern || !$assignees) continue; 392 $sqlite->exec($sql, [$pattern, $assignees]); 393 394 // patterns may overlap, so we need to gather all affected pages first 395 $affectedPages = $this->getPagesMatchingPattern($pattern); 396 foreach ($affectedPages as $page) { 397 if (isset($pages[$page])) { 398 $pages[$page] .= ',' . $assignees; 399 } else { 400 $pages[$page] = $assignees; 401 } 402 } 403 } 404 405 $sql = "INSERT INTO assignments (page, autoassignees) VALUES (?, ?) 406 ON CONFLICT(page) 407 DO UPDATE SET autoassignees = ?"; 408 foreach ($pages as $page => $assignees) { 409 // remove duplicates and empty entries 410 $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees))))); 411 $sqlite->exec($sql, [$page, $assignees, $assignees]); 412 } 413 } catch (Exception $e) { 414 $sqlite->getPdo()->rollBack(); 415 throw $e; 416 } 417 $sqlite->getPdo()->commit(); 418 } 419 420 /** 421 * Get all known pages that match the given pattern 422 * 423 * @param $pattern 424 * @return string[] 425 */ 426 public function getPagesMatchingPattern($pattern) 427 { 428 $sqlite = $this->getDB(); 429 if (!$sqlite) return []; 430 431 $sql = "SELECT page FROM pages WHERE MATCHES_PAGE_PATTERN(?, page)"; 432 $pages = $sqlite->queryAll($sql, $pattern); 433 434 return array_column($pages, 'page'); 435 } 436 437 // endregion 438 // region Acknowledgements 439 440 /** 441 * Has the given user acknowledged the given page? 442 * 443 * @param string $page 444 * @param string $user 445 * @return bool|int timestamp of acknowledgement or false 446 */ 447 public function hasUserAcknowledged($page, $user) 448 { 449 $sqlite = $this->getDB(); 450 if (!$sqlite) return false; 451 452 $sql = "SELECT ack 453 FROM acks A, pages B 454 WHERE A.page = B.page 455 AND A.page = ? 456 AND A.user = ? 457 AND A.ack >= B.lastmod"; 458 459 $acktime = $sqlite->queryValue($sql, $page, $user); 460 461 return $acktime ? (int)$acktime : false; 462 } 463 464 /** 465 * Timestamp of the latest acknowledgment of the given page 466 * by the given user 467 * 468 * @param string $page 469 * @param string $user 470 * @return bool|string 471 */ 472 public function getLatestUserAcknowledgement($page, $user) 473 { 474 $sqlite = $this->getDB(); 475 if (!$sqlite) return false; 476 477 $sql = "SELECT MAX(ack) 478 FROM acks 479 WHERE page = ? 480 AND user = ?"; 481 482 return $sqlite->queryValue($sql, [$page, $user]); 483 } 484 485 /** 486 * Save user's acknowledgement for a given page 487 * 488 * @param string $page 489 * @param string $user 490 * @return bool 491 */ 492 public function saveAcknowledgement($page, $user) 493 { 494 $sqlite = $this->getDB(); 495 if (!$sqlite) return false; 496 497 $sql = "INSERT INTO acks (page, user, ack) VALUES (?,?, strftime('%s','now'))"; 498 499 $sqlite->exec($sql, $page, $user); 500 return true; 501 } 502 503 /** 504 * Get all pages that a user needs to acknowledge and/or the last acknowledgement infos 505 * depending on the (optional) filter based on status of the acknowledgements. 506 * 507 * @param string $user 508 * @param array $groups 509 * @param string $status Optional status filter, can be all (default), current or due 510 * 511 * @return array|bool 512 */ 513 public function getUserAcknowledgements($user, $groups, $status = '') 514 { 515 $sqlite = $this->getDB(); 516 if (!$sqlite) return false; 517 518 // filter clause 519 switch ($status) { 520 case 'current': 521 $having = ' HAVING ack >= B.lastmod '; 522 break; 523 case 'due': 524 $having = ' HAVING (ack IS NULL) OR (ack < B.lastmod) '; 525 break; 526 case 'outdated': 527 $having = ' HAVING ack < B.lastmod '; 528 break; 529 case 'all': 530 default: 531 $having = ''; 532 break; 533 } 534 535 // query 536 $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, MAX(C.ack) AS ack 537 FROM assignments A 538 JOIN pages B 539 ON A.page = B.page 540 LEFT JOIN acks C 541 ON A.page = C.page AND C.user = ? 542 WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees, ? , ?) 543 GROUP BY A.page"; 544 $sql .= $having; 545 $sql .= " 546 ORDER BY A.page"; 547 548 return $sqlite->queryAll($sql, [$user, $user, implode('///', $groups)]); 549 } 550 551 /** 552 * Get ack status for all assigned users of a given page 553 * 554 * This can be slow! 555 * 556 * @param string $page 557 * @param string $user 558 * @return array|false 559 */ 560 public function getPageAcknowledgements($page, $max = 0, $user = '') 561 { 562 $users = $this->getPageAssignees($page); 563 if ($users === false) return false; 564 $sqlite = $this->getDB(); 565 if (!$sqlite) return false; 566 567 $ulist = implode(',', array_map([$sqlite->getPdo(), 'quote'], $users)); 568 $sql = "SELECT A.page, A.lastmod, B.user, MAX(B.ack) AS ack 569 FROM pages A 570 LEFT JOIN acks B 571 ON A.page = B.page 572 AND B.user IN ($ulist) 573 WHERE A.page = ? 574 GROUP BY A.page, B.user 575 "; 576 if ($max) $sql .= " LIMIT $max"; 577 $acknowledgements = $sqlite->queryAll($sql, $page); 578 579 // there should be at least one result, unless the page is unknown 580 if (!count($acknowledgements)) return false; 581 582 $baseinfo = [ 583 'page' => $acknowledgements[0]['page'], 584 'lastmod' => $acknowledgements[0]['lastmod'], 585 'user' => null, 586 'ack' => null, 587 ]; 588 589 // fill up the result with all users that never acknowledged the page 590 $combined = []; 591 foreach ($acknowledgements as $ack) { 592 if ($ack['user'] !== null) { 593 $combined[$ack['user']] = $ack; 594 } 595 } 596 foreach ($users as $user) { 597 if (!isset($combined[$user])) { 598 $combined[$user] = array_merge($baseinfo, ['user' => $user]); 599 } 600 } 601 602 ksort($combined); 603 return array_values($combined); 604 } 605 606 /** 607 * Returns all acknowledgements 608 * 609 * @param int $limit maximum number of results 610 * @return array|bool 611 */ 612 public function getAcknowledgements($limit = 100) 613 { 614 $sqlite = $this->getDB(); 615 if (!$sqlite) return false; 616 617 $sql = ' 618 SELECT A.page, A.user, B.lastmod, max(A.ack) AS ack 619 FROM acks A, pages B 620 WHERE A.page = B.page 621 GROUP BY A.user, A.page 622 ORDER BY ack DESC 623 LIMIT ? 624 '; 625 $acknowledgements = $sqlite->queryAll($sql, $limit); 626 627 return $acknowledgements; 628 } 629 630 // endregion 631} 632