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