1<?php 2 3use dokuwiki\ErrorHandler; 4use dokuwiki\Extension\AuthPlugin; 5use dokuwiki\plugin\sqlite\SQLiteDB; 6 7/** 8 * DokuWiki Plugin acknowledge (Helper Component) 9 * 10 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 11 * @author Andreas Gohr, Anna Dabrowska <dokuwiki@cosmocode.de> 12 */ 13class helper_plugin_acknowledge extends DokuWiki_Plugin 14{ 15 16 protected $db; 17 18 // region Database Management 19 20 /** 21 * Get SQLiteDB instance 22 * 23 * @return SQLiteDB|null 24 */ 25 public function getDB() 26 { 27 if ($this->db === null) { 28 try { 29 $this->db = new SQLiteDB('acknowledgement', __DIR__ . '/db'); 30 31 // register our custom functions 32 $this->db->getPdo()->sqliteCreateFunction('AUTH_ISMEMBER', [$this, 'auth_isMember'], -1); 33 $this->db->getPdo()->sqliteCreateFunction('MATCHES_PAGE_PATTERN', [$this, 'matchPagePattern'], 2); 34 } catch (\Exception $exception) { 35 if (defined('DOKU_UNITTEST')) throw new \RuntimeException('Could not load SQLite', 0, $exception); 36 ErrorHandler::logException($exception); 37 msg($this->getLang('error sqlite plugin missing'), -1); 38 return null; 39 } 40 } 41 return $this->db; 42 } 43 44 /** 45 * Wrapper function for auth_isMember which accepts groups as string 46 * 47 * @param string $memberList 48 * @param string $user 49 * @param string $groups 50 * @return bool 51 */ 52 public function auth_isMember($memberList, $user, $groups) 53 { 54 return auth_isMember($memberList, $user, explode('///', $groups)); 55 } 56 57 /** 58 * Fills the page index with all unknown pages from the fulltext index 59 * @return void 60 */ 61 public function updatePageIndex() 62 { 63 $sqlite = $this->getDB(); 64 if (!$sqlite) return; 65 66 $pages = idx_getIndex('page', ''); 67 $sql = "INSERT OR IGNORE INTO pages (page, lastmod) VALUES (?,?)"; 68 69 $sqlite->getPdo()->beginTransaction(); 70 foreach ($pages as $page) { 71 $page = trim($page); 72 $lastmod = @filemtime(wikiFN($page)); 73 if ($lastmod) { 74 try { 75 $sqlite->exec($sql, [$page, $lastmod]); 76 } catch (\Exception $exception) { 77 $sqlite->getPdo()->rollBack(); 78 throw $exception; 79 } 80 } 81 } 82 $sqlite->getPdo()->commit(); 83 } 84 85 /** 86 * Check if the given pattern matches the given page 87 * 88 * @param string $pattern the pattern to check against 89 * @param string $page the cleaned pageid to check 90 * @return bool 91 */ 92 public function matchPagePattern($pattern, $page) 93 { 94 if (trim($pattern, ':') == '**') return true; // match all 95 96 // regex patterns 97 if ($pattern[0] == '/') { 98 return (bool)preg_match($pattern, ":$page"); 99 } 100 101 $pns = ':' . getNS($page) . ':'; 102 103 $ans = ':' . cleanID($pattern) . ':'; 104 if (substr($pattern, -2) == '**') { 105 // upper namespaces match 106 if (strpos($pns, $ans) === 0) { 107 return true; 108 } 109 } elseif (substr($pattern, -1) == '*') { 110 // namespaces match exact 111 if ($ans == $pns) { 112 return true; 113 } 114 } else { 115 // exact match 116 if (cleanID($pattern) == $page) { 117 return true; 118 } 119 } 120 121 return false; 122 } 123 124 // endregion 125 // region Page Data 126 127 /** 128 * Delete a page 129 * 130 * Cascades to delete all assigned data, etc. 131 * 132 * @param string $page Page ID 133 */ 134 public function removePage($page) 135 { 136 $sqlite = $this->getDB(); 137 if (!$sqlite) return; 138 139 $sql = "DELETE FROM pages WHERE page = ?"; 140 $sqlite->exec($sql, $page); 141 } 142 143 /** 144 * Update last modified date of page if content has changed 145 * 146 * @param string $page Page ID 147 * @param int $lastmod timestamp of last non-minor change 148 */ 149 public function storePageDate($page, $lastmod, $newContent) 150 { 151 $changelog = new \dokuwiki\ChangeLog\PageChangeLog($page); 152 $revs = $changelog->getRevisions(0, 1); 153 154 // compare content 155 $oldContent = str_replace(NL, '', io_readFile(wikiFN($page, $revs[0]))); 156 $newContent = str_replace(NL, '', $newContent); 157 if ($oldContent === $newContent) return; 158 159 $sqlite = $this->getDB(); 160 if (!$sqlite) return; 161 162 $sql = "REPLACE INTO pages (page, lastmod) VALUES (?,?)"; 163 $sqlite->exec($sql, [$page, $lastmod]); 164 } 165 166 // endregion 167 // region Assignments 168 169 /** 170 * Clears direct assignments for a page 171 * 172 * @param string $page Page ID 173 */ 174 public function clearPageAssignments($page) 175 { 176 $sqlite = $this->getDB(); 177 if (!$sqlite) return; 178 179 $sql = "UPDATE assignments SET pageassignees = '' WHERE page = ?"; 180 $sqlite->exec($sql, $page); 181 } 182 183 /** 184 * Set assignees for a given page as manually specified 185 * 186 * @param string $page Page ID 187 * @param string $assignees 188 * @return void 189 */ 190 public function setPageAssignees($page, $assignees) 191 { 192 $sqlite = $this->getDB(); 193 if (!$sqlite) return; 194 195 $assignees = join(',', array_unique(array_filter(array_map('trim', explode(',', $assignees))))); 196 197 $sql = "REPLACE INTO assignments ('page', 'pageassignees') VALUES (?,?)"; 198 $sqlite->exec($sql, [$page, $assignees]); 199 } 200 201 /** 202 * Set assignees for a given page from the patterns 203 * @param string $page Page ID 204 */ 205 public function setAutoAssignees($page) 206 { 207 $sqlite = $this->getDB(); 208 if (!$sqlite) return; 209 210 $patterns = $this->getAssignmentPatterns(); 211 212 // given assignees 213 $assignees = ''; 214 215 // find all patterns that match the page and add the configured assignees 216 foreach ($patterns as $pattern => $assignees) { 217 if ($this->matchPagePattern($pattern, $page)) { 218 $assignees .= ',' . $assignees; 219 } 220 } 221 222 // remove duplicates and empty entries 223 $assignees = join(',', array_unique(array_filter(array_map('trim', explode(',', $assignees))))); 224 225 // store the assignees 226 $sql = "REPLACE INTO assignments ('page', 'autoassignees') VALUES (?,?)"; 227 $sqlite->exec($sql, [$page, $assignees]); 228 } 229 230 /** 231 * Is the given user one of the assignees for this page 232 * 233 * @param string $page Page ID 234 * @param string $user user name to check 235 * @param string[] $groups groups this user is in 236 * @return bool 237 */ 238 public function isUserAssigned($page, $user, $groups) 239 { 240 $sqlite = $this->getDB(); 241 if (!$sqlite) return false; 242 243 $sql = "SELECT pageassignees,autoassignees FROM assignments WHERE page = ?"; 244 $record = $sqlite->queryRecord($sql, $page); 245 if (!$record) return false; 246 $assignees = $record['pageassignees'] . ',' . $record['autoassignees']; 247 return auth_isMember($assignees, $user, $groups); 248 } 249 250 /** 251 * Fetch all assignments for a given user, with additional page information, 252 * by default filtering already granted acknowledgements. 253 * Filter can be switched off via $includeDone 254 * 255 * @param string $user 256 * @param array $groups 257 * @param bool $includeDone 258 * 259 * @return array|bool 260 */ 261 public function getUserAssignments($user, $groups, $includeDone = false) 262 { 263 $sqlite = $this->getDB(); 264 if (!$sqlite) return false; 265 266 $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, C.ack FROM assignments A 267 JOIN pages B 268 ON A.page = B.page 269 LEFT JOIN acks C 270 ON A.page = C.page AND ( (C.user = ? AND C.ack > B.lastmod) ) 271 WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees , ? , ?)"; 272 273 if (!$includeDone) { 274 $sql .= ' AND ack IS NULL'; 275 } 276 277 return $sqlite->queryAll($sql, $user, $user, implode('///', $groups)); 278 } 279 280 281 /** 282 * Resolve names of users assigned to a given page 283 * 284 * This can be slow on huge user bases! 285 * 286 * @param string $page 287 * @return array|false 288 */ 289 public function getPageAssignees($page) 290 { 291 $sqlite = $this->getDB(); 292 if (!$sqlite) return false; 293 /** @var AuthPlugin $auth */ 294 global $auth; 295 296 $sql = "SELECT pageassignees || ',' || autoassignees AS 'assignments' 297 FROM assignments 298 WHERE page = ?"; 299 $assignments = $sqlite->queryValue($sql, $page); 300 301 $users = []; 302 foreach (explode(',', $assignments) as $item) { 303 $item = trim($item); 304 if ($item === '') continue; 305 if ($item[0] == '@') { 306 $users = array_merge( 307 $users, 308 array_keys($auth->retrieveUsers(0, 0, ['grps' => substr($item, 1)])) 309 ); 310 } else { 311 $users[] = $item; 312 } 313 } 314 315 return array_unique($users); 316 } 317 318 // endregion 319 // region Assignment Patterns 320 321 /** 322 * Get all the assignment patterns 323 * @return array (pattern => assignees) 324 */ 325 public function getAssignmentPatterns() 326 { 327 $sqlite = $this->getDB(); 328 if (!$sqlite) return []; 329 330 $sql = "SELECT pattern, assignees FROM assignments_patterns"; 331 return $sqlite->queryKeyValueList($sql); 332 } 333 334 /** 335 * Save new assignment patterns 336 * 337 * This resaves all patterns and reapplies them 338 * 339 * @param array $patterns (pattern => assignees) 340 */ 341 public function saveAssignmentPatterns($patterns) 342 { 343 $sqlite = $this->getDB(); 344 if (!$sqlite) return; 345 346 $sqlite->getPdo()->beginTransaction(); 347 try { 348 349 /** @noinspection SqlWithoutWhere Remove all assignments */ 350 $sql = "UPDATE assignments SET autoassignees = ''"; 351 $sqlite->exec($sql); 352 353 /** @noinspection SqlWithoutWhere Remove all patterns */ 354 $sql = "DELETE FROM assignments_patterns"; 355 $sqlite->exec($sql); 356 357 // insert new patterns and gather affected pages 358 $pages = []; 359 360 $sql = "REPLACE INTO assignments_patterns (pattern, assignees) VALUES (?,?)"; 361 foreach ($patterns as $pattern => $assignees) { 362 $pattern = trim($pattern); 363 $assignees = trim($assignees); 364 if (!$pattern || !$assignees) continue; 365 $sqlite->exec($sql, [$pattern, $assignees]); 366 367 // patterns may overlap, so we need to gather all affected pages first 368 $affectedPages = $this->getPagesMatchingPattern($pattern); 369 foreach ($affectedPages as $page) { 370 if (isset($pages[$page])) { 371 $pages[$page] .= ',' . $assignees; 372 } else { 373 $pages[$page] = $assignees; 374 } 375 } 376 } 377 378 $sql = "INSERT INTO assignments (page, autoassignees) VALUES (?, ?) 379 ON CONFLICT(page) 380 DO UPDATE SET autoassignees = ?"; 381 foreach ($pages as $page => $assignees) { 382 // remove duplicates and empty entries 383 $assignees = join(',', array_unique(array_filter(array_map('trim', explode(',', $assignees))))); 384 $sqlite->exec($sql, [$page, $assignees, $assignees]); 385 } 386 } catch (Exception $e) { 387 $sqlite->getPdo()->rollBack(); 388 throw $e; 389 } 390 $sqlite->getPdo()->commit(); 391 } 392 393 /** 394 * Get all known pages that match the given pattern 395 * 396 * @param $pattern 397 * @return string[] 398 */ 399 public function getPagesMatchingPattern($pattern) 400 { 401 $sqlite = $this->getDB(); 402 if (!$sqlite) return []; 403 404 $sql = "SELECT page FROM pages WHERE MATCHES_PAGE_PATTERN(?, page)"; 405 $pages = $sqlite->queryAll($sql, $pattern); 406 407 return array_column($pages, 'page'); 408 } 409 410 // endregion 411 // region Acknowledgements 412 413 /** 414 * Has the given user acknowledged the given page? 415 * 416 * @param string $page 417 * @param string $user 418 * @return bool|int timestamp of acknowledgement or false 419 */ 420 public function hasUserAcknowledged($page, $user) 421 { 422 $sqlite = $this->getDB(); 423 if (!$sqlite) return false; 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 = $sqlite->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 $sqlite = $this->getDB(); 448 if (!$sqlite) return false; 449 450 $sql = "SELECT MAX(ack) 451 FROM acks 452 WHERE page = ? 453 AND user = ?"; 454 455 return $sqlite->queryValue($sql, [$page, $user]); 456 } 457 458 /** 459 * Save user's acknowledgement for a given page 460 * 461 * @param string $page 462 * @param string $user 463 * @return bool 464 */ 465 public function saveAcknowledgement($page, $user) 466 { 467 $sqlite = $this->getDB(); 468 if (!$sqlite) return false; 469 470 $sql = "INSERT INTO acks (page, user, ack) VALUES (?,?, strftime('%s','now'))"; 471 472 $sqlite->exec($sql, $page, $user); 473 return true; 474 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 = join(',', 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 584