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 * 127 * @return array 128 */ 129 public function getUsers() 130 { 131 /** @var AuthPlugin $auth */ 132 global $auth; 133 134 if (!$auth->canDo('getUsers')) { 135 return []; 136 } 137 138 $cb = function($k, $v) { 139 return [ 140 'value' => $k, 141 'label' => $k . ' (' . $v['name'] . ')' 142 ]; 143 }; 144 $users = $auth->retrieveUsers(); 145 $users = array_map($cb, array_keys($users), array_values($users)); 146 147 return $users; 148 } 149 150 // endregion 151 // region Page Data 152 153 /** 154 * Delete a page 155 * 156 * Cascades to delete all assigned data, etc. 157 * 158 * @param string $page Page ID 159 */ 160 public function removePage($page) 161 { 162 $sqlite = $this->getDB(); 163 if (!$sqlite) return; 164 165 $sql = "DELETE FROM pages WHERE page = ?"; 166 $sqlite->exec($sql, $page); 167 } 168 169 /** 170 * Update last modified date of page if content has changed 171 * 172 * @param string $page Page ID 173 * @param int $lastmod timestamp of last non-minor change 174 */ 175 public function storePageDate($page, $lastmod, $newContent) 176 { 177 $changelog = new PageChangeLog($page); 178 $revs = $changelog->getRevisions(0, 1); 179 180 // compare content 181 $oldContent = str_replace(NL, '', io_readFile(wikiFN($page, $revs[0]))); 182 $newContent = str_replace(NL, '', $newContent); 183 if ($oldContent === $newContent) return; 184 185 $sqlite = $this->getDB(); 186 if (!$sqlite) return; 187 188 $sql = "REPLACE INTO pages (page, lastmod) VALUES (?,?)"; 189 $sqlite->exec($sql, [$page, $lastmod]); 190 } 191 192 // endregion 193 // region Assignments 194 195 /** 196 * Clears direct assignments for a page 197 * 198 * @param string $page Page ID 199 */ 200 public function clearPageAssignments($page) 201 { 202 $sqlite = $this->getDB(); 203 if (!$sqlite) return; 204 205 $sql = "UPDATE assignments SET pageassignees = '' WHERE page = ?"; 206 $sqlite->exec($sql, $page); 207 } 208 209 /** 210 * Set assignees for a given page as manually specified 211 * 212 * @param string $page Page ID 213 * @param string $assignees 214 * @return void 215 */ 216 public function setPageAssignees($page, $assignees) 217 { 218 $sqlite = $this->getDB(); 219 if (!$sqlite) return; 220 221 $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees))))); 222 223 $sql = "REPLACE INTO assignments ('page', 'pageassignees') VALUES (?,?)"; 224 $sqlite->exec($sql, [$page, $assignees]); 225 } 226 227 /** 228 * Set assignees for a given page from the patterns 229 * @param string $page Page ID 230 */ 231 public function setAutoAssignees($page) 232 { 233 $sqlite = $this->getDB(); 234 if (!$sqlite) return; 235 236 $patterns = $this->getAssignmentPatterns(); 237 238 // given assignees 239 $assignees = ''; 240 241 // find all patterns that match the page and add the configured assignees 242 foreach ($patterns as $pattern => $assignees) { 243 if ($this->matchPagePattern($pattern, $page)) { 244 $assignees .= ',' . $assignees; 245 } 246 } 247 248 // remove duplicates and empty entries 249 $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees))))); 250 251 // store the assignees 252 $sql = "REPLACE INTO assignments ('page', 'autoassignees') VALUES (?,?)"; 253 $sqlite->exec($sql, [$page, $assignees]); 254 } 255 256 /** 257 * Is the given user one of the assignees for this page 258 * 259 * @param string $page Page ID 260 * @param string $user user name to check 261 * @param string[] $groups groups this user is in 262 * @return bool 263 */ 264 public function isUserAssigned($page, $user, $groups) 265 { 266 $sqlite = $this->getDB(); 267 if (!$sqlite) return false; 268 269 $sql = "SELECT pageassignees,autoassignees FROM assignments WHERE page = ?"; 270 $record = $sqlite->queryRecord($sql, $page); 271 if (!$record) return false; 272 $assignees = $record['pageassignees'] . ',' . $record['autoassignees']; 273 return auth_isMember($assignees, $user, $groups); 274 } 275 276 /** 277 * Fetch all assignments for a given user, with additional page information, 278 * by default filtering already granted acknowledgements. 279 * Filter can be switched off via $includeDone 280 * 281 * @param string $user 282 * @param array $groups 283 * @param bool $includeDone 284 * 285 * @return array|bool 286 */ 287 public function getUserAssignments($user, $groups, $includeDone = false) 288 { 289 $sqlite = $this->getDB(); 290 if (!$sqlite) return false; 291 292 $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, C.ack FROM assignments A 293 JOIN pages B 294 ON A.page = B.page 295 LEFT JOIN acks C 296 ON A.page = C.page AND ( (C.user = ? AND C.ack > B.lastmod) ) 297 WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees , ? , ?)"; 298 299 if (!$includeDone) { 300 $sql .= ' AND ack IS NULL'; 301 } 302 303 return $sqlite->queryAll($sql, $user, $user, implode('///', $groups)); 304 } 305 306 307 /** 308 * Resolve names of users assigned to a given page 309 * 310 * This can be slow on huge user bases! 311 * 312 * @param string $page 313 * @return array|false 314 */ 315 public function getPageAssignees($page) 316 { 317 $sqlite = $this->getDB(); 318 if (!$sqlite) return false; 319 /** @var AuthPlugin $auth */ 320 global $auth; 321 322 $sql = "SELECT pageassignees || ',' || autoassignees AS 'assignments' 323 FROM assignments 324 WHERE page = ?"; 325 $assignments = $sqlite->queryValue($sql, $page); 326 327 $users = []; 328 foreach (explode(',', $assignments) as $item) { 329 $item = trim($item); 330 if ($item === '') continue; 331 if ($item[0] == '@') { 332 $users = array_merge( 333 $users, 334 array_keys($auth->retrieveUsers(0, 0, ['grps' => substr($item, 1)])) 335 ); 336 } else { 337 $users[] = $item; 338 } 339 } 340 341 return array_unique($users); 342 } 343 344 // endregion 345 // region Assignment Patterns 346 347 /** 348 * Get all the assignment patterns 349 * @return array (pattern => assignees) 350 */ 351 public function getAssignmentPatterns() 352 { 353 $sqlite = $this->getDB(); 354 if (!$sqlite) return []; 355 356 $sql = "SELECT pattern, assignees FROM assignments_patterns"; 357 return $sqlite->queryKeyValueList($sql); 358 } 359 360 /** 361 * Save new assignment patterns 362 * 363 * This resaves all patterns and reapplies them 364 * 365 * @param array $patterns (pattern => assignees) 366 */ 367 public function saveAssignmentPatterns($patterns) 368 { 369 $sqlite = $this->getDB(); 370 if (!$sqlite) return; 371 372 $sqlite->getPdo()->beginTransaction(); 373 try { 374 375 /** @noinspection SqlWithoutWhere Remove all assignments */ 376 $sql = "UPDATE assignments SET autoassignees = ''"; 377 $sqlite->exec($sql); 378 379 /** @noinspection SqlWithoutWhere Remove all patterns */ 380 $sql = "DELETE FROM assignments_patterns"; 381 $sqlite->exec($sql); 382 383 // insert new patterns and gather affected pages 384 $pages = []; 385 386 $sql = "REPLACE INTO assignments_patterns (pattern, assignees) VALUES (?,?)"; 387 foreach ($patterns as $pattern => $assignees) { 388 $pattern = trim($pattern); 389 $assignees = trim($assignees); 390 if (!$pattern || !$assignees) continue; 391 $sqlite->exec($sql, [$pattern, $assignees]); 392 393 // patterns may overlap, so we need to gather all affected pages first 394 $affectedPages = $this->getPagesMatchingPattern($pattern); 395 foreach ($affectedPages as $page) { 396 if (isset($pages[$page])) { 397 $pages[$page] .= ',' . $assignees; 398 } else { 399 $pages[$page] = $assignees; 400 } 401 } 402 } 403 404 $sql = "INSERT INTO assignments (page, autoassignees) VALUES (?, ?) 405 ON CONFLICT(page) 406 DO UPDATE SET autoassignees = ?"; 407 foreach ($pages as $page => $assignees) { 408 // remove duplicates and empty entries 409 $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees))))); 410 $sqlite->exec($sql, [$page, $assignees, $assignees]); 411 } 412 } catch (Exception $e) { 413 $sqlite->getPdo()->rollBack(); 414 throw $e; 415 } 416 $sqlite->getPdo()->commit(); 417 } 418 419 /** 420 * Get all known pages that match the given pattern 421 * 422 * @param $pattern 423 * @return string[] 424 */ 425 public function getPagesMatchingPattern($pattern) 426 { 427 $sqlite = $this->getDB(); 428 if (!$sqlite) return []; 429 430 $sql = "SELECT page FROM pages WHERE MATCHES_PAGE_PATTERN(?, page)"; 431 $pages = $sqlite->queryAll($sql, $pattern); 432 433 return array_column($pages, 'page'); 434 } 435 436 // endregion 437 // region Acknowledgements 438 439 /** 440 * Has the given user acknowledged the given page? 441 * 442 * @param string $page 443 * @param string $user 444 * @return bool|int timestamp of acknowledgement or false 445 */ 446 public function hasUserAcknowledged($page, $user) 447 { 448 $sqlite = $this->getDB(); 449 if (!$sqlite) return false; 450 451 $sql = "SELECT ack 452 FROM acks A, pages B 453 WHERE A.page = B.page 454 AND A.page = ? 455 AND A.user = ? 456 AND A.ack >= B.lastmod"; 457 458 $acktime = $sqlite->queryValue($sql, $page, $user); 459 460 return $acktime ? (int)$acktime : false; 461 } 462 463 /** 464 * Timestamp of the latest acknowledgment of the given page 465 * by the given user 466 * 467 * @param string $page 468 * @param string $user 469 * @return bool|string 470 */ 471 public function getLatestUserAcknowledgement($page, $user) 472 { 473 $sqlite = $this->getDB(); 474 if (!$sqlite) return false; 475 476 $sql = "SELECT MAX(ack) 477 FROM acks 478 WHERE page = ? 479 AND user = ?"; 480 481 return $sqlite->queryValue($sql, [$page, $user]); 482 } 483 484 /** 485 * Save user's acknowledgement for a given page 486 * 487 * @param string $page 488 * @param string $user 489 * @return bool 490 */ 491 public function saveAcknowledgement($page, $user) 492 { 493 $sqlite = $this->getDB(); 494 if (!$sqlite) return false; 495 496 $sql = "INSERT INTO acks (page, user, ack) VALUES (?,?, strftime('%s','now'))"; 497 498 $sqlite->exec($sql, $page, $user); 499 return true; 500 } 501 502 /** 503 * Get all pages a user needs to acknowledge and the last acknowledge date 504 * 505 * @param string $user 506 * @param array $groups 507 * @return array|bool 508 */ 509 public function getUserAcknowledgements($user, $groups) 510 { 511 $sqlite = $this->getDB(); 512 if (!$sqlite) return false; 513 514 $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, MAX(C.ack) AS ack 515 FROM assignments A 516 JOIN pages B 517 ON A.page = B.page 518 LEFT JOIN acks C 519 ON A.page = C.page AND C.user = ? 520 WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees, ? , ?) 521 GROUP BY A.page 522 ORDER BY A.page 523 "; 524 525 return $sqlite->queryAll($sql, [$user, $user, implode('///', $groups)]); 526 } 527 528 /** 529 * Get ack status for all assigned users of a given page 530 * 531 * This can be slow! 532 * 533 * @param string $page 534 * @return array|false 535 */ 536 public function getPageAcknowledgements($page, $max = 0) 537 { 538 $users = $this->getPageAssignees($page); 539 if ($users === false) return false; 540 $sqlite = $this->getDB(); 541 if (!$sqlite) return false; 542 543 $ulist = implode(',', array_map([$sqlite->getPdo(), 'quote'], $users)); 544 $sql = "SELECT A.page, A.lastmod, B.user, MAX(B.ack) AS ack 545 FROM pages A 546 LEFT JOIN acks B 547 ON A.page = B.page 548 AND B.user IN ($ulist) 549 WHERE A.page = ? 550 GROUP BY A.page, B.user 551 "; 552 if ($max) $sql .= " LIMIT $max"; 553 $acknowledgements = $sqlite->queryAll($sql, $page); 554 555 // there should be at least one result, unless the page is unknown 556 if (!count($acknowledgements)) return false; 557 558 $baseinfo = [ 559 'page' => $acknowledgements[0]['page'], 560 'lastmod' => $acknowledgements[0]['lastmod'], 561 'user' => null, 562 'ack' => null, 563 ]; 564 565 // fill up the result with all users that never acknowledged the page 566 $combined = []; 567 foreach ($acknowledgements as $ack) { 568 if ($ack['user'] !== null) { 569 $combined[$ack['user']] = $ack; 570 } 571 } 572 foreach ($users as $user) { 573 if (!isset($combined[$user])) { 574 $combined[$user] = array_merge($baseinfo, ['user' => $user]); 575 } 576 } 577 578 ksort($combined); 579 return array_values($combined); 580 } 581 582 /** 583 * Returns all acknowledgements 584 * 585 * @param int $limit maximum number of results 586 * @return array|bool 587 */ 588 public function getAcknowledgements($limit = 100) 589 { 590 $sqlite = $this->getDB(); 591 if (!$sqlite) return false; 592 593 $sql = ' 594 SELECT A.page, A.user, B.lastmod, max(A.ack) AS ack 595 FROM acks A, pages B 596 WHERE A.page = B.page 597 GROUP BY A.user, A.page 598 ORDER BY ack DESC 599 LIMIT ? 600 '; 601 $acknowledgements = $sqlite->queryAll($sql, $limit); 602 603 return $acknowledgements; 604 } 605 606 // endregion 607} 608