1<?php 2// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project 3// 4// All Rights Reserved. See copyright.txt for details and a complete list of authors. 5// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details. 6// $Id$ 7 8class MonitorLib 9{ 10 private $queue = []; 11 12 /** 13 * Provides the list of priorities available for notifications. 14 */ 15 function getPriorities() 16 { 17 static $priorities; 18 if ($priorities) { 19 return $priorities; 20 } 21 22 $priorities = [ 23 'none' => ['label' => '', 'description' => null], 24 'critical' => ['label' => tr('Critical'), 'description' => tr('Immediate notification by email.'), 'class' => 'label-danger'], 25 'high' => ['label' => tr('High'), 'description' => tr('Will be sent to you with the next periodic digest.'), 'class' => 'label-warning'], 26 'low' => ['label' => tr('Low'), 'description' => tr('Included in your personalized recent changes feed.'), 'class' => 'label-info'], 27 ]; 28 29 global $prefs; 30 if ($prefs['monitor_digest'] != 'y') { 31 unset($priorities['high']); 32 } 33 34 return $priorities; 35 } 36 37 /** 38 * Provides the complete list of notifications that can affect a 39 * specific object in the system, including all of it's supported 40 * structures, like translation sets. 41 * 42 * @param user login name 43 * @param type standard object type 44 * @param object full itemId 45 */ 46 function getOptions($user, $type, $object) 47 { 48 global $prefs; 49 50 $tikilib = TikiLib::lib('tiki'); 51 $userId = $tikilib->get_user_id($user); 52 53 // Events applicable for this object 54 $events = $this->getApplicableEvents($type); 55 $options = []; 56 57 // Include object directly 58 $options[] = $this->gatherOptions($userId, $events, $type, $object); 59 60 // Include translation set 61 if ($this->hasMultilingual($type)) { 62 // Using fake types - wiki page -> wiki page trans 63 // article -> article trans 64 $options[] = $this->gatherOptions($userId, $events, "$type trans", $object); 65 } 66 67 if ($prefs['feature_wiki_structure'] == 'y' && $type == 'wiki page') { 68 $structlib = TikiLib::lib('struct'); 69 $structures = $structlib->get_page_structures($object); 70 foreach ($structures as $row) { 71 $path = $structlib->get_structure_path($row['req_page_ref_id']); 72 $path = array_reverse($path); 73 foreach ($path as $level => $entry) { 74 $options[] = $this->gatherOptions($userId, $events, 'structure', $entry['page_ref_id'], $this->getStructureLabel($level, $entry)); 75 } 76 } 77 } 78 79 if ($prefs['feature_forums'] == 'y' && $type == 'forum post') { 80 $post = TikiLib::lib('comments')->get_comment($object); 81 $options[] = $this->gatherOptions($userId, $events, 'forum', $post['object']); 82 } 83 84 if ($prefs['feature_trackers'] == 'y' && $type == 'trackeritem') { 85 $item = TikiLib::lib('trk')->get_item_info($object); 86 $options[] = $this->gatherOptions($userId, $events, 'tracker', $item['trackerId']); 87 } 88 89 // Include any category and parent category 90 if ($prefs['feature_categories'] == 'y') { 91 $categlib = TikiLib::lib('categ'); 92 $categories = $categlib->get_object_categories($type, $object); 93 $parents = $categlib->get_with_parents($categories); 94 95 foreach ($parents as $categoryId) { 96 $perms = Perms::get('category', $categoryId); 97 if ($perms->view_category) { 98 $options[] = array_map(function ($item) use ($categories) { 99 $item['isParent'] = ! in_array($item['object'], $categories); 100 return $item; 101 }, $this->gatherOptions($userId, $events, 'category', $categoryId)); 102 } 103 } 104 } 105 106 // Global / Catch-all always applicable, except for tiki.save, which would 107 // cause too much noise. 108 $events = array_filter($events, function ($e) { 109 return ! $e['local']; 110 }); 111 $options[] = $this->gatherOptions($userId, $events, 'global', null); 112 113 return call_user_func_array('array_merge', $options); 114 } 115 116 /** 117 * Method used to enumerate all targets being triggered by an event. 118 * Used to generate a single lookup query on event trigger. 119 */ 120 private function collectTargets($args) 121 { 122 global $prefs; 123 124 $type = $args['type']; 125 $object = $args['object']; 126 127 if ($prefs['feature_categories'] == 'y') { 128 $categlib = TikiLib::lib('categ'); 129 $categories = $categlib->get_object_categories($type, $object); 130 $categories = $categlib->get_with_parents($categories); 131 $targets = array_map(function ($categoryId) { 132 return "category:$categoryId"; 133 }, $categories); 134 } 135 136 list($type, $objectId) = $this->cleanObjectId($type, $object); 137 $targets[] = 'global'; 138 $targets[] = "$type:$objectId"; 139 140 if ($this->hasMultilingual($type)) { 141 $targets = array_merge($targets, $this->getMultilingualTargets($type, $objectId)); 142 } 143 144 if ($prefs['feature_wiki_structure'] == 'y' && $type == 'wiki page') { 145 $structlib = TikiLib::lib('struct'); 146 $structures = $structlib->get_page_structures($object); 147 foreach ($structures as $row) { 148 $path = $structlib->get_structure_path($row['req_page_ref_id']); 149 foreach ($path as $entry) { 150 $targets[] = "structure:{$entry['page_ref_id']}"; 151 } 152 } 153 } 154 155 if ($prefs['feature_forums'] == 'y' && $type == 'forum post') { 156 if (! empty($args['forum_id'])) { 157 $targets[] = "forum:{$args['forum_id']}"; 158 } 159 if (! empty($args['parent_id'])) { 160 $targets[] = "forum post:{$args['parent_id']}"; 161 } 162 } 163 164 if ($prefs['feature_trackers'] == 'y' && $type == 'trackeritem') { 165 if (! empty($args['trackerId'])) { 166 $targets[] = "tracker:{$args['trackerId']}"; 167 } 168 } 169 170 return $targets; 171 } 172 173 private function table() 174 { 175 return TikiDb::get()->table('tiki_user_monitors'); 176 } 177 178 /** 179 * Replaces the current priority for an event/target pair, for a specific user. 180 */ 181 function replacePriority($user, $event, $target, $priority) 182 { 183 $tikilib = TikiLib::lib('tiki'); 184 $userId = $tikilib->get_user_id($user); 185 186 if ($userId === -1 || ! $userId) { 187 return false; 188 } 189 190 $priorities = $this->getPriorities(); 191 if (! isset($priorities[$priority])) { 192 return false; 193 } 194 195 $table = $this->table(); 196 197 $base = ['userId' => $userId, 'target' => $target, 'event' => $event]; 198 199 if ($priority === 'none') { 200 $table->delete($base); 201 } else { 202 $table->insertOrUpdate(['priority' => $priority], $base); 203 } 204 205 return true; 206 } 207 208 /** 209 * Bind all events required to process notifications. 210 * One event is bound per active event type to collect the 211 * notifications to be sent out. A final event is sent out on 212 * shutdown to process the queued notifications. 213 */ 214 function bindEvents(Tiki_Event_Manager $events) 215 { 216 $events->bind('tiki.process.shutdown', function () { 217 $this->finalEvent(); 218 }); 219 220 $db = TikiDb::get(); 221 $list = $db->fetchAll('SELECT DISTINCT event FROM tiki_user_monitors', null, -1, -1, TikiDb::ERR_NONE); 222 223 // Ignore errors to avoid locking out users 224 if ($list) { 225 foreach ($list as $row) { 226 $event = $row['event']; 227 $events->bind($event, function ($args, $originalEvent) use ($event) { 228 $this->handleEvent($args, $originalEvent, $event); 229 }); 230 } 231 } 232 } 233 234 private function handleEvent($args, $originalEvent, $registeredEvent) 235 { 236 if (! isset($args['type']) || ! isset($args['object'])) { 237 return; 238 } 239 240 $eventId = $args['EVENT_ID']; 241 242 // Handle newly encountered events 243 if (! isset($this->queue[$eventId])) { 244 $this->queue[$eventId] = [ 245 'event' => $originalEvent, 246 'arguments' => $args, 247 'events' => [], 248 'force' => null, 249 ]; 250 } 251 252 $this->queue[$eventId]['events'][] = $registeredEvent; 253 } 254 255 function directNotification($priority, $userId, $event, $args) 256 { 257 if ($userId==0 && isset($args['groupname'])) { 258 $this->queue[$args['EVENT_ID']] = [ 259 'event' => $event, 260 'arguments' => $args, 261 'events' => [], 262 'force' => [ 263 'priority' => $priority."grp", 264 'userId' => TikiDb::get()->table('users_groups')->fetchOne('id', ['groupName' => $args['groupname']]), 265 ], 266 ]; 267 } 268 elseif ($userId > 0) { 269 $this->queue[$args['EVENT_ID']] = [ 270 'event' => $event, 271 'arguments' => $args, 272 'events' => [], 273 'force' => [ 274 'priority' => $priority, 275 'userId' => $userId, 276 ], 277 ]; 278 } 279 } 280 281 private function finalEvent() 282 { 283 $queue = $this->queue; 284 $this->queue = []; 285 286 $activitylib = TikiLib::lib('activity'); 287 288 $tx = TikiDb::get()->begin(); 289 290 // TODO : Shrink large events / truncate content ? 291 292 $mailQueue = []; 293 294 $monitormail = TikiLib::lib('monitormail'); 295 foreach ($queue as $item) { 296 list($args, $sendTo) = $this->finalHandleEvent($item['arguments'], $item['events'], $item['force']); 297 298 if ($args) { 299 $activitylib->recordEvent($item['event'], $args); 300 } 301 302 if (! empty($sendTo)) { 303 $monitormail->queue($item['event'], $args, $sendTo); 304 } 305 } 306 307 $tx->commit(); 308 309 // Send email (rather slow, dealing with external services) after Tiki's management is done 310 $monitormail->sendQueue(); 311 } 312 313 private function finalHandleEvent($args, $events, $force) 314 { 315 $currentUser = TikiLib::lib('login')->getUserId(); 316 if ($force) { 317 if ($currentUser != $force['userId']) { 318 // Direct notification, we know user and priority 319 $results = [$force]; 320 } 321 } else { 322 $targets = $this->collectTargets($args); 323 324 $table = $this->table(); 325 $results = $table->fetchAll(['priority', 'userId'], [ 326 'event' => $table->in($events), 327 'target' => $table->in($targets), 328 'userId' => $table->not($currentUser), 329 ]); 330 } 331 332 if (empty($results)) { 333 return [null, []]; 334 } 335 336 $sendTo = []; 337 $args['stream'] = isset($args['stream']) ? (array) $args['stream'] : []; 338 339 foreach ($results as $row) { 340 // Add entries to the named streams, each user will have a few of those 341 $priority = $row['priority']; 342 $args['stream'][] = $priority . $row['userId']; 343 344 if ($priority == 'critical') { 345 $sendTo[] = $row['userId']; 346 } 347 } 348 349 return [$args, array_unique($sendTo)]; 350 } 351 352 /** 353 * Create an option set for each event in the list. 354 * Collects the appropriate object information for adequate display. 355 */ 356 private function gatherOptions($userId, $events, $type, $object, $title = null) 357 { 358 if ($object) { 359 $objectInfo = $this->getObjectInfo($type, $object, $title); 360 } else { 361 $objectInfo = [ 362 'type' => 'global', 363 'target' => 'global', 364 'title' => tr('Anywhere'), 365 'isContainer' => true, 366 'fetchTargets' => ['global'], 367 ]; 368 } 369 370 $options = []; 371 372 $isContainer = $objectInfo['isContainer']; 373 foreach ($events as $eventName => $info) { 374 if ($isContainer || ! $info['global']) { 375 $options[] = $this->createOption($userId, $eventName, $info['label'], $objectInfo); 376 } 377 } 378 379 return $options; 380 } 381 382 private function getObjectInfo($type, $object, $title) 383 { 384 $objectlib = TikiLib::lib('object'); 385 386 list($realType, $objectId) = $this->cleanObjectId($type, $object); 387 388 $title = $title ?: $objectlib->get_title($realType, $object); 389 390 $target = "$type:$objectId"; 391 392 // For multilingual targets, collect all targets in the set as the event 393 // is bound for a single page, but needs to be displayed for all other pages 394 // as well to explain why the notification occurs. 395 if (substr($type, -6) == ' trans') { 396 $title = tr('translations of %0', $title); 397 $fetchTargets = $this->getMultilingualTargets($realType, $objectId); 398 $isTranslation = true; 399 } else { 400 $fetchTargets = []; 401 $isTranslation = false; 402 } 403 404 $fetchTargets[] = $target; 405 406 return [ 407 'type' => $type, 408 'object' => $objectId, 409 'target' => $target, 410 'title' => $title, 411 'isContainer' => $isTranslation || in_array($realType, ['category', 'structure', 'forum', 'tracker']), 412 'fetchTargets' => $fetchTargets, 413 ]; 414 } 415 416 private function cleanObjectId($type, $object) 417 { 418 // Hash must be short, so never use page names or such, use IDs 419 if ($type == 'wiki page' || $type == 'wiki page trans') { 420 $tikilib = TikiLib::lib('tiki'); 421 $object = $tikilib->get_page_id_from_name($object); 422 } 423 424 if ($type == 'user') { 425 $tikilib = TikiLib::lib('tiki'); 426 $object = $tikilib->get_user_id($object); 427 } 428 429 if (substr($type, -6) == ' trans') { 430 $type = substr($type, 0, -6); 431 } 432 433 return [$type, (int) $object]; 434 } 435 436 private function createOption($userId, $eventName, $label, $objectInfo) 437 { 438 $table = $this->table(); 439 $conditions = [ 440 'userId' => $userId, 441 'event' => $eventName, 442 'target' => $table->in($objectInfo['fetchTargets']), 443 ]; 444 // Always fetch the oldest target possible, there would rarely be multiple 445 // But a case where two translation sets would be join could have multiple 446 // monitors active, only display the oldest one. 447 $active = $table->fetchRow(['target', 'priority'], $conditions, [ 448 'monitorId' => 'ASC', 449 ]); 450 451 // Because of the above rule, the active target may not be the requested one 452 // Still display everything as it is the requested one 453 $realTarget = $active ? $active['target'] : $objectInfo['target']; 454 return [ 455 'priority' => $active ? $active['priority'] : 'none', 456 'event' => $eventName, 457 'target' => $realTarget, 458 'hash' => md5($eventName . $realTarget), 459 'type' => $objectInfo['type'], 460 'object' => $objectInfo['object'], 461 'description' => $objectInfo['isContainer'] 462 ? tr('%0 in %1', $label, $objectInfo['title']) 463 : tr('%0 for %1', $label, $objectInfo['title']), 464 ]; 465 } 466 467 private function getApplicableEvents($type) 468 { 469 /** 470 * Global indicates that the event cannot apply to a direct object 471 * Local indicates the event cannot apply on a global scale (to reduce noise) 472 */ 473 switch ($type) { 474 case 'wiki page': 475 return [ 476 'tiki.save' => ['global' => false, 'local' => true, 'label' => tr('Any activity')], 477 'tiki.wiki.save' => ['global' => false, 'local' => false, 'label' => tr('Page modified')], 478 'tiki.wiki.create' => ['global' => true, 'local' => false, 'label' => tr('Page created')], 479 ]; 480 case 'forum post': 481 return [ 482 'tiki.save' => ['global' => false, 'local' => true, 'label' => tr('Any activity')], 483 'tiki.forumpost.save' => ['global' => false, 'local' => false, 'label' => tr('Any forum activity')], 484 'tiki.forumpost.create' => ['global' => true, 'local' => false, 'label' => tr('New topics')], 485 ]; 486 case 'trackeritem': 487 return [ 488 'tiki.save' => ['global' => false, 'local' => true, 'label' => tr('Any activity')], 489 'tiki.trackeritem.save' => ['global' => false, 'local' => false, 'label' => tr('Any item activity')], 490 'tiki.trackeritem.create' => ['global' => true, 'local' => false, 'label' => tr('New items')], 491 ]; 492 case 'user': 493 return [ 494 'tiki.mustread.required' => ['global' => false, 'local' => true, 'label' => tr('Action Required')], 495 'tiki.recommendation.incoming' => ['global' => false, 'local' => true, 'label' => tr('Recommendation Received')], 496 ]; 497 default: 498 return []; 499 } 500 } 501 502 private function hasMultilingual($type) 503 { 504 global $prefs; 505 return $prefs['feature_multilingual'] == 'y' && in_array($type, ['wiki page', 'article']); 506 } 507 508 private function getMultilingualTargets($type, $objectId) 509 { 510 $targets = []; 511 $multilingual = TikiLib::lib('multilingual'); 512 foreach ($multilingual->getTrads($type, $objectId) as $row) { 513 $targets[] = "$type trans:{$row['objId']}"; 514 } 515 516 return $targets; 517 } 518 519 private function getStructureLabel($level, $entry) 520 { 521 $page = $entry['pageName']; 522 523 if ($entry['parent_id'] == 0) { 524 return tr('%0 (%1 level up, entire structure)', $page, $level); 525 } elseif ($level) { 526 return tr('%0 (%1 level up)', $page, $level); 527 } else { 528 return tr('%0 (current subtree)', $page); 529 } 530 } 531} 532