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