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 $assignees = $record['pageassignees'] . ',' . $record['autoassignees']; 246 return auth_isMember($assignees, $user, $groups); 247 } 248 249 /** 250 * Fetch all assignments for a given user, with additional page information, 251 * filtering already granted acknowledgements. 252 * 253 * @param string $user 254 * @param array $groups 255 * @return array|bool 256 */ 257 public function getUserAssignments($user, $groups) 258 { 259 $sqlite = $this->getDB(); 260 if (!$sqlite) return false; 261 262 $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, C.ack FROM assignments A 263 JOIN pages B 264 ON A.page = B.page 265 LEFT JOIN acks C 266 ON A.page = C.page AND ( (C.user = ? AND C.ack > B.lastmod) ) 267 WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees , ? , ?) 268 AND ack IS NULL"; 269 270 return $sqlite->queryAll($sql, $user, $user, implode('///', $groups)); 271 } 272 273 274 /** 275 * Resolve names of users assigned to a given page 276 * 277 * This can be slow on huge user bases! 278 * 279 * @param string $page 280 * @return array|false 281 */ 282 public function getPageAssignees($page) 283 { 284 $sqlite = $this->getDB(); 285 if (!$sqlite) return false; 286 /** @var AuthPlugin $auth */ 287 global $auth; 288 289 $sql = "SELECT pageassignees || ',' || autoassignees AS 'assignments' 290 FROM assignments 291 WHERE page = ?"; 292 $assignments = $sqlite->queryValue($sql, $page); 293 294 $users = []; 295 foreach (explode(',', $assignments) as $item) { 296 $item = trim($item); 297 if ($item === '') continue; 298 if ($item[0] == '@') { 299 $users = array_merge( 300 $users, 301 array_keys($auth->retrieveUsers(0, 0, ['grps' => substr($item, 1)])) 302 ); 303 } else { 304 $users[] = $item; 305 } 306 } 307 308 return array_unique($users); 309 } 310 311 // endregion 312 // region Assignment Patterns 313 314 /** 315 * Get all the assignment patterns 316 * @return array (pattern => assignees) 317 */ 318 public function getAssignmentPatterns() 319 { 320 $sqlite = $this->getDB(); 321 if (!$sqlite) return []; 322 323 $sql = "SELECT pattern, assignees FROM assignments_patterns"; 324 return $sqlite->queryKeyValueList($sql); 325 } 326 327 /** 328 * Save new assignment patterns 329 * 330 * This resaves all patterns and reapplies them 331 * 332 * @param array $patterns (pattern => assignees) 333 */ 334 public function saveAssignmentPatterns($patterns) 335 { 336 $sqlite = $this->getDB(); 337 if (!$sqlite) return; 338 339 $sqlite->getPdo()->beginTransaction(); 340 try { 341 342 /** @noinspection SqlWithoutWhere Remove all assignments */ 343 $sql = "UPDATE assignments SET autoassignees = ''"; 344 $sqlite->exec($sql); 345 346 /** @noinspection SqlWithoutWhere Remove all patterns */ 347 $sql = "DELETE FROM assignments_patterns"; 348 $sqlite->exec($sql); 349 350 // insert new patterns and gather affected pages 351 $pages = []; 352 353 $sql = "REPLACE INTO assignments_patterns (pattern, assignees) VALUES (?,?)"; 354 foreach ($patterns as $pattern => $assignees) { 355 $pattern = trim($pattern); 356 $assignees = trim($assignees); 357 if (!$pattern || !$assignees) continue; 358 $sqlite->exec($sql, [$pattern, $assignees]); 359 360 // patterns may overlap, so we need to gather all affected pages first 361 $affectedPages = $this->getPagesMatchingPattern($pattern); 362 foreach ($affectedPages as $page) { 363 if (isset($pages[$page])) { 364 $pages[$page] .= ',' . $assignees; 365 } else { 366 $pages[$page] = $assignees; 367 } 368 } 369 } 370 371 $sql = "INSERT INTO assignments (page, autoassignees) VALUES (?, ?) 372 ON CONFLICT(page) 373 DO UPDATE SET autoassignees = ?"; 374 foreach ($pages as $page => $assignees) { 375 // remove duplicates and empty entries 376 $assignees = join(',', array_unique(array_filter(array_map('trim', explode(',', $assignees))))); 377 $sqlite->exec($sql, [$page, $assignees, $assignees]); 378 } 379 } catch (Exception $e) { 380 $sqlite->getPdo()->rollBack(); 381 throw $e; 382 } 383 $sqlite->getPdo()->commit(); 384 } 385 386 /** 387 * Get all known pages that match the given pattern 388 * 389 * @param $pattern 390 * @return string[] 391 */ 392 public function getPagesMatchingPattern($pattern) 393 { 394 $sqlite = $this->getDB(); 395 if (!$sqlite) return []; 396 397 $sql = "SELECT page FROM pages WHERE MATCHES_PAGE_PATTERN(?, page)"; 398 $pages = $sqlite->queryAll($sql, $pattern); 399 400 return array_column($pages, 'page'); 401 } 402 403 // endregion 404 // region Acknowledgements 405 406 /** 407 * Has the given user acknowledged the given page? 408 * 409 * @param string $page 410 * @param string $user 411 * @return bool|int timestamp of acknowledgement or false 412 */ 413 public function hasUserAcknowledged($page, $user) 414 { 415 $sqlite = $this->getDB(); 416 if (!$sqlite) return false; 417 418 $sql = "SELECT ack 419 FROM acks A, pages B 420 WHERE A.page = B.page 421 AND A.page = ? 422 AND A.user = ? 423 AND A.ack >= B.lastmod"; 424 425 $acktime = $sqlite->queryValue($sql, $page, $user); 426 427 return $acktime ? (int)$acktime : false; 428 } 429 430 /** 431 * Timestamp of the latest acknowledgment of the given page 432 * by the given user 433 * 434 * @param string $page 435 * @param string $user 436 * @return bool|string 437 */ 438 public function getLatestUserAcknowledgement($page, $user) 439 { 440 $sqlite = $this->getDB(); 441 if (!$sqlite) return false; 442 443 $sql = "SELECT MAX(ack) 444 FROM acks 445 WHERE page = ? 446 AND user = ?"; 447 448 return $sqlite->queryValue($sql, [$page, $user]); 449 } 450 451 /** 452 * Save user's acknowledgement for a given page 453 * 454 * @param string $page 455 * @param string $user 456 * @return bool 457 */ 458 public function saveAcknowledgement($page, $user) 459 { 460 $sqlite = $this->getDB(); 461 if (!$sqlite) return false; 462 463 $sql = "INSERT INTO acks (page, user, ack) VALUES (?,?, strftime('%s','now'))"; 464 465 $sqlite->exec($sql, $page, $user); 466 return true; 467 468 } 469 470 /** 471 * Get all pages a user needs to acknowledge and the last acknowledge date 472 * 473 * @param string $user 474 * @param array $groups 475 * @return array|bool 476 */ 477 public function getUserAcknowledgements($user, $groups) 478 { 479 $sqlite = $this->getDB(); 480 if (!$sqlite) return false; 481 482 $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, MAX(C.ack) AS ack 483 FROM assignments A 484 JOIN pages B 485 ON A.page = B.page 486 LEFT JOIN acks C 487 ON A.page = C.page AND C.user = ? 488 WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees, ? , ?) 489 GROUP BY A.page 490 ORDER BY A.page 491 "; 492 493 return $sqlite->queryAll($sql, [$user, $user, implode('///', $groups)]); 494 } 495 496 /** 497 * Get ack status for all assigned users of a given page 498 * 499 * This can be slow! 500 * 501 * @param string $page 502 * @return array|false 503 */ 504 public function getPageAcknowledgements($page) 505 { 506 $users = $this->getPageAssignees($page); 507 if ($users === false) return false; 508 $sqlite = $this->getDB(); 509 if (!$sqlite) return false; 510 511 $ulist = join(',', array_map([$sqlite->getPdo(), 'quote'], $users)); 512 $sql = "SELECT A.page, A.lastmod, B.user, MAX(B.ack) AS ack 513 FROM pages A 514 LEFT JOIN acks B 515 ON A.page = B.page 516 AND B.user IN ($ulist) 517 WHERE A.page = ? 518 GROUP BY A.page, B.user 519 "; 520 $acknowledgements = $sqlite->queryAll($sql, $page); 521 522 // there should be at least one result, unless the page is unknown 523 if (!count($acknowledgements)) return false; 524 525 $baseinfo = [ 526 'page' => $acknowledgements[0]['page'], 527 'lastmod' => $acknowledgements[0]['lastmod'], 528 'user' => null, 529 'ack' => null, 530 ]; 531 532 // fill up the result with all users that never acknowledged the page 533 $combined = []; 534 foreach ($acknowledgements as $ack) { 535 if ($ack['user'] !== null) { 536 $combined[$ack['user']] = $ack; 537 } 538 } 539 foreach ($users as $user) { 540 if (!isset($combined[$user])) { 541 $combined[$user] = array_merge($baseinfo, ['user' => $user]); 542 } 543 } 544 545 ksort($combined); 546 return array_values($combined); 547 } 548 549 /** 550 * Returns all acknowledgements 551 * 552 * @param int $limit maximum number of results 553 * @return array|bool 554 */ 555 public function getAcknowledgements($limit = 100) 556 { 557 $sqlite = $this->getDB(); 558 if (!$sqlite) return false; 559 560 $sql = ' 561 SELECT A.page, A.user, B.lastmod, max(A.ack) AS ack 562 FROM acks A, pages B 563 WHERE A.page = B.page 564 GROUP BY A.user, A.page 565 ORDER BY ack DESC 566 LIMIT ? 567 '; 568 $acknowledgements = $sqlite->queryAll($sql, $limit); 569 570 return $acknowledgements; 571 } 572 573 // endregion 574} 575 576