1<?php 2 3use dokuwiki\Extension\AuthPlugin; 4 5/** 6 * DokuWiki Plugin acknowledge (Helper Component) 7 * 8 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 9 * @author Andreas Gohr, Anna Dabrowska <dokuwiki@cosmocode.de> 10 */ 11class helper_plugin_acknowledge extends DokuWiki_Plugin 12{ 13 14 // region Database Management 15 16 /** 17 * @return helper_plugin_sqlite|null 18 */ 19 public function getDB() 20 { 21 /** @var \helper_plugin_sqlite $sqlite */ 22 $sqlite = plugin_load('helper', 'sqlite'); 23 if ($sqlite === null) { 24 msg($this->getLang('error sqlite plugin missing'), -1); 25 return null; 26 } 27 $sqlite->getAdapter()->setUseNativeAlter(true); 28 if (!$sqlite->init('acknowledgement', __DIR__ . '/db')) { 29 return null; 30 } 31 32 $this->registerUDF($sqlite); 33 34 return $sqlite; 35 } 36 37 /** 38 * Register user defined functions 39 * 40 * @param helper_plugin_sqlite $sqlite 41 */ 42 protected function registerUDF($sqlite) 43 { 44 $sqlite->create_function('AUTH_ISMEMBER', [$this, 'auth_isMember'], -1); 45 $sqlite->create_function('MATCHES_PAGE_PATTERN', [$this, 'matchPagePattern'], 2); 46 } 47 48 /** 49 * Wrapper function for auth_isMember which accepts groups as string 50 * 51 * @param string $memberList 52 * @param string $user 53 * @param string $groups 54 * @return bool 55 */ 56 public function auth_isMember($memberList, $user, $groups) 57 { 58 return auth_isMember($memberList, $user, explode('///', $groups)); 59 } 60 61 /** 62 * Fills the page index with all unknown pages from the fulltext index 63 * @return void 64 */ 65 public function updatePageIndex() 66 { 67 $sqlite = $this->getDB(); 68 if (!$sqlite) return; 69 70 $pages = idx_getIndex('page', ''); 71 $sql = "INSERT OR IGNORE INTO pages (page, lastmod) VALUES (?,?)"; 72 73 $sqlite->query('BEGIN TRANSACTION'); 74 foreach ($pages as $page) { 75 $page = trim($page); 76 $lastmod = @filemtime(wikiFN($page)); 77 if ($lastmod) { 78 $sqlite->query($sql, $page, $lastmod); 79 } 80 } 81 $sqlite->query('COMMIT TRANSACTION'); 82 } 83 84 /** 85 * Check if the given pattern matches the given page 86 * 87 * @param string $pattern the pattern to check against 88 * @param string $page the cleaned pageid to check 89 * @return bool 90 */ 91 public function matchPagePattern($pattern, $page) 92 { 93 if (trim($pattern, ':') == '**') return true; // match all 94 95 // regex patterns 96 if ($pattern[0] == '/') { 97 return (bool)preg_match($pattern, ":$page"); 98 } 99 100 $pns = ':' . getNS($page) . ':'; 101 102 $ans = ':' . cleanID($pattern) . ':'; 103 if (substr($pattern, -2) == '**') { 104 // upper namespaces match 105 if (strpos($pns, $ans) === 0) { 106 return true; 107 } 108 } elseif (substr($pattern, -1) == '*') { 109 // namespaces match exact 110 if ($ans == $pns) { 111 return true; 112 } 113 } else { 114 // exact match 115 if (cleanID($pattern) == $page) { 116 return true; 117 } 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->query($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 \dokuwiki\ChangeLog\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->query($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->query($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 = join(',', array_unique(array_filter(array_map('trim', explode(',', $assignees))))); 195 196 $sql = "REPLACE INTO assignments ('page', 'pageassignees') VALUES (?,?)"; 197 $sqlite->query($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 = join(',', array_unique(array_filter(array_map('trim', explode(',', $assignees))))); 223 224 // store the assignees 225 $sql = "REPLACE INTO assignments ('page', 'autoassignees') VALUES (?,?)"; 226 $sqlite->query($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 $result = $sqlite->query($sql, $page); 244 $row = $sqlite->res2row($result); 245 $sqlite->res_close($result); 246 $assignees = $row['pageassignees'] . ',' . $row['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 $result = $sqlite->query($sql, $user, $user, implode('///', $groups)); 272 $assignments = $sqlite->res2arr($result); 273 $sqlite->res_close($result); 274 275 return $assignments; 276 } 277 278 279 /** 280 * Resolve names of users assigned to a given page 281 * 282 * This can be slow on huge user bases! 283 * 284 * @param string $page 285 * @return array|false 286 */ 287 public function getPageAssignees($page) 288 { 289 $sqlite = $this->getDB(); 290 if (!$sqlite) return false; 291 /** @var AuthPlugin $auth */ 292 global $auth; 293 294 $sql = "SELECT pageassignees || ',' || autoassignees AS 'assignments' 295 FROM assignments 296 WHERE page = ?"; 297 $result = $sqlite->query($sql, $page); 298 $assignments = $sqlite->res2single($result); 299 $sqlite->res_close($result); 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 $result = $sqlite->query($sql); 332 $patterns = $sqlite->res2arr($result); 333 $sqlite->res_close($result); 334 335 return array_combine( 336 array_column($patterns, 'pattern'), 337 array_column($patterns, 'assignees') 338 ); 339 } 340 341 /** 342 * Save new assignment patterns 343 * 344 * This resaves all patterns and reapplies them 345 * 346 * @param array $patterns (pattern => assignees) 347 */ 348 public function saveAssignmentPatterns($patterns) 349 { 350 $sqlite = $this->getDB(); 351 if (!$sqlite) return; 352 353 $sqlite->query('BEGIN TRANSACTION'); 354 355 /** @noinsp0ection SqlWithoutWhere Remove all assignments */ 356 $sql = "UPDATE assignments SET autoassignees = ''"; 357 $sqlite->query($sql); 358 359 /** @noinspection SqlWithoutWhere Remove all patterns */ 360 $sql = "DELETE FROM assignments_patterns"; 361 $sqlite->query($sql); 362 363 // insert new patterns and gather affected pages 364 $pages = []; 365 366 $sql = "REPLACE INTO assignments_patterns (pattern, assignees) VALUES (?,?)"; 367 foreach ($patterns as $pattern => $assignees) { 368 $pattern = trim($pattern); 369 $assignees = trim($assignees); 370 if (!$pattern || !$assignees) continue; 371 $sqlite->query($sql, $pattern, $assignees); 372 373 // patterns may overlap, so we need to gather all affected pages first 374 $affectedPages = $this->getPagesMatchingPattern($pattern); 375 foreach ($affectedPages as $page) { 376 if (isset($pages[$page])) { 377 $pages[$page] .= ',' . $assignees; 378 } else { 379 $pages[$page] = $assignees; 380 } 381 } 382 } 383 384 $sql = "INSERT INTO assignments (page, autoassignees) VALUES (?, ?) 385 ON CONFLICT(page) 386 DO UPDATE SET autoassignees = ?"; 387 foreach ($pages as $page => $assignees) { 388 // remove duplicates and empty entries 389 $assignees = join(',', array_unique(array_filter(array_map('trim', explode(',', $assignees))))); 390 $sqlite->query($sql, $page, $assignees, $assignees); 391 } 392 393 $sqlite->query('COMMIT TRANSACTION'); 394 } 395 396 /** 397 * Get all known pages that match the given pattern 398 * 399 * @param $pattern 400 * @return string[] 401 */ 402 public function getPagesMatchingPattern($pattern) 403 { 404 $sqlite = $this->getDB(); 405 if (!$sqlite) return []; 406 407 $sql = "SELECT page FROM pages WHERE MATCHES_PAGE_PATTERN(?, page)"; 408 $result = $sqlite->query($sql, $pattern); 409 $pages = $sqlite->res2arr($result); 410 $sqlite->res_close($result); 411 412 return array_column($pages, 'page'); 413 } 414 415 // endregion 416 // region Acknowledgements 417 418 /** 419 * Has the given user acknowledged the given page? 420 * 421 * @param string $page 422 * @param string $user 423 * @return bool|int timestamp of acknowledgement or false 424 */ 425 public function hasUserAcknowledged($page, $user) 426 { 427 $sqlite = $this->getDB(); 428 if (!$sqlite) return false; 429 430 $sql = "SELECT ack 431 FROM acks A, pages B 432 WHERE A.page = B.page 433 AND A.page = ? 434 AND A.user = ? 435 AND A.ack >= B.lastmod"; 436 437 $result = $sqlite->query($sql, $page, $user); 438 $acktime = $sqlite->res2single($result); 439 $sqlite->res_close($result); 440 441 return $acktime ? (int)$acktime : false; 442 } 443 444 /** 445 * Timestamp of the latest acknowledgment of the given page 446 * by the given user 447 * 448 * @param string $page 449 * @param string $user 450 * @return bool|string 451 */ 452 public function getLatestUserAcknowledgement($page, $user) 453 { 454 $sqlite = $this->getDB(); 455 if (!$sqlite) return false; 456 457 $sql = "SELECT MAX(ack) 458 FROM acks 459 WHERE page = ? 460 AND user = ?"; 461 462 $result = $sqlite->query($sql, $page, $user); 463 $latestAck = $sqlite->res2single($result); 464 $sqlite->res_close($result); 465 466 return $latestAck; 467 } 468 469 /** 470 * Save user's acknowledgement for a given page 471 * 472 * @param string $page 473 * @param string $user 474 * @return bool 475 */ 476 public function saveAcknowledgement($page, $user) 477 { 478 $sqlite = $this->getDB(); 479 if (!$sqlite) return false; 480 481 $sql = "INSERT INTO acks (page, user, ack) VALUES (?,?, strftime('%s','now'))"; 482 483 $result = $sqlite->query($sql, $page, $user); 484 $sqlite->res_close($result); 485 return true; 486 487 } 488 489 /** 490 * Get all pages a user needs to acknowledge and the last acknowledge date 491 * 492 * @param string $user 493 * @param array $groups 494 * @return array|bool 495 */ 496 public function getUserAcknowledgements($user, $groups) 497 { 498 $sqlite = $this->getDB(); 499 if (!$sqlite) return false; 500 501 $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, MAX(C.ack) AS ack 502 FROM assignments A 503 JOIN pages B 504 ON A.page = B.page 505 LEFT JOIN acks C 506 ON A.page = C.page AND C.user = ? 507 WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees, ? , ?) 508 GROUP BY A.page 509 ORDER BY A.page 510 "; 511 512 $result = $sqlite->query($sql, $user, $user, implode('///', $groups)); 513 $assignments = $sqlite->res2arr($result); 514 $sqlite->res_close($result); 515 516 return $assignments; 517 } 518 519 /** 520 * Get ack status for all assigned users of a given page 521 * 522 * This can be slow! 523 * 524 * @param string $page 525 * @return array|false 526 */ 527 public function getPageAcknowledgements($page) 528 { 529 $users = $this->getPageAssignees($page); 530 if ($users === false) return false; 531 $sqlite = $this->getDB(); 532 if (!$sqlite) return false; 533 534 $ulist = $sqlite->quote_and_join($users); 535 $sql = "SELECT A.page, A.lastmod, B.user, MAX(B.ack) AS ack 536 FROM pages A 537 LEFT JOIN acks B 538 ON A.page = B.page 539 AND B.user IN ($ulist) 540 WHERE A.page = ? 541 GROUP BY A.page, B.user 542 "; 543 $result = $sqlite->query($sql, $page); 544 $acknowledgements = $sqlite->res2arr($result); 545 $sqlite->res_close($result); 546 547 // there should be at least one result, unless the page is unknown 548 if (!count($acknowledgements)) return false; 549 550 $baseinfo = [ 551 'page' => $acknowledgements[0]['page'], 552 'lastmod' => $acknowledgements[0]['lastmod'], 553 'user' => null, 554 'ack' => null, 555 ]; 556 557 // fill up the result with all users that never acknowledged the page 558 $combined = []; 559 foreach ($acknowledgements as $ack) { 560 if ($ack['user'] !== null) { 561 $combined[$ack['user']] = $ack; 562 } 563 } 564 foreach ($users as $user) { 565 if (!isset($combined[$user])) { 566 $combined[$user] = array_merge($baseinfo, ['user' => $user]); 567 } 568 } 569 570 ksort($combined); 571 return array_values($combined); 572 } 573 574 /** 575 * Returns all acknowledgements 576 * 577 * @param int $limit maximum number of results 578 * @return array|bool 579 */ 580 public function getAcknowledgements($limit = 100) 581 { 582 $sqlite = $this->getDB(); 583 if (!$sqlite) return false; 584 585 $sql = ' 586 SELECT A.page, A.user, B.lastmod, max(A.ack) AS ack 587 FROM acks A, pages B 588 WHERE A.page = B.page 589 GROUP BY A.user, A.page 590 ORDER BY ack DESC 591 LIMIT ? 592 '; 593 $result = $sqlite->query($sql, $limit); 594 $acknowledgements = $sqlite->res2arr($result); 595 $sqlite->res_close($result); 596 597 return $acknowledgements; 598 } 599 600 // endregion 601} 602 603