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