Page MenuHomePhorge

No OneTemporary

diff --git a/plugins/kolab_delegation/kolab_delegation_engine.php b/plugins/kolab_delegation/kolab_delegation_engine.php
index ca4d5b4b..be16cf69 100644
--- a/plugins/kolab_delegation/kolab_delegation_engine.php
+++ b/plugins/kolab_delegation/kolab_delegation_engine.php
@@ -1,927 +1,934 @@
<?php
/**
* Kolab Delegation Engine
*
* @version @package_version@
* @author Thomas Bruederli <bruederli@kolabsys.com>
* @author Aleksander Machniak <machniak@kolabsys.com>
*
* Copyright (C) 2011-2012, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
class kolab_delegation_engine
{
public $context;
private $rc;
private $ldap_filter;
private $ldap_delegate_field;
private $ldap_login_field;
private $ldap_name_field;
private $ldap_email_field;
private $ldap_org_field;
private $ldap_dn;
private $cache = array();
private $folder_types = array('mail', 'event', 'task');
const ACL_READ = 1;
const ACL_WRITE = 2;
/**
* Class constructor
*/
public function __construct()
{
$this->rc = rcube::get_instance();
}
/**
* Add delegate
*
* @param string|array $delegate Delegate DN (encoded) or delegate data (result of delegate_get())
* @param array $acl List of folder->right map
*/
public function delegate_add($delegate, $acl)
{
if (!is_array($delegate)) {
$delegate = $this->delegate_get($delegate);
}
$dn = $delegate['ID'];
if (empty($delegate) || empty($dn)) {
return false;
}
$list = $this->list_delegates();
// add delegate to the list
$list = array_keys((array)$list);
$list = array_filter($list);
if (!in_array($dn, $list)) {
$list[] = $dn;
}
$list = array_map(array('kolab_auth_ldap', 'dn_decode'), $list);
// update user record
$result = $this->user_update_delegates($list);
// Set ACL on folders
if ($result && !empty($acl)) {
$this->delegate_acl_update($delegate['uid'], $acl);
}
return $result;
}
/**
* Set/Update ACL on delegator's folders
*
* @param string $uid Delegate authentication identifier
* @param array $acl List of folder->right map
* @param bool $update Update (remove) old rights
*/
public function delegate_acl_update($uid, $acl, $update = false)
{
$storage = $this->rc->get_storage();
$right_types = $this->right_types();
$folders = $update ? $this->list_folders($uid) : array();
foreach ($acl as $folder_name => $rights) {
$r = $right_types[$rights];
if ($r) {
$storage->set_acl($folder_name, $uid, $r);
}
if (!empty($folders) && isset($folders[$folder_name])) {
unset($folders[$folder_name]);
}
}
foreach ($folders as $folder_name => $folder) {
if ($folder['rights']) {
$storage->delete_acl($folder_name, $uid);
}
}
return true;
}
/**
* Delete delgate
*
* @param string $dn Delegate DN (encoded)
* @param bool $acl_del Enable ACL deletion on delegator folders
*/
public function delegate_delete($dn, $acl_del = false)
{
$delegate = $this->delegate_get($dn);
$list = $this->list_delegates();
$user = $this->user();
if (empty($delegate) || !isset($list[$dn])) {
return false;
}
// remove delegate from the list
unset($list[$dn]);
$list = array_keys($list);
$list = array_map(array('kolab_auth_ldap', 'dn_decode'), $list);
$user[$this->ldap_delegate_field] = $list;
// update user record
$result = $this->user_update_delegates($list);
// remove ACL
if ($result && $acl_del) {
$this->delegate_acl_update($delegate['uid'], array(), true);
}
return $result;
}
/**
* Return delegate data
*
* @param string $dn Delegate DN (encoded)
*
* @return array Delegate record (ID, name, uid, imap_uid)
*/
public function delegate_get($dn)
{
// use internal cache so we not query LDAP more than once per request
if (!isset($this->cache[$dn])) {
$ldap = $this->ldap();
if (!$ldap || empty($dn)) {
return array();
}
// Get delegate
$user = $ldap->get_record(kolab_auth_ldap::dn_decode($dn));
if (empty($user)) {
return array();
}
$delegate = $this->parse_ldap_record($user);
$delegate['ID'] = $dn;
$this->cache[$dn] = $delegate;
}
return $this->cache[$dn];
}
/**
* Return delegate data
*
* @param string $login Delegate name (the 'uid' returned in get_users())
*
* @return array Delegate record (ID, name, uid, imap_uid)
*/
public function delegate_get_by_name($login)
{
$ldap = $this->ldap();
if (!$ldap || empty($login)) {
return array();
}
$list = $ldap->dosearch($this->ldap_login_field, $login, 1);
if (count($list) == 1) {
$dn = key($list);
$user = $list[$dn];
return $this->parse_ldap_record($user, $dn);
}
}
/**
* LDAP object getter
*/
private function ldap()
{
$ldap = kolab_auth::ldap();
if (!$ldap || !$ldap->ready) {
return null;
}
// Default filter of LDAP queries
$this->ldap_filter = $this->rc->config->get('kolab_delegation_filter', '(objectClass=kolabInetOrgPerson)');
// Name of the LDAP field for delegates list
$this->ldap_delegate_field = $this->rc->config->get('kolab_delegation_delegate_field', 'kolabDelegate');
// Encoded LDAP DN of current user, set on login by kolab_auth plugin
$this->ldap_dn = $_SESSION['kolab_dn'];
// Name of the LDAP field with authentication ID
$this->ldap_login_field = $this->rc->config->get('kolab_auth_login');
// Name of the LDAP field with user name used for identities
$this->ldap_name_field = $this->rc->config->get('kolab_auth_name');
// Name of the LDAP field with email addresses used for identities
$this->ldap_email_field = $this->rc->config->get('kolab_auth_email');
// Name of the LDAP field with organization name for identities
$this->ldap_org_field = $this->rc->config->get('kolab_auth_organization');
$ldap->set_filter($this->ldap_filter);
$ldap->extend_fieldmap(array($this->ldap_delegate_field => $this->ldap_delegate_field));
return $ldap;
}
/**
* List current user delegates
*/
public function list_delegates()
{
$result = array();
$ldap = $this->ldap();
$user = $this->user();
if (empty($ldap) || empty($user)) {
return array();
}
// Get delegates of current user
$delegates = $user[$this->ldap_delegate_field];
if (!empty($delegates)) {
foreach ((array)$delegates as $dn) {
$delegate = $ldap->get_record($dn);
$data = $this->parse_ldap_record($delegate, $dn);
if (!empty($data) && !empty($data['name'])) {
$result[$data['ID']] = $data['name'];
}
}
}
return $result;
}
/**
* List current user delegators
*
* @return array List of delegators
*/
public function list_delegators()
{
$result = array();
$ldap = $this->ldap();
if (empty($ldap) || empty($this->ldap_dn)) {
return array();
}
$list = $ldap->dosearch($this->ldap_delegate_field, $this->ldap_dn, 1);
foreach ($list as $dn => $delegator) {
$delegator = $this->parse_ldap_record($delegator, $dn);
$result[$delegator['ID']] = $delegator;
}
return $result;
}
/**
* List current user delegators in format compatible with Calendar plugin
*
* @return array List of delegators
*/
public function list_delegators_js()
{
$list = $this->list_delegators();
$result = array();
foreach ($list as $delegator) {
$name = $delegator['name'];
if ($pos = strrpos($name, '(')) {
$name = trim(substr($name, 0, $pos));
}
$result[$delegator['imap_uid']] = array(
'emails' => ';' . implode(';', $delegator['email']),
'email' => $delegator['email'][0],
'name' => $name,
);
}
return $result;
}
/**
* Prepare namespace prefixes for JS environment
*
* @return array List of prefixes
*/
public function namespace_js()
{
$storage = $this->rc->get_storage();
$ns = $storage->get_namespace('other');
if ($ns) {
foreach ($ns as $idx => $nsval) {
$ns[$idx] = kolab_storage::folder_id($nsval[0]);
}
}
return $ns;
}
/**
* Get all folders to which current user has admin access
*
* @param string $delegate IMAP user identifier
*
* @return array Folder type/rights
*/
public function list_folders($delegate = null)
{
$storage = $this->rc->get_storage();
$folders = $storage->list_folders();
$metadata = kolab_storage::folders_typedata();
$result = array();
if (!is_array($metadata)) {
return $result;
}
// Definition of read and write ACL
$right_types = $this->right_types();
foreach ($folders as $folder) {
// get only folders in personal namespace
if ($storage->folder_namespace($folder) != 'personal') {
continue;
}
$rights = null;
$type = $metadata[$folder] ?: 'mail';
list($class, $subclass) = explode('.', $type);
if (!in_array($class, $this->folder_types)) {
continue;
}
// in edit mode, get folder ACL
if ($delegate) {
// @TODO: cache ACL
$acl = $storage->get_acl($folder);
if ($acl = $acl[$delegate]) {
if ($this->acl_compare($acl, $right_types[self::ACL_WRITE])) {
$rights = self::ACL_WRITE;
}
else if ($this->acl_compare($acl, $right_types[self::ACL_READ])) {
$rights = self::ACL_READ;
}
}
}
else if ($folder == 'INBOX' || $subclass == 'default' || $subclass == 'inbox') {
$rights = self::ACL_WRITE;
}
$result[$folder] = array(
'type' => $class,
'rights' => $rights,
);
}
return $result;
}
/**
* Returns list of users for autocompletion
*
* @param string $search Search string
*
* @return array Users list
*/
public function list_users($search)
{
$ldap = $this->ldap();
if (empty($ldap) || $search === '' || $search === null) {
return array();
}
$max = (int) $this->rc->config->get('autocomplete_max', 15);
$mode = (int) $this->rc->config->get('addressbook_search_mode');
$fields = array_unique(array_filter(array_merge((array)$this->ldap_name_field, (array)$this->ldap_login_field)));
$users = array();
$keys = array();
$result = $ldap->dosearch($fields, $search, $mode, (array)$this->ldap_login_field, $max);
foreach ($result as $record) {
// skip self
if ($record['dn'] == $_SESSION['kolab_dn']) {
continue;
}
$user = $this->parse_ldap_record($record);
if ($user['uid']) {
$display = rcube_addressbook::compose_search_name($record);
$user = array('name' => $user['uid'], 'display' => $display);
$users[] = $user;
$keys[] = $display ?: $user['uid'];
}
}
if (count($users)) {
// sort users index
asort($keys, SORT_LOCALE_STRING);
// re-sort users according to index
foreach (array_keys($keys) as $idx) {
$keys[$idx] = $users[$idx];
}
$users = array_values($keys);
}
return $users;
}
/**
* Extract delegate identifiers and pretty name from LDAP record
*/
private function parse_ldap_record($data, $dn = null)
{
$email = array();
$uid = $data[$this->ldap_login_field];
if (is_array($uid)) {
$uid = array_filter($uid);
$uid = $uid[0];
}
// User name for identity
foreach ((array)$this->ldap_name_field as $field) {
$name = is_array($data[$field]) ? $data[$field][0] : $data[$field];
if (!empty($name)) {
break;
}
}
// User email(s) for identity
foreach ((array)$this->ldap_email_field as $field) {
$user_email = is_array($data[$field]) ? array_filter($data[$field]) : $data[$field];
if (!empty($user_email)) {
$email = array_merge((array)$email, (array)$user_email);
}
}
// Organization for identity
foreach ((array)$this->ldap_org_field as $field) {
$organization = is_array($data[$field]) ? $data[$field][0] : $data[$field];
if (!empty($organization)) {
break;
}
}
$realname = $name;
if ($uid && $name) {
$name .= ' (' . $uid . ')';
}
else {
$name = $uid;
}
// get IMAP uid - identifier used in shared folder hierarchy
$imap_uid = $uid;
if ($pos = strpos($imap_uid, '@')) {
$imap_uid = substr($imap_uid, 0, $pos);
}
return array(
'ID' => kolab_auth_ldap::dn_encode($dn),
'uid' => $uid,
'name' => $name,
'realname' => $realname,
'imap_uid' => $imap_uid,
'email' => $email,
'organization' => $organization,
);
}
/**
* Returns LDAP record of current user
*
* @return array User data
*/
public function user($parsed = false)
{
if (!isset($this->cache['user'])) {
$ldap = $this->ldap();
if (!$ldap) {
return array();
}
// Get current user record
$this->cache['user'] = $ldap->get_record($this->ldap_dn);
}
return $parsed ? $this->parse_ldap_record($this->cache['user']) : $this->cache['user'];
}
/**
* Update LDAP record of current user
*
* @param array List of delegates
*/
public function user_update_delegates($list)
{
$ldap = $this->ldap();
$pass = $this->rc->decrypt($_SESSION['password']);
if (!$ldap) {
return false;
}
// need to bind as self for sufficient privilages
if (!$ldap->bind($this->ldap_dn, $pass)) {
return false;
}
$user[$this->ldap_delegate_field] = $list;
unset($this->cache['user']);
// replace delegators list in user record
return $ldap->replace($this->ldap_dn, $user);
}
/**
* Manage delegation data on user login
*/
public function delegation_init()
{
// Fetch all delegators from LDAP who assigned the
// current user as their delegate and create identities
// a) if identity with delegator's email exists, continue
// b) create identity ($delegate on behalf of $delegator
// <$delegator-email>) for new delegators
// c) remove all other identities which do not match the user's primary
// or alias email if 'kolab_delegation_purge_identities' is set.
$delegators = $this->list_delegators();
$use_subs = $this->rc->config->get('kolab_use_subscriptions');
$identities = $this->rc->user->list_emails();
$emails = array();
$uids = array();
if (!empty($delegators)) {
$storage = $this->rc->get_storage();
$other_ns = $storage->get_namespace('other');
$folders = $storage->list_folders();
}
// convert identities to simpler format for faster access
foreach ($identities as $idx => $ident) {
// get user name from default identity
if (!$idx) {
$default = array(
'name' => $ident['name'],
);
}
$emails[$ident['identity_id']] = $ident['email'];
}
// for every delegator...
foreach ($delegators as $delegator) {
$uids[$delegator['imap_uid']] = $email_arr = $delegator['email'];
$diff = array_intersect($emails, $email_arr);
// identity with delegator's email already exist, do nothing
if (count($diff)) {
$emails = array_diff($emails, $email_arr);
continue;
}
// create identities for delegator emails
foreach ($email_arr as $email) {
// @TODO: "Delegatorname" or "Username on behalf of Delegatorname"?
$default['name'] = $delegator['realname'];
$default['email'] = $email;
// Database field for organization is NOT NULL
$default['organization'] = empty($delegator['organization']) ? '' : $delegator['organization'];
$this->rc->user->insert_identity($default);
}
// IMAP folders shared by new delegators shall be subscribed on login,
// as well as existing subscriptions of previously shared folders shall
// be removed. I suppose the latter one is already done in Roundcube.
// for every accessible folder...
foreach ($folders as $folder) {
// for every 'other' namespace root...
foreach ($other_ns as $ns) {
$prefix = $ns[0] . $delegator['imap_uid'];
// subscribe delegator's folder
if ($folder === $prefix || strpos($folder, $prefix . substr($ns[0], -1)) === 0) {
// Event/Task folders need client-side activation
$type = kolab_storage::folder_type($folder);
if (preg_match('/^(event|task)/i', $type)) {
kolab_storage::folder_activate($folder);
}
// Subscribe to mail folders and (if system is configured
// to display only subscribed folders) to other
if ($use_subs || preg_match('/^mail/i', $type)) {
$storage->subscribe($folder);
}
}
}
}
}
// remove identities that "do not belong" to user nor delegators
if ($this->rc->config->get('kolab_delegation_purge_identities')) {
$user = $this->user(true);
$emails = array_diff($emails, $user['email']);
foreach (array_keys($emails) as $idx) {
$this->rc->user->delete_identity($idx);
}
}
$_SESSION['delegators'] = $uids;
}
/**
* Sets delegator context according to email message recipient
*
* @param rcube_message $message Email message object
*/
public function delegator_context_from_message($message)
{
if (empty($_SESSION['delegators'])) {
return;
}
// Match delegators' addresses with message To: address
// @TODO: Is this reliable enough?
// Roundcube sends invitations to every attendee separately,
// but maybe there's a software which sends with CC header or many addresses in To:
$emails = $message->get_header('to');
$emails = rcube_mime::decode_address_list($emails, null, false);
foreach ($emails as $email) {
foreach ($_SESSION['delegators'] as $uid => $addresses) {
if (in_array($email['mailto'], $addresses)) {
return $this->context = $uid;
}
}
}
}
/**
* Return (set) current delegator context
*
* @return string Delegator UID
*/
public function delegator_context()
{
if (!$this->context && !empty($_SESSION['delegators'])) {
$context = rcube_utils::get_input_value('_context', rcube_utils::INPUT_GPC);
if ($context && isset($_SESSION['delegators'][$context])) {
$this->context = $context;
}
}
return $this->context;
}
/**
* Set user identity according to delegator delegator
*
* @param array $args Reference to plugin hook arguments
*/
public function delegator_identity_filter(&$args)
{
$context = $this->delegator_context();
if (!$context) {
return;
}
$identities = $this->rc->user->list_emails();
$emails = $_SESSION['delegators'][$context];
foreach ($identities as $ident) {
if (in_array($ident['email'], $emails)) {
$args['identity'] = $ident;
return;
}
}
// fallback to default identity
$args['identity'] = array_shift($identities);
}
/**
* Filter user emails according to delegator context
*
* @param array $args Reference to plugin hook arguments
*/
public function delegator_emails_filter(&$args)
{
$context = $this->delegator_context();
+ // try to derive context from the given user email
+ if (!$context && !empty($args['emails'])) {
+ if (($user = preg_replace('/@.+$/', '', $args['emails'][0])) && isset($_SESSION['delegators'][$user])) {
+ $context = $user;
+ }
+ }
+
// return delegator's addresses
if ($context) {
$args['emails'] = $_SESSION['delegators'][$context];
$args['abort'] = true;
}
// return only user addresses (exclude all delegators addresses)
else if (!empty($_SESSION['delegators'])) {
$identities = $this->rc->user->list_emails();
$emails[] = $this->rc->user->get_username();
foreach ($identities as $identity) {
$emails[] = $identity['email'];
}
foreach ($_SESSION['delegators'] as $delegator_emails) {
$emails = array_diff($emails, $delegator_emails);
}
$args['emails'] = array_unique($emails);
$args['abort'] = true;
}
}
/**
* Filters list of calendars according to delegator context
*
* @param array $args Reference to plugin hook arguments
*/
public function delegator_folder_filter(&$args)
{
$context = $this->delegator_context();
$storage = $this->rc->get_storage();
$other_ns = $storage->get_namespace('other');
$delim = $storage->get_hierarchy_delimiter();
$calendars = array();
// code parts derived from kolab_driver::filter_calendars()
foreach ($args['list'] as $cal) {
if (!$cal->ready) {
continue;
}
if ($args['writeable'] && $cal->readonly) {
continue;
}
if ($args['active'] && !$cal->storage->is_active()) {
continue;
}
if ($args['personal']) {
$ns = $cal->get_namespace();
if (empty($context)) {
if ($ns != 'personal') {
continue;
}
}
else {
if ($ns != 'other') {
continue;
}
foreach ($other_ns as $ns) {
$folder = $ns[0] . $context . $delim;
if (strpos($cal->name, $folder) !== 0) {
continue;
}
}
}
}
$calendars[$cal->id] = $cal;
}
$args['calendars'] = $calendars;
$args['abort'] = true;
}
/**
* Filters/updates message headers according to delegator context
*
* @param array $args Reference to plugin hook arguments
*/
public function delegator_delivery_filter(&$args)
{
// no context, but message still can be send on behalf of...
if (!empty($_SESSION['delegators'])) {
$message = $args['message'];
$headers = $message->headers();
// get email address from From: header
$from = rcube_mime::decode_address_list($headers['From']);
$from = array_shift($from);
$from = $from['mailto'];
foreach ($_SESSION['delegators'] as $uid => $addresses) {
if (in_array($from, $addresses)) {
$context = $uid;
break;
}
}
// add Sender: header with current user default identity
if ($context) {
$identity = $this->rc->user->get_identity();
$sender = format_email_recipient($identity['email'], $identity['name']);
$message->headers(array('Sender' => $sender), false, true);
}
}
}
/**
* Compares two ACLs (according to supported rights)
*
* @todo: this is stolen from acl plugin, move to rcube_storage/rcube_imap
*
* @param array $acl1 ACL rights array (or string)
* @param array $acl2 ACL rights array (or string)
*
* @param int Comparision result, 2 - full match, 1 - partial match, 0 - no match
*/
function acl_compare($acl1, $acl2)
{
if (!is_array($acl1)) $acl1 = str_split($acl1);
if (!is_array($acl2)) $acl2 = str_split($acl2);
$rights = $this->rights_supported();
$acl1 = array_intersect($acl1, $rights);
$acl2 = array_intersect($acl2, $rights);
$res = array_intersect($acl1, $acl2);
$cnt1 = count($res);
$cnt2 = count($acl2);
if ($cnt1 == $cnt2)
return 2;
else if ($cnt1)
return 1;
else
return 0;
}
/**
* Get list of supported access rights (according to RIGHTS capability)
*
* @todo: this is stolen from acl plugin, move to rcube_storage/rcube_imap
*
* @return array List of supported access rights abbreviations
*/
public function rights_supported()
{
if ($this->supported !== null) {
return $this->supported;
}
$storage = $this->rc->get_storage();
$capa = $storage->get_capability('RIGHTS');
if (is_array($capa)) {
$rights = strtolower($capa[0]);
}
else {
$rights = 'cd';
}
return $this->supported = str_split('lrswi' . $rights . 'pa');
}
private function right_types()
{
// Get supported rights and build column names
$supported = $this->rights_supported();
// depending on server capability either use 'te' or 'd' for deleting msgs
$deleteright = implode(array_intersect(str_split('ted'), $supported));
return array(
self::ACL_READ => 'lrs',
self::ACL_WRITE => 'lrswi'.$deleteright,
);
}
}
diff --git a/plugins/libcalendaring/libcalendaring.php b/plugins/libcalendaring/libcalendaring.php
index 0384ba02..ec82fdfd 100644
--- a/plugins/libcalendaring/libcalendaring.php
+++ b/plugins/libcalendaring/libcalendaring.php
@@ -1,1543 +1,1548 @@
<?php
/**
* Library providing common functions for calendaring plugins
*
* Provides utility functions for calendar-related modules such as
* - alarms display and dismissal
* - attachment handling
* - recurrence computation and UI elements
* - ical parsing and exporting
* - itip scheduling protocol
*
* @version @package_version@
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2012-2014, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
class libcalendaring extends rcube_plugin
{
public $rc;
public $timezone;
public $gmt_offset;
public $dst_active;
public $timezone_offset;
public $ical_parts = array();
public $ical_message;
public $defaults = array(
'calendar_date_format' => "yyyy-MM-dd",
'calendar_date_short' => "M-d",
'calendar_date_long' => "MMM d yyyy",
'calendar_date_agenda' => "ddd MM-dd",
'calendar_time_format' => "HH:mm",
'calendar_first_day' => 1,
'calendar_first_hour' => 6,
'calendar_date_format_sets' => array(
'yyyy-MM-dd' => array('MMM d yyyy', 'M-d', 'ddd MM-dd'),
'dd-MM-yyyy' => array('d MMM yyyy', 'd-M', 'ddd dd-MM'),
'yyyy/MM/dd' => array('MMM d yyyy', 'M/d', 'ddd MM/dd'),
'MM/dd/yyyy' => array('MMM d yyyy', 'M/d', 'ddd MM/dd'),
'dd/MM/yyyy' => array('d MMM yyyy', 'd/M', 'ddd dd/MM'),
'dd.MM.yyyy' => array('dd. MMM yyyy', 'd.M', 'ddd dd.MM.'),
'd.M.yyyy' => array('d. MMM yyyy', 'd.M', 'ddd d.MM.'),
),
);
private static $instance;
private static $email_regex = '/([a-z0-9][a-z0-9\-\.\+\_]*@[^&@"\'.][^@&"\']*\\.([^\\x00-\\x40\\x5b-\\x60\\x7b-\\x7f]{2,}|xn--[a-z0-9]{2,}))/';
private $mail_ical_parser;
/**
* Singleton getter to allow direct access from other plugins
*/
public static function get_instance()
{
return self::$instance;
}
/**
* Required plugin startup method
*/
public function init()
{
self::$instance = $this;
$this->rc = rcube::get_instance();
// set user's timezone
try {
$this->timezone = new DateTimeZone($this->rc->config->get('timezone', 'GMT'));
}
catch (Exception $e) {
$this->timezone = new DateTimeZone('GMT');
}
$now = new DateTime('now', $this->timezone);
$this->gmt_offset = $now->getOffset();
$this->dst_active = $now->format('I');
$this->timezone_offset = $this->gmt_offset / 3600 - $this->dst_active;
$this->add_texts('localization/', false);
// include client scripts and styles
if ($this->rc->output) {
if ($this->rc->output->type == 'html') {
$this->rc->output->set_env('libcal_settings', $this->load_settings());
$this->include_script('libcalendaring.js');
$this->include_stylesheet($this->local_skin_path() . '/libcal.css');
}
// add hook to display alarms
$this->add_hook('refresh', array($this, 'refresh'));
$this->register_action('plugin.alarms', array($this, 'alarms_action'));
$this->register_action('plugin.expand_attendee_group', array($this, 'expand_attendee_group'));
}
// proceed initialization in startup hook
$this->add_hook('startup', array($this, 'startup'));
}
/**
* Startup hook
*/
public function startup($args)
{
if ($args['task'] == 'mail') {
if ($args['action'] == 'show' || $args['action'] == 'preview') {
$this->add_hook('message_load', array($this, 'mail_message_load'));
}
}
}
/**
* Load iCalendar functions
*/
public static function get_ical()
{
$self = self::get_instance();
require_once($self->home . '/libvcalendar.php');
return new libvcalendar();
}
/**
* Load iTip functions
*/
public static function get_itip($domain = 'libcalendaring')
{
$self = self::get_instance();
require_once($self->home . '/lib/libcalendaring_itip.php');
return new libcalendaring_itip($self, $domain);
}
/**
* Load recurrence computation engine
*/
public static function get_recurrence()
{
$self = self::get_instance();
require_once($self->home . '/lib/libcalendaring_recurrence.php');
return new libcalendaring_recurrence($self);
}
/**
* Shift dates into user's current timezone
*
* @param mixed Any kind of a date representation (DateTime object, string or unix timestamp)
* @return object DateTime object in user's timezone
*/
public function adjust_timezone($dt, $dateonly = false)
{
if (is_numeric($dt))
$dt = new DateTime('@'.$dt);
else if (is_string($dt))
$dt = rcube_utils::anytodatetime($dt);
if ($dt instanceof DateTime && !($dt->_dateonly || $dateonly)) {
$dt->setTimezone($this->timezone);
}
return $dt;
}
/**
*
*/
public function load_settings()
{
$this->date_format_defaults();
$settings = array();
// configuration
$settings['date_format'] = (string)$this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format']);
$settings['time_format'] = (string)$this->rc->config->get('calendar_time_format', $this->defaults['calendar_time_format']);
$settings['date_short'] = (string)$this->rc->config->get('calendar_date_short', $this->defaults['calendar_date_short']);
$settings['date_long'] = (string)$this->rc->config->get('calendar_date_long', $this->defaults['calendar_date_long']);
$settings['dates_long'] = str_replace(' yyyy', '[ yyyy]', $settings['date_long']) . "{ '&mdash;' " . $settings['date_long'] . '}';
$settings['first_day'] = (int)$this->rc->config->get('calendar_first_day', $this->defaults['calendar_first_day']);
$settings['timezone'] = $this->timezone_offset;
$settings['dst'] = $this->dst_active;
// localization
$settings['days'] = array(
$this->rc->gettext('sunday'), $this->rc->gettext('monday'),
$this->rc->gettext('tuesday'), $this->rc->gettext('wednesday'),
$this->rc->gettext('thursday'), $this->rc->gettext('friday'),
$this->rc->gettext('saturday')
);
$settings['days_short'] = array(
$this->rc->gettext('sun'), $this->rc->gettext('mon'),
$this->rc->gettext('tue'), $this->rc->gettext('wed'),
$this->rc->gettext('thu'), $this->rc->gettext('fri'),
$this->rc->gettext('sat')
);
$settings['months'] = array(
$this->rc->gettext('longjan'), $this->rc->gettext('longfeb'),
$this->rc->gettext('longmar'), $this->rc->gettext('longapr'),
$this->rc->gettext('longmay'), $this->rc->gettext('longjun'),
$this->rc->gettext('longjul'), $this->rc->gettext('longaug'),
$this->rc->gettext('longsep'), $this->rc->gettext('longoct'),
$this->rc->gettext('longnov'), $this->rc->gettext('longdec')
);
$settings['months_short'] = array(
$this->rc->gettext('jan'), $this->rc->gettext('feb'),
$this->rc->gettext('mar'), $this->rc->gettext('apr'),
$this->rc->gettext('may'), $this->rc->gettext('jun'),
$this->rc->gettext('jul'), $this->rc->gettext('aug'),
$this->rc->gettext('sep'), $this->rc->gettext('oct'),
$this->rc->gettext('nov'), $this->rc->gettext('dec')
);
$settings['today'] = $this->rc->gettext('today');
// define list of file types which can be displayed inline
// same as in program/steps/mail/show.inc
$settings['mimetypes'] = (array)$this->rc->config->get('client_mimetypes');
return $settings;
}
/**
* Helper function to set date/time format according to config and user preferences
*/
private function date_format_defaults()
{
static $defaults = array();
// nothing to be done
if (isset($defaults['date_format']))
return;
$defaults['date_format'] = $this->rc->config->get('calendar_date_format', self::from_php_date_format($this->rc->config->get('date_format')));
$defaults['time_format'] = $this->rc->config->get('calendar_time_format', self::from_php_date_format($this->rc->config->get('time_format')));
// override defaults
if ($defaults['date_format'])
$this->defaults['calendar_date_format'] = $defaults['date_format'];
if ($defaults['time_format'])
$this->defaults['calendar_time_format'] = $defaults['time_format'];
// derive format variants from basic date format
$format_sets = $this->rc->config->get('calendar_date_format_sets', $this->defaults['calendar_date_format_sets']);
if ($format_set = $format_sets[$this->defaults['calendar_date_format']]) {
$this->defaults['calendar_date_long'] = $format_set[0];
$this->defaults['calendar_date_short'] = $format_set[1];
$this->defaults['calendar_date_agenda'] = $format_set[2];
}
}
/**
* Compose a date string for the given event
*/
public function event_date_text($event, $tzinfo = false)
{
$fromto = '--';
// handle task objects
if ($event['_type'] == 'task' && is_object($event['due'])) {
$date_format = $event['due']->_dateonly ? self::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format'])) : null;
$fromto = $this->rc->format_date($event['due'], $date_format, false);
// add timezone information
if ($fromto && $tzinfo && ($tzname = $this->timezone->getName())) {
$fromto .= ' (' . strtr($tzname, '_', ' ') . ')';
}
return $fromto;
}
// abort if no valid event dates are given
if (!is_object($event['start']) || !is_a($event['start'], 'DateTime') || !is_object($event['end']) || !is_a($event['end'], 'DateTime')) {
return $fromto;
}
$duration = $event['start']->diff($event['end'])->format('s');
$this->date_format_defaults();
$date_format = self::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format']));
$time_format = self::to_php_date_format($this->rc->config->get('calendar_time_format', $this->defaults['calendar_time_format']));
if ($event['allday']) {
$fromto = format_date($event['start'], $date_format);
if (($todate = format_date($event['end'], $date_format)) != $fromto)
$fromto .= ' - ' . $todate;
}
else if ($duration < 86400 && $event['start']->format('d') == $event['end']->format('d')) {
$fromto = format_date($event['start'], $date_format) . ' ' . format_date($event['start'], $time_format) .
' - ' . format_date($event['end'], $time_format);
}
else {
$fromto = format_date($event['start'], $date_format) . ' ' . format_date($event['start'], $time_format) .
' - ' . format_date($event['end'], $date_format) . ' ' . format_date($event['end'], $time_format);
}
// add timezone information
if ($tzinfo && ($tzname = $this->timezone->getName())) {
$fromto .= ' (' . strtr($tzname, '_', ' ') . ')';
}
return $fromto;
}
/**
* Render HTML form for alarm configuration
*/
public function alarm_select($attrib, $alarm_types, $absolute_time = true)
{
unset($attrib['name']);
$select_type = new html_select(array('name' => 'alarmtype[]', 'class' => 'edit-alarm-type', 'id' => $attrib['id']));
$select_type->add($this->gettext('none'), '');
foreach ($alarm_types as $type)
$select_type->add($this->gettext(strtolower("alarm{$type}option")), $type);
$input_value = new html_inputfield(array('name' => 'alarmvalue[]', 'class' => 'edit-alarm-value', 'size' => 3));
$input_date = new html_inputfield(array('name' => 'alarmdate[]', 'class' => 'edit-alarm-date', 'size' => 10));
$input_time = new html_inputfield(array('name' => 'alarmtime[]', 'class' => 'edit-alarm-time', 'size' => 6));
$select_offset = new html_select(array('name' => 'alarmoffset[]', 'class' => 'edit-alarm-offset'));
foreach (array('-M','-H','-D','+M','+H','+D') as $trigger)
$select_offset->add($this->gettext('trigger' . $trigger), $trigger);
if ($absolute_time)
$select_offset->add($this->gettext('trigger@'), '@');
// pre-set with default values from user settings
$preset = self::parse_alaram_value($this->rc->config->get('calendar_default_alarm_offset', '-15M'));
$hidden = array('style' => 'display:none');
$html = html::span('edit-alarm-set',
$select_type->show($this->rc->config->get('calendar_default_alarm_type', '')) . ' ' .
html::span(array('class' => 'edit-alarm-values', 'style' => 'display:none'),
$input_value->show($preset[0]) . ' ' .
$select_offset->show($preset[1]) . ' ' .
$input_date->show('', $hidden) . ' ' .
$input_time->show('', $hidden)
)
);
// TODO: support adding more alarms
#$html .= html::a(array('href' => '#', 'id' => 'edit-alam-add', 'title' => $this->gettext('addalarm')),
# $attrib['addicon'] ? html::img(array('src' => $attrib['addicon'], 'alt' => 'add')) : '(+)');
return $html;
}
/**
- * Get a list of email addresses of the current user (from login and identities)
+ * Get a list of email addresses of the given user (from login and identities)
+ *
+ * @param string User Email (default to current user)
+ * @return array Email addresses related to the user
*/
- public function get_user_emails()
+ public function get_user_emails($user = null)
{
- static $emails;
+ static $_emails = array();
+
+ if (empty($user)) {
+ $user = $this->rc->user->get_username();
+ }
// return cached result
- if (is_array($emails)) {
- return $emails;
+ if (is_array($_emails[$user])) {
+ return $_emails[$user];
}
- $emails = array();
+ $emails = array($user);
$plugin = $this->rc->plugins->exec_hook('calendar_user_emails', array('emails' => $emails));
$emails = array_map('strtolower', $plugin['emails']);
- if ($plugin['abort']) {
- return $emails;
- }
-
- $emails[] = $this->rc->user->get_username();
- foreach ($this->rc->user->list_emails() as $identity) {
- $emails[] = strtolower($identity['email']);
+ // add all emails from the current user's identities
+ if (!$plugin['abort'] && ($user == $this->rc->user->get_username())) {
+ foreach ($this->rc->user->list_emails() as $identity) {
+ $emails[] = strtolower($identity['email']);
+ }
}
- $emails = array_unique($emails);
- return $emails;
+ $_emails[$user] = array_unique($emails);
+ return $_emails[$user];
}
/**
* Set the given participant status to the attendee matching the current user's identities
*
* @param array Hash array with event struct
* @param string The PARTSTAT value to set
* @return mixed Email address of the updated attendee or False if none matching found
*/
public function set_partstat(&$event, $status)
{
$emails = $this->get_user_emails();
foreach ((array)$event['attendees'] as $i => $attendee) {
if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) {
$event['attendees'][$i]['status'] = strtoupper($status);
return $attendee['email'];
}
}
return false;
}
/********* Alarms handling *********/
/**
* Helper function to convert alarm trigger strings
* into two-field values (e.g. "-45M" => 45, "-M")
*/
public static function parse_alaram_value($val)
{
if ($val[0] == '@') {
return array(new DateTime($val));
}
else if (preg_match('/([+-]?)P?(T?\d+[HMSDW])+/', $val, $m) && preg_match_all('/T?(\d+)([HMSDW])/', $val, $m2, PREG_SET_ORDER)) {
if ($m[1] == '')
$m[1] = '+';
foreach ($m2 as $seg) {
$prefix = $seg[2] == 'D' || $seg[2] == 'W' ? 'P' : 'PT';
if ($seg[1] > 0) { // ignore zero values
return array($seg[1], $m[1].$seg[2], $m[1].$seg[1].$seg[2], $m[1].$prefix.$seg[1].$seg[2]);
}
}
// return zero value nevertheless
return array($seg[1], $m[1].$seg[2], $m[1].$seg[1].$seg[2], $m[1].$prefix.$seg[1].$seg[2]);
}
return false;
}
/**
* Convert the alarms list items to be processed on the client
*/
public static function to_client_alarms($valarms)
{
return array_map(function($alarm){
if ($alarm['trigger'] instanceof DateTime) {
$alarm['trigger'] = '@' . $alarm['trigger']->format('U');
}
else if ($trigger = libcalendaring::parse_alaram_value($alarm['trigger'])) {
$alarm['trigger'] = $trigger[2];
}
return $alarm;
}, (array)$valarms);
}
/**
* Process the alarms values submitted by the client
*/
public static function from_client_alarms($valarms)
{
return array_map(function($alarm){
if ($alarm['trigger'][0] == '@') {
try {
$alarm['trigger'] = new DateTime($alarm['trigger']);
$alarm['trigger']->setTimezone(new DateTimeZone('UTC'));
}
catch (Exception $e) { /* handle this ? */ }
}
else if ($trigger = libcalendaring::parse_alaram_value($alarm['trigger'])) {
$alarm['trigger'] = $trigger[3];
}
return $alarm;
}, (array)$valarms);
}
/**
* Render localized text for alarm settings
*/
public static function alarms_text($alarms)
{
if (is_array($alarms) && is_array($alarms[0])) {
$texts = array();
foreach ($alarms as $alarm) {
if ($text = self::alarm_text($alarm))
$texts[] = $text;
}
return join(', ', $texts);
}
else {
return self::alarm_text($alarms);
}
}
/**
* Render localized text for a single alarm property
*/
public static function alarm_text($alarm)
{
if (is_string($alarm)) {
list($trigger, $action) = explode(':', $alarm);
}
else {
$trigger = $alarm['trigger'];
$action = $alarm['action'];
}
$text = '';
$rcube = rcube::get_instance();
switch ($action) {
case 'EMAIL':
$text = $rcube->gettext('libcalendaring.alarmemail');
break;
case 'DISPLAY':
$text = $rcube->gettext('libcalendaring.alarmdisplay');
break;
case 'AUDIO':
$text = $rcube->gettext('libcalendaring.alarmaudio');
break;
}
if ($trigger instanceof DateTime) {
$text .= ' ' . $rcube->gettext(array(
'name' => 'libcalendaring.alarmat',
'vars' => array('datetime' => $rcube->format_date($trigger))
));
}
else if (preg_match('/@(\d+)/', $trigger, $m)) {
$text .= ' ' . $rcube->gettext(array(
'name' => 'libcalendaring.alarmat',
'vars' => array('datetime' => $rcube->format_date($m[1]))
));
}
else if ($val = self::parse_alaram_value($trigger)) {
// TODO: for all-day events say 'on date of event at XX' ?
if ($val[0] == 0)
$text .= ' ' . $rcube->gettext('libcalendaring.triggerattime');
else
$text .= ' ' . intval($val[0]) . ' ' . $rcube->gettext('libcalendaring.trigger' . $val[1]);
}
else {
return false;
}
return $text;
}
/**
* Get the next alarm (time & action) for the given event
*
* @param array Record data
* @return array Hash array with alarm time/type or null if no alarms are configured
*/
public static function get_next_alarm($rec, $type = 'event')
{
if (!($rec['valarms'] || $rec['alarms']) || $rec['cancelled'] || $rec['status'] == 'CANCELLED')
return null;
if ($type == 'task') {
$timezone = self::get_instance()->timezone;
if ($rec['startdate'])
$rec['start'] = new DateTime($rec['startdate'] . ' ' . ($rec['starttime'] ?: '12:00'), $timezone);
if ($rec['date'])
$rec[($rec['start'] ? 'end' : 'start')] = new DateTime($rec['date'] . ' ' . ($rec['time'] ?: '12:00'), $timezone);
}
if (!$rec['end'])
$rec['end'] = $rec['start'];
// support legacy format
if (!$rec['valarms']) {
list($trigger, $action) = explode(':', $rec['alarms'], 2);
if ($alarm = self::parse_alaram_value($trigger)) {
$rec['valarms'] = array(array('action' => $action, 'trigger' => $alarm[3] ?: $alarm[0]));
}
}
$expires = new DateTime('now - 12 hours');
$alarm_id = $rec['id']; // alarm ID eq. record ID by default to keep backwards compatibility
// handle multiple alarms
$notify_at = null;
foreach ($rec['valarms'] as $alarm) {
$notify_time = null;
if ($alarm['trigger'] instanceof DateTime) {
$notify_time = $alarm['trigger'];
}
else if (is_string($alarm['trigger'])) {
$refdate = $alarm['trigger'][0] == '+' ? $rec['end'] : $rec['start'];
// abort if no reference date is available to compute notification time
if (!is_a($refdate, 'DateTime'))
continue;
// TODO: for all-day events, take start @ 00:00 as reference date ?
try {
$interval = new DateInterval(trim($alarm['trigger'], '+-'));
$interval->invert = $alarm['trigger'][0] != '+';
$notify_time = clone $refdate;
$notify_time->add($interval);
}
catch (Exception $e) {
rcube::raise_error($e, true);
continue;
}
}
if ($notify_time && (!$notify_at || ($notify_time > $notify_at && $notify_time > $expires))) {
$notify_at = $notify_time;
$action = $alarm['action'];
$alarm_prop = $alarm;
// generate a unique alarm ID if multiple alarms are set
if (count($rec['valarms']) > 1) {
$alarm_id = substr(md5($rec['id']), 0, 16) . '-' . $notify_at->format('Ymd\THis');
}
}
}
return !$notify_at ? null : array(
'time' => $notify_at->format('U'),
'action' => $action ? strtoupper($action) : 'DISPLAY',
'id' => $alarm_id,
'prop' => $alarm_prop,
);
}
/**
* Handler for keep-alive requests
* This will check for pending notifications and pass them to the client
*/
public function refresh($attr)
{
// collect pending alarms from all providers (e.g. calendar, tasks)
$plugin = $this->rc->plugins->exec_hook('pending_alarms', array(
'time' => time(),
'alarms' => array(),
));
if (!$plugin['abort'] && !empty($plugin['alarms'])) {
// make sure texts and env vars are available on client
$this->add_texts('localization/', true);
$this->rc->output->add_label('close');
$this->rc->output->set_env('snooze_select', $this->snooze_select());
$this->rc->output->command('plugin.display_alarms', $this->_alarms_output($plugin['alarms']));
}
}
/**
* Handler for alarm dismiss/snooze requests
*/
public function alarms_action()
{
// $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC);
$data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true);
$data['ids'] = explode(',', $data['id']);
$plugin = $this->rc->plugins->exec_hook('dismiss_alarms', $data);
if ($plugin['success'])
$this->rc->output->show_message('successfullysaved', 'confirmation');
else
$this->rc->output->show_message('calendar.errorsaving', 'error');
}
/**
* Generate reduced and streamlined output for pending alarms
*/
private function _alarms_output($alarms)
{
$out = array();
foreach ($alarms as $alarm) {
$out[] = array(
'id' => $alarm['id'],
'start' => $alarm['start'] ? $this->adjust_timezone($alarm['start'])->format('c') : '',
'end' => $alarm['end'] ? $this->adjust_timezone($alarm['end'])->format('c') : '',
'allDay' => ($alarm['allday'] == 1)?true:false,
'title' => $alarm['title'],
'location' => $alarm['location'],
'calendar' => $alarm['calendar'],
);
}
return $out;
}
/**
* Render a dropdown menu to choose snooze time
*/
private function snooze_select($attrib = array())
{
$steps = array(
5 => 'repeatinmin',
10 => 'repeatinmin',
15 => 'repeatinmin',
20 => 'repeatinmin',
30 => 'repeatinmin',
60 => 'repeatinhr',
120 => 'repeatinhrs',
1440 => 'repeattomorrow',
10080 => 'repeatinweek',
);
$items = array();
foreach ($steps as $n => $label) {
$items[] = html::tag('li', null, html::a(array('href' => "#" . ($n * 60), 'class' => 'active'),
$this->gettext(array('name' => $label, 'vars' => array('min' => $n % 60, 'hrs' => intval($n / 60))))));
}
return html::tag('ul', $attrib + array('class' => 'toolbarmenu'), join("\n", $items), html::$common_attrib);
}
/********* Recurrence rules handling ********/
/**
* Render localized text describing the recurrence rule of an event
*/
public function recurrence_text($rrule)
{
// derive missing FREQ and INTERVAL from RDATE list
if (empty($rrule['FREQ']) && !empty($rrule['RDATE'])) {
$first = $rrule['RDATE'][0];
$second = $rrule['RDATE'][1];
$third = $rrule['RDATE'][2];
if (is_a($first, 'DateTime') && is_a($second, 'DateTime')) {
$diff = $first->diff($second);
foreach (array('y' => 'YEARLY', 'm' => 'MONTHLY', 'd' => 'DAILY') as $k => $freq) {
if ($diff->$k != 0) {
$rrule['FREQ'] = $freq;
$rrule['INTERVAL'] = $diff->$k;
// verify interval with next item
if (is_a($third, 'DateTime')) {
$diff2 = $second->diff($third);
if ($diff2->$k != $diff->$k) {
unset($rrule['INTERVAL']);
}
}
break;
}
}
}
if (!$rrule['INTERVAL']) {
$rrule['FREQ'] = 'RDATE';
}
$rrule['UNTIL'] = end($rrule['RDATE']);
}
$freq = sprintf('%s %d ', $this->gettext('every'), $rrule['INTERVAL']);
$details = '';
switch ($rrule['FREQ']) {
case 'DAILY':
$freq .= $this->gettext('days');
break;
case 'WEEKLY':
$freq .= $this->gettext('weeks');
break;
case 'MONTHLY':
$freq .= $this->gettext('months');
break;
case 'YEARLY':
$freq .= $this->gettext('years');
break;
}
if ($rrule['INTERVAL'] <= 1) {
$freq = $this->gettext(strtolower($rrule['FREQ']));
}
if ($rrule['COUNT']) {
$until = $this->gettext(array('name' => 'forntimes', 'vars' => array('nr' => $rrule['COUNT'])));
}
else if ($rrule['UNTIL']) {
$until = $this->gettext('recurrencend') . ' ' . format_date($rrule['UNTIL'], self::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format'])));
}
else {
$until = $this->gettext('forever');
}
$except = '';
if (is_array($rrule['EXDATE']) && !empty($rrule['EXDATE'])) {
$format = self::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format']));
$exdates = array_map(
function($dt) use ($format) { return format_date($dt, $format); },
array_slice($rrule['EXDATE'], 0, 10)
);
$except = '; ' . $this->gettext('except') . ' ' . join(', ', $exdates);
}
return rtrim($freq . $details . ', ' . $until . $except);
}
/**
* Generate the form for recurrence settings
*/
public function recurrence_form($attrib = array())
{
switch ($attrib['part']) {
// frequency selector
case 'frequency':
$select = new html_select(array('name' => 'frequency', 'id' => 'edit-recurrence-frequency'));
$select->add($this->gettext('never'), '');
$select->add($this->gettext('daily'), 'DAILY');
$select->add($this->gettext('weekly'), 'WEEKLY');
$select->add($this->gettext('monthly'), 'MONTHLY');
$select->add($this->gettext('yearly'), 'YEARLY');
$select->add($this->gettext('rdate'), 'RDATE');
$html = html::label('edit-recurrence-frequency', $this->gettext('frequency')) . $select->show('');
break;
// daily recurrence
case 'daily':
$select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval', 'id' => 'edit-recurrence-interval-daily'));
$html = html::div($attrib, html::label('edit-recurrence-interval-daily', $this->gettext('every')) . $select->show(1) . html::span('label-after', $this->gettext('days')));
break;
// weekly recurrence form
case 'weekly':
$select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval', 'id' => 'edit-recurrence-interval-weekly'));
$html = html::div($attrib, html::label('edit-recurrence-interval-weekly', $this->gettext('every')) . $select->show(1) . html::span('label-after', $this->gettext('weeks')));
// weekday selection
$daymap = array('sun','mon','tue','wed','thu','fri','sat');
$checkbox = new html_checkbox(array('name' => 'byday', 'class' => 'edit-recurrence-weekly-byday'));
$first = $this->rc->config->get('calendar_first_day', 1);
for ($weekdays = '', $j = $first; $j <= $first+6; $j++) {
$d = $j % 7;
$weekdays .= html::label(array('class' => 'weekday'),
$checkbox->show('', array('value' => strtoupper(substr($daymap[$d], 0, 2)))) .
$this->gettext($daymap[$d])
) . ' ';
}
$html .= html::div($attrib, html::label(null, $this->gettext('bydays')) . $weekdays);
break;
// monthly recurrence form
case 'monthly':
$select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval', 'id' => 'edit-recurrence-interval-monthly'));
$html = html::div($attrib, html::label('edit-recurrence-interval-monthly', $this->gettext('every')) . $select->show(1) . html::span('label-after', $this->gettext('months')));
$checkbox = new html_checkbox(array('name' => 'bymonthday', 'class' => 'edit-recurrence-monthly-bymonthday'));
for ($monthdays = '', $d = 1; $d <= 31; $d++) {
$monthdays .= html::label(array('class' => 'monthday'), $checkbox->show('', array('value' => $d)) . $d);
$monthdays .= $d % 7 ? ' ' : html::br();
}
// rule selectors
$radio = new html_radiobutton(array('name' => 'repeatmode', 'class' => 'edit-recurrence-monthly-mode'));
$table = new html_table(array('cols' => 2, 'border' => 0, 'cellpadding' => 0, 'class' => 'formtable'));
$table->add('label', html::label(null, $radio->show('BYMONTHDAY', array('value' => 'BYMONTHDAY')) . ' ' . $this->gettext('each')));
$table->add(null, $monthdays);
$table->add('label', html::label(null, $radio->show('', array('value' => 'BYDAY')) . ' ' . $this->gettext('onevery')));
$table->add(null, $this->rrule_selectors($attrib['part']));
$html .= html::div($attrib, $table->show());
break;
// annually recurrence form
case 'yearly':
$select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval', 'id' => 'edit-recurrence-interval-yearly'));
$html = html::div($attrib, html::label('edit-recurrence-interval-yearly', $this->gettext('every')) . $select->show(1) . html::span('label-after', $this->gettext('years')));
// month selector
$monthmap = array('','jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec');
$checkbox = new html_checkbox(array('name' => 'bymonth', 'class' => 'edit-recurrence-yearly-bymonth'));
for ($months = '', $m = 1; $m <= 12; $m++) {
$months .= html::label(array('class' => 'month'), $checkbox->show(null, array('value' => $m)) . $this->gettext($monthmap[$m]));
$months .= $m % 4 ? ' ' : html::br();
}
$html .= html::div($attrib + array('id' => 'edit-recurrence-yearly-bymonthblock'), $months);
// day rule selection
$html .= html::div($attrib, html::label(null, $this->gettext('onevery')) . $this->rrule_selectors($attrib['part'], '---'));
break;
// end of recurrence form
case 'until':
$radio = new html_radiobutton(array('name' => 'repeat', 'class' => 'edit-recurrence-until'));
$select = $this->interval_selector(array('name' => 'times', 'id' => 'edit-recurrence-repeat-times'));
$input = new html_inputfield(array('name' => 'untildate', 'id' => 'edit-recurrence-enddate', 'size' => "10"));
$html = html::div('line first',
html::label(null, $radio->show('', array('value' => '', 'id' => 'edit-recurrence-repeat-forever')) . ' ' .
$this->gettext('forever'))
);
$forntimes = $this->gettext(array(
'name' => 'forntimes',
'vars' => array('nr' => '%s'))
);
$html .= html::div('line',
$radio->show('', array('value' => 'count', 'id' => 'edit-recurrence-repeat-count', 'aria-label' => sprintf($forntimes, 'N'))) . ' ' .
sprintf($forntimes, $select->show(1))
);
$html .= html::div('line',
$radio->show('', array('value' => 'until', 'id' => 'edit-recurrence-repeat-until', 'aria-label' => $this->gettext('untilenddate'))) . ' ' .
$this->gettext('untildate') . ' ' . $input->show('', array('aria-label' => $this->gettext('untilenddate')))
);
$html = html::div($attrib, html::label(null, ucfirst($this->gettext('recurrencend'))) . $html);
break;
case 'rdate':
$ul = html::tag('ul', array('id' => 'edit-recurrence-rdates'), '');
$input = new html_inputfield(array('name' => 'rdate', 'id' => 'edit-recurrence-rdate-input', 'size' => "10"));
$button = new html_inputfield(array('type' => 'button', 'class' => 'button add', 'value' => $this->gettext('addrdate')));
$html .= html::div($attrib, $ul . html::div('inputform', $input->show() . $button->show()));
break;
}
return $html;
}
/**
* Input field for interval selection
*/
private function interval_selector($attrib)
{
$select = new html_select($attrib);
$select->add(range(1,30), range(1,30));
return $select;
}
/**
* Drop-down menus for recurrence rules like "each last sunday of"
*/
private function rrule_selectors($part, $noselect = null)
{
// rule selectors
$select_prefix = new html_select(array('name' => 'bydayprefix', 'id' => "edit-recurrence-$part-prefix"));
if ($noselect) $select_prefix->add($noselect, '');
$select_prefix->add(array(
$this->gettext('first'),
$this->gettext('second'),
$this->gettext('third'),
$this->gettext('fourth'),
$this->gettext('last')
),
array(1, 2, 3, 4, -1));
$select_wday = new html_select(array('name' => 'byday', 'id' => "edit-recurrence-$part-byday"));
if ($noselect) $select_wday->add($noselect, '');
$daymap = array('sunday','monday','tuesday','wednesday','thursday','friday','saturday');
$first = $this->rc->config->get('calendar_first_day', 1);
for ($j = $first; $j <= $first+6; $j++) {
$d = $j % 7;
$select_wday->add($this->gettext($daymap[$d]), strtoupper(substr($daymap[$d], 0, 2)));
}
return $select_prefix->show() . '&nbsp;' . $select_wday->show();
}
/**
* Convert the recurrence settings to be processed on the client
*/
public function to_client_recurrence($recurrence, $allday = false)
{
if ($recurrence['UNTIL'])
$recurrence['UNTIL'] = $this->adjust_timezone($recurrence['UNTIL'], $allday)->format('c');
// format RDATE values
if (is_array($recurrence['RDATE'])) {
$libcal = $this;
$recurrence['RDATE'] = array_map(function($rdate) use ($libcal) {
return $libcal->adjust_timezone($rdate, true)->format('c');
}, $recurrence['RDATE']);
}
unset($recurrence['EXCEPTIONS']);
return $recurrence;
}
/**
* Process the alarms values submitted by the client
*/
public function from_client_recurrence($recurrence, $start = null)
{
if (is_array($recurrence) && !empty($recurrence['UNTIL'])) {
$recurrence['UNTIL'] = new DateTime($recurrence['UNTIL'], $this->timezone);
}
if (is_array($recurrence) && is_array($recurrence['RDATE'])) {
$tz = $this->timezone;
$recurrence['RDATE'] = array_map(function($rdate) use ($tz, $start) {
try {
$dt = new DateTime($rdate, $tz);
if (is_a($start, 'DateTime'))
$dt->setTime($start->format('G'), $start->format('i'));
return $dt;
}
catch (Exception $e) {
return null;
}
}, $recurrence['RDATE']);
}
return $recurrence;
}
/********* Attachments handling *********/
/**
* Handler for attachment uploads
*/
public function attachment_upload($session_key, $id_prefix = '')
{
// Upload progress update
if (!empty($_GET['_progress'])) {
$this->rc->upload_progress();
}
$recid = $id_prefix . rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC);
$uploadid = rcube_utils::get_input_value('_uploadid', rcube_utils::INPUT_GPC);
if (!is_array($_SESSION[$session_key]) || $_SESSION[$session_key]['id'] != $recid) {
$_SESSION[$session_key] = array();
$_SESSION[$session_key]['id'] = $recid;
$_SESSION[$session_key]['attachments'] = array();
}
// clear all stored output properties (like scripts and env vars)
$this->rc->output->reset();
if (is_array($_FILES['_attachments']['tmp_name'])) {
foreach ($_FILES['_attachments']['tmp_name'] as $i => $filepath) {
// Process uploaded attachment if there is no error
$err = $_FILES['_attachments']['error'][$i];
if (!$err) {
$attachment = array(
'path' => $filepath,
'size' => $_FILES['_attachments']['size'][$i],
'name' => $_FILES['_attachments']['name'][$i],
'mimetype' => rcube_mime::file_content_type($filepath, $_FILES['_attachments']['name'][$i], $_FILES['_attachments']['type'][$i]),
'group' => $recid,
);
$attachment = $this->rc->plugins->exec_hook('attachment_upload', $attachment);
}
if (!$err && $attachment['status'] && !$attachment['abort']) {
$id = $attachment['id'];
// store new attachment in session
unset($attachment['status'], $attachment['abort']);
$_SESSION[$session_key]['attachments'][$id] = $attachment;
if (($icon = $_SESSION[$session_key . '_deleteicon']) && is_file($icon)) {
$button = html::img(array(
'src' => $icon,
'alt' => $this->rc->gettext('delete')
));
}
else {
$button = Q($this->rc->gettext('delete'));
}
$content = html::a(array(
'href' => "#delete",
'class' => 'delete',
'onclick' => sprintf("return %s.remove_from_attachment_list('rcmfile%s')", JS_OBJECT_NAME, $id),
'title' => $this->rc->gettext('delete'),
'aria-label' => $this->rc->gettext('delete') . ' ' . $attachment['name'],
), $button);
$content .= Q($attachment['name']);
$this->rc->output->command('add2attachment_list', "rcmfile$id", array(
'html' => $content,
'name' => $attachment['name'],
'mimetype' => $attachment['mimetype'],
'classname' => rcube_utils::file2class($attachment['mimetype'], $attachment['name']),
'complete' => true), $uploadid);
}
else { // upload failed
if ($err == UPLOAD_ERR_INI_SIZE || $err == UPLOAD_ERR_FORM_SIZE) {
$msg = $this->rc->gettext(array('name' => 'filesizeerror', 'vars' => array(
'size' => show_bytes(parse_bytes(ini_get('upload_max_filesize'))))));
}
else if ($attachment['error']) {
$msg = $attachment['error'];
}
else {
$msg = $this->rc->gettext('fileuploaderror');
}
$this->rc->output->command('display_message', $msg, 'error');
$this->rc->output->command('remove_from_attachment_list', $uploadid);
}
}
}
else if ($_SERVER['REQUEST_METHOD'] == 'POST') {
// if filesize exceeds post_max_size then $_FILES array is empty,
// show filesizeerror instead of fileuploaderror
if ($maxsize = ini_get('post_max_size'))
$msg = $this->rc->gettext(array('name' => 'filesizeerror', 'vars' => array(
'size' => show_bytes(parse_bytes($maxsize)))));
else
$msg = $this->rc->gettext('fileuploaderror');
$this->rc->output->command('display_message', $msg, 'error');
$this->rc->output->command('remove_from_attachment_list', $uploadid);
}
$this->rc->output->send('iframe');
}
/**
* Deliver an event/task attachment to the client
* (similar as in Roundcube core program/steps/mail/get.inc)
*/
public function attachment_get($attachment)
{
ob_end_clean();
if ($attachment && $attachment['body']) {
// allow post-processing of the attachment body
$part = new rcube_message_part;
$part->filename = $attachment['name'];
$part->size = $attachment['size'];
$part->mimetype = $attachment['mimetype'];
$plugin = $this->rc->plugins->exec_hook('message_part_get', array(
'body' => $attachment['body'],
'mimetype' => strtolower($attachment['mimetype']),
'download' => !empty($_GET['_download']),
'part' => $part,
));
if ($plugin['abort'])
exit;
$mimetype = $plugin['mimetype'];
list($ctype_primary, $ctype_secondary) = explode('/', $mimetype);
$browser = $this->rc->output->browser;
// send download headers
if ($plugin['download']) {
header("Content-Type: application/octet-stream");
if ($browser->ie)
header("Content-Type: application/force-download");
}
else if ($ctype_primary == 'text') {
header("Content-Type: text/$ctype_secondary");
}
else {
header("Content-Type: $mimetype");
header("Content-Transfer-Encoding: binary");
}
// display page, @TODO: support text/plain (and maybe some other text formats)
if ($mimetype == 'text/html' && empty($_GET['_download'])) {
$OUTPUT = new rcube_html_page();
// @TODO: use washtml on $body
$OUTPUT->write($plugin['body']);
}
else {
// don't kill the connection if download takes more than 30 sec.
@set_time_limit(0);
$filename = $attachment['name'];
$filename = preg_replace('[\r\n]', '', $filename);
if ($browser->ie && $browser->ver < 7)
$filename = rawurlencode(abbreviate_string($filename, 55));
else if ($browser->ie)
$filename = rawurlencode($filename);
else
$filename = addcslashes($filename, '"');
$disposition = !empty($_GET['_download']) ? 'attachment' : 'inline';
header("Content-Disposition: $disposition; filename=\"$filename\"");
echo $plugin['body'];
}
exit;
}
// if we arrive here, the requested part was not found
header('HTTP/1.1 404 Not Found');
exit;
}
/**
* Show "loading..." page in attachment iframe
*/
public function attachment_loading_page()
{
$url = str_replace('&_preload=1', '', $_SERVER['REQUEST_URI']);
$message = $this->rc->gettext('loadingdata');
header('Content-Type: text/html; charset=' . RCUBE_CHARSET);
print "<html>\n<head>\n"
. '<meta http-equiv="refresh" content="0; url='.Q($url).'">' . "\n"
. '<meta http-equiv="content-type" content="text/html; charset='.RCUBE_CHARSET.'">' . "\n"
. "</head>\n<body>\n$message\n</body>\n</html>";
exit;
}
/**
* Template object for attachment display frame
*/
public function attachment_frame($attrib = array())
{
$mimetype = strtolower($this->attachment['mimetype']);
list($ctype_primary, $ctype_secondary) = explode('/', $mimetype);
$attrib['src'] = './?' . str_replace('_frame=', ($ctype_primary == 'text' ? '_show=' : '_preload='), $_SERVER['QUERY_STRING']);
$this->rc->output->add_gui_object('attachmentframe', $attrib['id']);
return html::iframe($attrib);
}
/**
*
*/
public function attachment_header($attrib = array())
{
$rcmail = rcmail::get_instance();
$dl_link = strtolower($attrib['downloadlink']) == 'true';
$dl_url = $this->rc->url(array('_frame' => null, '_download' => 1) + $_GET);
$table = new html_table(array('cols' => $dl_link ? 3 : 2));
if (!empty($this->attachment['name'])) {
$table->add('title', Q($this->rc->gettext('filename')));
$table->add('header', Q($this->attachment['name']));
if ($dl_link) {
$table->add('download-link', html::a($dl_url, Q($this->rc->gettext('download'))));
}
}
if (!empty($this->attachment['mimetype'])) {
$table->add('title', Q($this->rc->gettext('type')));
$table->add('header', Q($this->attachment['mimetype']));
}
if (!empty($this->attachment['size'])) {
$table->add('title', Q($this->rc->gettext('filesize')));
$table->add('header', Q(show_bytes($this->attachment['size'])));
}
$this->rc->output->set_env('attachment_download_url', $dl_url);
return $table->show($attrib);
}
/********* iTip message detection *********/
/**
* Check mail message structure of there are .ics files attached
*/
public function mail_message_load($p)
{
$this->ical_message = $p['object'];
$itip_part = null;
// check all message parts for .ics files
foreach ((array)$this->ical_message->mime_parts as $part) {
if (self::part_is_vcalendar($part)) {
if ($part->ctype_parameters['method'])
$itip_part = $part->mime_id;
else
$this->ical_parts[] = $part->mime_id;
}
}
// priorize part with method parameter
if ($itip_part) {
$this->ical_parts = array($itip_part);
}
}
/**
* Getter for the parsed iCal objects attached to the current email message
*
* @return object libvcalendar parser instance with the parsed objects
*/
public function get_mail_ical_objects()
{
// create parser and load ical objects
if (!$this->mail_ical_parser) {
$this->mail_ical_parser = $this->get_ical();
foreach ($this->ical_parts as $mime_id) {
$part = $this->ical_message->mime_parts[$mime_id];
$charset = $part->ctype_parameters['charset'] ?: RCMAIL_CHARSET;
$this->mail_ical_parser->import($this->ical_message->get_part_body($mime_id, true), $charset);
// stop on the part that has an iTip method specified
if (count($this->mail_ical_parser->objects) && $this->mail_ical_parser->method) {
$this->mail_ical_parser->message_date = $this->ical_message->headers->date;
$this->mail_ical_parser->mime_id = $mime_id;
// store the message's sender address for comparisons
$this->mail_ical_parser->sender = preg_match(self::$email_regex, $this->ical_message->headers->from, $m) ? $m[1] : '';
if (!empty($this->mail_ical_parser->sender)) {
foreach ($this->mail_ical_parser->objects as $i => $object) {
$this->mail_ical_parser->objects[$i]['_sender'] = $this->mail_ical_parser->sender;
$this->mail_ical_parser->objects[$i]['_sender_utf'] = rcube_utils::idn_to_utf8($this->mail_ical_parser->sender);
}
}
break;
}
}
}
return $this->mail_ical_parser;
}
/**
* Read the given mime message from IMAP and parse ical data
*
* @param string Mailbox name
* @param string Message UID
* @param string Message part ID and object index (e.g. '1.2:0')
* @param string Object type filter (optional)
*
* @return array Hash array with the parsed iCal
*/
public function mail_get_itip_object($mbox, $uid, $mime_id, $type = null)
{
$charset = RCMAIL_CHARSET;
// establish imap connection
$imap = $this->rc->get_storage();
$imap->set_mailbox($mbox);
if ($uid && $mime_id) {
list($mime_id, $index) = explode(':', $mime_id);
$part = $imap->get_message_part($uid, $mime_id);
$headers = $imap->get_message_headers($uid);
$parser = $this->get_ical();
if ($part->ctype_parameters['charset']) {
$charset = $part->ctype_parameters['charset'];
}
if ($part) {
$objects = $parser->import($part, $charset);
}
}
// successfully parsed events/tasks?
if (!empty($objects) && ($object = $objects[$index]) && (!$type || $object['_type'] == $type)) {
if ($parser->method)
$object['_method'] = $parser->method;
// store the message's sender address for comparisons
$object['_sender'] = preg_match(self::$email_regex, $headers->from, $m) ? $m[1] : '';
$object['_sender_utf'] = rcube_utils::idn_to_utf8($object['_sender']);
return $object;
}
return null;
}
/**
* Checks if specified message part is a vcalendar data
*
* @param rcube_message_part Part object
* @return boolean True if part is of type vcard
*/
public static function part_is_vcalendar($part)
{
return (
in_array($part->mimetype, array('text/calendar', 'text/x-vcalendar', 'application/ics')) ||
// Apple sends files as application/x-any (!?)
($part->mimetype == 'application/x-any' && $part->filename && preg_match('/\.ics$/i', $part->filename))
);
}
/********* Attendee handling functions *********/
/**
* Handler for attendee group expansion requests
*/
public function expand_attendee_group()
{
$id = rcube_utils::get_input_value('id', rcube_utils::INPUT_POST);
$data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true);
$result = array('id' => $id, 'members' => array());
$maxnum = 500;
// iterate over all autocomplete address books (we don't know the source of the group)
foreach ((array)$this->rc->config->get('autocomplete_addressbooks', 'sql') as $abook_id) {
if (($abook = $this->rc->get_address_book($abook_id)) && $abook->groups) {
foreach ($abook->list_groups($data['name'], 1) as $group) {
// this is the matching group to expand
if (in_array($data['email'], (array)$group['email'])) {
$abook->set_pagesize($maxnum);
$abook->set_group($group['ID']);
// get all members
$res = $abook->list_records($this->rc->config->get('contactlist_fields'));
// handle errors (e.g. sizelimit, timelimit)
if ($abook->get_error()) {
$result['error'] = $this->rc->gettext('expandattendeegrouperror', 'libcalendaring');
$res = false;
}
// check for maximum number of members (we don't wanna bloat the UI too much)
else if ($res->count > $maxnum) {
$result['error'] = $this->rc->gettext('expandattendeegroupsizelimit', 'libcalendaring');
$res = false;
}
while ($res && ($member = $res->iterate())) {
$emails = (array)$abook->get_col_values('email', $member, true);
if (!empty($emails) && ($email = array_shift($emails))) {
$result['members'][] = array(
'email' => $email,
'name' => rcube_addressbook::compose_list_name($member),
);
}
}
break 2;
}
}
}
}
$this->rc->output->command('plugin.expand_attendee_callback', $result);
}
/********* Static utility functions *********/
/**
* Convert the internal structured data into a vcalendar rrule 2.0 string
*/
public static function to_rrule($recurrence)
{
if (is_string($recurrence))
return $recurrence;
$rrule = '';
foreach ((array)$recurrence as $k => $val) {
$k = strtoupper($k);
switch ($k) {
case 'UNTIL':
// convert to UTC according to RFC 5545
if (is_a($val, 'DateTime')) {
$until = clone $val;
$until->setTimezone(new DateTimeZone('UTC'));
$val = $until->format('Ymd\THis\Z');
}
break;
case 'RDATE':
case 'EXDATE':
foreach ((array)$val as $i => $ex)
$val[$i] = $ex->format('Ymd\THis');
$val = join(',', (array)$val);
break;
case 'EXCEPTIONS':
continue 2;
}
$rrule .= $k . '=' . $val . ';';
}
return rtrim($rrule, ';');
}
/**
* Convert from fullcalendar date format to PHP date() format string
*/
public static function to_php_date_format($from)
{
// "dd.MM.yyyy HH:mm:ss" => "d.m.Y H:i:s"
return strtr(strtr($from, array(
'yyyy' => 'Y',
'yy' => 'y',
'MMMM' => 'F',
'MMM' => 'M',
'MM' => 'm',
'M' => 'n',
'dddd' => 'l',
'ddd' => 'D',
'dd' => 'd',
'd' => 'j',
'HH' => '**',
'hh' => '%%',
'H' => 'G',
'h' => 'g',
'mm' => 'i',
'ss' => 's',
'TT' => 'A',
'tt' => 'a',
'T' => 'A',
't' => 'a',
'u' => 'c',
)), array(
'**' => 'H',
'%%' => 'h',
));
}
/**
* Convert from PHP date() format to fullcalendar format string
*/
public static function from_php_date_format($from)
{
// "d.m.Y H:i:s" => "dd.MM.yyyy HH:mm:ss"
return strtr($from, array(
'y' => 'yy',
'Y' => 'yyyy',
'M' => 'MMM',
'F' => 'MMMM',
'm' => 'MM',
'n' => 'M',
'j' => 'd',
'd' => 'dd',
'D' => 'ddd',
'l' => 'dddd',
'H' => 'HH',
'h' => 'hh',
'G' => 'H',
'g' => 'h',
'i' => 'mm',
's' => 'ss',
'A' => 'TT',
'a' => 'tt',
'c' => 'u',
));
}
}

File Metadata

Mime Type
text/x-diff
Expires
Mon, Sep 15, 1:05 PM (1 d, 17 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
287553
Default Alt Text
(91 KB)

Event Timeline