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