Blame | Last modification | View Log | Download
<?php namespace ProcessWire;/*** System Notifications for ProcessWire** By Avoine and Ryan Cramer** @property int $disabled Notification status (0=off, 1=on)* @property int $reverse Notification order (0=newest to oldest, 1=oldest to newest)* @property int $placement* @property array $activeHooks Active automatic notification hooks (0=404s, 1=logins, 2=logouts)* @property int|bool $trackEdits Whether or not to track page edits* @property string $systemUserName Name of user that receives system notifications* @property int $systemUserID ID of user that receives system notifications* @property int $updateDelay Time between updates in ms (default=5000)* @property string $iconMessage default icon for message notifications* @property string $iconWarning default icon for warning notifications* @property string $iconError default icon for error notifications* @property string $iconProgress icon for any item that has an active progress bar* @property string $iconDebug default icon for debug-mode notification* @property int $ghostDelay how long a ghost appears on screen (in ms)* @property int $ghostDelayError how long an error ghost appears on screen (in ms)* @property int $ghostFadeSpeed speed at which ghosts fade in or out, or blank for no fade* @property int $ghostOpacity full opacity of ghost (when fully faded in)* @property int $ghostPos ghost position: 1=left, 2=right* @property int $ghostLimit only show 1 summary ghost if there are more than this number* @property string $dateFormat date format to use in notifications (anything compatible with wireDate() function)**/class SystemNotifications extends WireData implements Module {public static function getModuleInfo() {return array('title' => 'System Notifications','version' => 12,'summary' => 'Adds support for notifications in ProcessWire (currently in development)','autoload' => true,'installs' => 'FieldtypeNotifications','icon' => 'bell',);}const fieldName = 'notifications';/*** System hooks that may be configured as active in this module** Each consists of: before|after hookToClass::hooktoMethod myHookMethod**/protected $systemHooks = array(0 => 'after ProcessPageView::pageNotFound hook404',1 => 'after Session::login hookLogin',2 => 'after Session::logoutSuccess hookLogout',);/*** Construction SystemNotifications**/public function __construct() {$path = dirname(__FILE__) . '/';require_once($path . "Notification.php");require_once($path . "NotificationArray.php");require_once($path . "SystemNotificationsConfig.php");// hook method to access notifications, in case field name ever needs to change for some reason$this->addHook('User::notifications', $this, 'hookUserNotifications');$this->set('disabled', false);}/*** API init: attach hooks**/public function init() {if($this->disabled) return;if($this->activeHooks) foreach($this->activeHooks as $id) {if(!isset($this->systemHooks[$id])) continue;list($when, $hook, $method) = explode(' ', $this->systemHooks[$id]);if($when == 'before') {$this->addHookBefore($hook, $this, $method);} else {$this->addHookAfter($hook, $this, $method);}}}/*** API ready**/public function ready() {if($this->disabled) return;$page = $this->wire('page');$config = $this->wire('config');$user = $this->wire('user');if(!$user->isLoggedin()) return;$this->testProgressNotification();if($this->wire('config')->ajax) {$adminPage = $this->wire('pages')->get($config->adminRootPageID);if($page->parents()->has($adminPage)) {$ajaxAction = $this->wire('input')->get('Notifications');if($ajaxAction) $this->ajaxAction($ajaxAction, $user->get(self::fieldName), $user);}}if($page->template == 'admin' && !$this->wire('input')->get('modal')) {$this->addHookAfter('AdminTheme::getExtraMarkup', $this, 'hookAdminThemeGetExtraMarkup');}}/*** Test out the progress bar notification**/protected function testProgressNotification() {$session = $this->wire('session');/** @var NotificationArray $notifications */$notifications = $this->wire('user')->notifications();if($this->wire('input')->get('test_progress')) {// start new progress bar notification/** @var Notification $notification */$notification = $this->wire('user')->notifications()->message('Testing progress bar notification');$notification->progress = 0;$notification->flag('annoy');$value = $session->get($this, 'test_progress');if(!is_array($value)) $value = array();$id = $notification->getID();$value[$id] = $id;$session->set($this, 'test_progress', $value);$notifications->save();} else if(($value = $session->get($this, 'test_progress')) && count($value)) {// updating existing progress bar notification(s)foreach($value as $id) {$notification = $notifications->get($id);if(!$notification) continue;$notification->progress += 10;if($notification->progress < 100) {$notification->html = "<p>$notification->progress%</p>";continue;}unset($value[$id]);$notification->title = "Your download is now complete!";$notification->flag('open');$notification->flag('email');$notification->html ="<p>This is just an example for demo purposes and the button below doesn't actually do anything.<br />" ."<button class='ui-button'><strong class='ui-button-text'>Download</strong></button></p>";}$session->set($this, 'test_progress', $value);$notifications->save();}}/*** Convert Notification object to array** @param Notification $notification* @return array**/protected function notificationToArray(Notification $notification) {$html = $notification->html;if(!$html && $notification->text) $html = "<p>" . $this->sanitizer->entities($notification->text) . "</p>";$a = array('id' => $notification->getID(),'title' => $notification->title,'from' => $notification->from,'created' => $notification->created,'modified' => $notification->modified,'when' => wireDate($this->dateFormat, $notification->modified),'href' => $notification->href,'icon' => $notification->icon,'flags' => $notification->flags,'flagNames' => implode(' ', $notification->flagNames),'progress' => $notification->progress,'html' => $html,'qty' => $notification->qty,'expires' => $notification->expires,);if($a['progress'] > 0 && $a['progress'] < 100) {$a['icon'] = $this->iconProgress;}if(empty($a['icon'])) {if($notification->is("error")) $a['icon'] = $this->iconError;else if($notification->is("warning")) $a['icon'] = $this->iconWarning;else $a['icon'] = $this->iconMessage;}return $a;}/*** Process an ajax action request** @param $action* @param NotificationArray $notifications* @param Page $page**/protected function ajaxAction($action, NotificationArray $notifications, Page $page) {$data = array();$qty = 0;$qtyNew = 0;$qtyMessage = 0;$qtyWarning = 0;$qtyError = 0;$time = (int) $this->wire('input')->get('time');$rm = $this->wire('input')->get('rm');$rm = $rm ? explode(',', $rm) : array();if($this->trackEdits) {$processKey = $this->wire('input')->get('processKey');$this->updateProcessKey($processKey);}foreach($notifications->sort('-modified') as $notification) {/** @var Notification $notification */$qty++;$a = $this->notificationToArray($notification);if(in_array($a['id'], $rm)) {$qty--;$notifications->remove($notification);continue;}if($time && $notification->modified < $time) {continue;}if($notification->is('shown')) {continue;} else {$notification->setFlag('shown');$qtyNew++;}if($notification->flags & Notification::flagError) $qtyError++;else if($notification->flags & Notification::flagWarning) $qtyWarning++;else $qtyMessage++;$data[] = $a;}if(count($rm) || $qtyNew) {$this->wire('pages')->saveField($page, 'notifications', array('quiet' => true));}if($action == 'update') {$data = array('notifications' => $data, // new notifications only'qty' => $qty, // total notifications (new or not)'qtyNew' => $qtyNew, // quantity of new notifications, not yet shown'qtyMessage' => $qtyMessage,'qtyWarning' => $qtyWarning,'qtyError' => $qtyError,'time' => time(), // time this info was generated);}header("Content-type: application/json");echo json_encode($data);exit;}/*** Adds markup to admin theme output to initialize notifications** @param $event**/public function hookAdminThemeGetExtraMarkup($event) {if($this->disabled) return;$config = $this->wire('config');$url = $config->urls->SystemNotifications . 'Notifications';$info = self::getModuleInfo();$config->styles->add("$url.css?v=$info[version]");$jsfile = $config->debug ? "$url.js" : "$url.min.js";$config->scripts->add("$jsfile?v=$info[version]");$qty = count($this->wire('user')->get(self::fieldName));$ghostLimit = $this->ghostLimit ? $this->ghostLimit : 20;$properties = array(// configured property names'updateDelay','iconMessage','iconWarning','iconError','ghostZindex','ghostDelay','ghostDelayError','ghostFadeSpeed','ghostOpacity','reverse',);$options = array(// runtime property names'version' => $info['version'],'updateLast' => time(),);foreach($properties as $key) {$options[$key] = $this->get($key);}$options['reverse'] = (bool) ((int) $options['reverse']);// options specified in $config->SystemNotifications$configDefaults = array('classMessage' => 'NoticeMessage','classWarning' => 'NoticeWarning','classError' => 'NoticeError','classContainer' => 'container',);$configOptions = $this->wire('config')->SystemNotifications;if(!is_array($configOptions)) $configOptions = array();$options = array_merge($options, $configDefaults, $configOptions);$textdomain = '/wire/core/Functions.php';$options['i18n'] = array('sec' => __('sec', $textdomain),'secs' => __('secs', $textdomain),'min' => __('min', $textdomain),'mins' => __('mins', $textdomain),'hour' => __('hour', $textdomain),'hours' => __('hours', $textdomain),'day' => __('day', $textdomain),'days' => __('days', $textdomain),'expires' => $this->_('expires'),'now' => __('now', $textdomain),'fromNow' => __('from now', $textdomain),'ago' => __('ago', $textdomain),);if($this->trackEdits) {$processKey = $this->makeProcessKey();if(!empty($processKey)) $options['processKey'] = $processKey;}$ghostClass = $this->ghostPos == 2 ? "NotificationGhostsRight" : "NotificationGhostsLeft";$out ="<div id='NotificationMenu' class='NotificationMenu'>" ."<ul id='NotificationList' class='NotificationList'></ul>" ."</div>" ."<ul id='NotificationGhosts' class='NotificationGhosts $ghostClass'></ul>" ."<script>Notifications.init(" . json_encode($options) . "); ";$notices = $this->wire('notices');$notifications = array();$numSave = 0; // number of notifications that need to be saved after being shown// convert runtime Notices to Notifications and bundle into arrayif($this->placement == SystemNotificationsConfig::placementCombined) {foreach($notices as $notice) {$notification = $this->noticeToNotification($notice);if(!$notification) continue;$sort = time() + 2592000;while(isset($notifications[$sort])) $sort++;$notifications[$sort] = $notification;}}// bundle user notifications into the same arrayforeach($this->wire('user')->notifications as $notification) {if($notification->is('shown')) {$notification->setFlag('no-ghost');} else {$notification->setFlag('shown');$numSave++;}$sort = ((int) $notification->modified) + ((int) $notification->sort);while(isset($notifications[$sort])) $sort++;$notifications[$sort] = $notification;}if($this->reverse) {ksort($notifications);} else {krsort($notifications);}$numMessages = 0;$numWarnings = 0;$numErrors = 0;$noGhost = count($notifications) > $ghostLimit;foreach($notifications as $notification) {if($noGhost) $notification->setFlag("no-ghost");$notificationJS = json_encode($this->notificationToArray($notification));$out .= "\nNotifications.add($notificationJS); ";if($notification->is("message")) $numMessages++;else if($notification->is("warning")) $numWarnings++;else if($notification->is("error")) $numErrors++;if($noGhost) $notification->removeFlag("no-ghost"); // restore in case saved to DB$notification->setFlag('shown');}if($noGhost) {$ghostTypes = array("message" => $numMessages,"warning" => $numWarnings,"error" => $numErrors);foreach($ghostTypes as $type => $qty) {if(!$qty) continue;if($type == 'message') $title = sprintf($this->_n('%d new message', '%d new messages', $qty), $qty);else if($type == 'warning') $title = sprintf($this->_n('%d new warning', '%d new warnings', $qty), $qty);else if($type == 'error') $title = sprintf($this->_n('%d new error', '%d new errors', $qty), $qty);else $title = '';$icon = $this->get("icon" . ucfirst($type));$out .= 'Notifications._ghost({"id":"","title":"' . $title . '","icon":"' . $icon . '","flagNames":"' . $type . ' notice"});';}}if($numSave) {// updates shown flag so notification doesn't pop up another ghost$this->wire('user')->notifications->save();}if($this->placement == SystemNotificationsConfig::placementCombined) {$out .= "$('#notices').remove(); "; // in case admin theme still has #notices}$out .="$(document).ready(function() { Notifications.render(); });" ."</script>";$extras = $event->return;$extras['body'] .= $out;$extras['masthead'] .="<div id='NotificationBug' class='NotificationBug qty$qty' data-qty='$qty'>" ."<span class='qty fa fa-fw'>$qty</span>" ."<i class='NotificationSpinner fa fa-fw fa-spin fa-spinner'></i>" ."</div>";$adminTheme = $this->wire('adminTheme');if($adminTheme) $adminTheme->addBodyClass('NotificationPlacement' . (int) $this->placement);$event->return = $extras;}/*** Convert ProcessWire runtime "Notice" objects to runtime Notification objects** @param Notice $notice* @return Notification|bool Returns Notification or boolean false on error**/protected function noticeToNotification(Notice $notice) {if($notice instanceof NoticeWarning || ($notice->flags & Notice::warning)) $type = 'warning';else if($notice instanceof NoticeError) $type = 'error';else $type = 'message';/** @var NotificationArray $notifications */$notifications = $this->wire('user')->notifications();if(!$notifications) return false;$notification = $notifications->getNew($type, false);if(!$notification) return false;$notification->setFlag('notice', true);if($notice->flags & Notice::allowMarkup) $notification->setFlag('markup', true);if($notice->flags & Notice::log) $notification->setFlag('log', true);if($notice->flags & Notice::logOnly) $notification->setFlag('log-only', true);if($notice->flags & Notice::debug) {$notification->setFlag('debug', true);$notification->icon = $this->iconDebug;}if($notice->class) $notification->from = $notice->class;if($notice->timestamp) $notification->created = $notice->timestamp;$title = strip_tags((string) $notice);if(strlen($title) > 100) {$title = substr($title, 0, 100);$title = substr($title, 0, strrpos($title, ' ')) . '...';$notification->title = $title;if($notice->flags & Notice::allowMarkup) {$notification->html = (string) $notice;} else {$notification->text = (string) $notice;}} else if($notice->flags & Notice::allowMarkup) {$notification->title = $notice->text;} else {$notification->title = $title;}return $notification;}/*** Adds automatic notification for every 404** @param HookEvent $event**/public function hook404(HookEvent $event) {/** @var Page $page */$page = $event->arguments(0);$url = $event->arguments(1);/** @var User $user */$user = $this->getSystemUser();if(!$user->id) return;if(isset($_SERVER['HTTP_REFERER'])) {$referer = $this->wire('sanitizer')->entities($this->wire('sanitizer')->text($_SERVER['HTTP_REFERER']));} else {$referer = '';}if(isset($_SERVER['HTTP_USER_AGENT'])) {$useragent = $this->wire('sanitizer')->entities($this->wire('sanitizer')->text($_SERVER['HTTP_USER_AGENT']));} else {$useragent = '';}if(empty($referer)) $referer = "unknown";if(empty($useragent)) $useragent = "unknown";/** @var NotificationArray $notifications */$notifications = $user->notifications();$notification = $notifications->warning(sprintf($this->_('404 occurred: %s'), $url));$notification->expires = 30;$notification->html ="<p>" ."<b>Referer:</b> $referer<br />" ."<b>Useragent:</b> $useragent<br />" ."<b>IP:</b> " . $this->wire('session')->getIP() . "<br />" ."<b>Page:</b> " . ($page->id ? $page->url : 'Unknown') . "<br />" ."<b>User:</b> " . $this->user->name ."</p>";$notifications->save();}/*** Creates a notifications() method with the user** @param HookEvent $event**/public function hookUserNotifications(HookEvent $event) {$user = $event->object;$notifications = $user->get(self::fieldName);if(!$notifications) {$this->install();$notifications = $user->get(self::fieldName);}$event->return = $notifications;}/*** Automatic notification for logins** @param HookEvent $event**/public function hookLogin(HookEvent $event) {$user = $this->getSystemUser();if(!$user->id) return;/** @var NotificationArray $notifications */$notifications = $user->notifications();$loginUser = $event->return;$loginName = $event->arguments(0);if($loginUser && $loginUser->id) {$notification = $notifications->message(sprintf($this->_('User logged in: %s'), $loginName));} else {$notification = $notifications->error(sprintf($this->_('Login failure: %s'), $loginName));}$useragent = $this->wire('sanitizer')->entities($this->wire('sanitizer')->text($_SERVER['HTTP_USER_AGENT']));$notification->html ="<p>" ."<b>Useragent:</b> $useragent<br />" ."<b>Time:</b> " . date('Y-m-d H:i:s') . "<br />" ."<b>IP:</b> " . $this->wire('session')->getIP() ."</p>";$notifications->save();}/*** Automatic notification for logouts** @param HookEvent $event**/public function hookLogout(HookEvent $event) {$user = $this->getSystemUser();if(!$user->id) return;$logoutUser = $event->arguments(0);/** @var NotificationArray $notifications */$notifications = $user->notifications();$notifications->message(sprintf($this->_('User logged out: %s'), $logoutUser->name));$notifications->save();}/*** Return the user that receives system notifications** @return User**/public function getSystemUser() {$user = null;if($this->systemUserName) $user = $this->wire('users')->get($this->systemUserName);if(!$user || !$user->id) $user = $this->wire('users')->get($this->systemUserID);if(!$user->id) {$role = $this->wire('roles')->get('superuser');$user = $this->wire('users')->get("roles=$role, sort=-created, include=all");}return $user;}/*** Install notifications**/public function ___install() {$fieldtype = $this->modules->get('FieldtypeNotifications');$field = $this->wire('fields')->get(self::fieldName);if($field && !$field->type instanceof FieldtypeNotifications) {throw new WireException("There is already a field named '" . self::fieldName . "'");}if(!$field) {$field = $this->wire(new Field());$field->name = self::fieldName;$field->label = 'Notifications';$field->type = $fieldtype;$field->collapsed = Inputfield::collapsedBlank;$field->flags = Field::flagSystem;$field->save();}$fieldgroup = $this->wire('fieldgroups')->get('user');if(!$fieldgroup->hasField($field)) {$fieldgroup->add($field);$fieldgroup->save();}// make this field one that the user is allowed to configure in their profile// $data = $this->wire('modules')->getModuleConfigData('ProcessProfile');// $data['profileFields'][] = 'notifications';// $this->wire('modules')->saveModuleConfigData('ProcessProfile', $data);$notifications = $this->wire('user')->get(self::fieldName);if($notifications) {$notifications->message('Hello World')->text('Thank you for installing the Notifications module. This is your first notification!');$notifications->save();}}/*** Uninstall notifications**/public function ___uninstall() {$fieldgroup = $this->wire('fieldgroups')->get('user');$field = $this->wire('fields')->get(self::fieldName);if($field) {$field->flags = Field::flagSystemOverride;$field->flags = 0;if($fieldgroup->hasField($field)) {$fieldgroup->remove($field);$fieldgroup->save();}$this->wire('fields')->delete($field);}if($this->wire('modules')->isInstalled('FieldtypeNotifications')) {$this->wire('modules')->uninstall('FieldtypeNotifications');}}/*** Update the current processKey and its time in the cache** This method is called only during ajax requests.** @param string $processKey**/protected function updateProcessKey($processKey) {// NPK.ProcessPageEdit.pageID.userID.windowIDif(!preg_match('/^NPK\.[A-Za-z]+\.\d+\.\d+\.PW\d+$/', $processKey)) return;$this->checkProcessKey($processKey);$times = $this->wire('cache')->get($processKey);if($times) {list($created, $modified) = explode(':', $times);if($modified) {} // unused$value = $created . ":" . time();} else {$value = time() . ":" . time();}$this->wire('cache')->save($processKey, $value, ($this->updateDelay / 1000) * 2);}/*** Create a new processKey for the current request** processKey example: NPK.ProcessPageEdit.pageID.userID* Note that it excludes the windowName, which is added at the end after the* first ajax request.** This method only runs during full requests, not during ajax requests.** @return string**/protected function makeProcessKey() {$process = $this->wire('process');if($process && ($process instanceof ProcessPageEdit || $process instanceof ProcessPageType)) {// good, we'll use it$page = $process->getPage();if(!$page || !$page->id) return '';} else {// we don't track this processreturn '';}// NPK = NotificationProcessKey$processKey = "NPK." .$process->className() ."." . $page->id ."." . $this->wire('user')->id;// reset because non-ajax request$this->wire('session')->remove($this, 'notifiedProcessKeys');return $processKey;}/*** Given a processKey, check for conflicts with other active processKeys** @param $processKey**/protected function checkProcessKey($processKey) {list($prefix, $className, $pageID, $userID, $windowName) = explode('.', $processKey);if($userID) {} // unused// locate all currently active processKeys editing $page$processKeys = $this->wire('cache')->get("$prefix.$className.$pageID.*");$notified = $this->wire('session')->getFor($this, 'notifiedProcessKeys');if(!is_array($notified)) $notified = array();foreach($processKeys as $_processKey => $times) {if(isset($notified[$processKey]) && in_array($_processKey, $notified[$processKey])) continue;list($created, $modified) = explode(":", $times);list($_prefix, $_className, $_pageID, $_userID, $_windowName) = explode('.', $_processKey);if($modified || $_prefix || $_className || $_pageID) {} // unused$recordNotify = false;if($_userID == $this->wire('user')->id) {// same userif($_windowName == $windowName) {// this is the window we are already in and this is OK to skip} else {// editing in different window$this->wire('user')->notifications()->error(sprintf($this->_('Warning: you are editing this page in another window (editing started %s)'),wireDate('%X', (int) $created)),Notification::flagAnnoy | Notification::flagSession);$recordNotify = true;}} else {// different user$editingUser = $this->wire('users')->get((int) $_userID);if($editingUser->id) {$this->wire('user')->notifications()->error(sprintf($this->_('Warning: user "%s" is currently editing this page (editing started %s)'),$editingUser->name, wireDate('%X', (int) $created)),Notification::flagAnnoy | Notification::flagSession);$recordNotify = true;}}if($recordNotify) {if(isset($notified[$processKey])) $notified[$processKey][] = $_processKey;else $notified[$processKey] = array($_processKey);}}if(count($notified)) {$this->wire('session')->setFor($this, 'notifiedProcessKeys', $notified);}}}