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