1<?php
2
3/**
4 * Kolab address book
5 *
6 * Sample plugin to add a new address book source with data from Kolab storage
7 * It provides also a possibilities to manage contact folders
8 * (create/rename/delete/acl) directly in Addressbook UI.
9 *
10 * @version @package_version@
11 * @author Thomas Bruederli <bruederli@kolabsys.com>
12 * @author Aleksander Machniak <machniak@kolabsys.com>
13 *
14 * Copyright (C) 2011-2015, Kolab Systems AG <contact@kolabsys.com>
15 *
16 * This program is free software: you can redistribute it and/or modify
17 * it under the terms of the GNU Affero General Public License as
18 * published by the Free Software Foundation, either version 3 of the
19 * License, or (at your option) any later version.
20 *
21 * This program is distributed in the hope that it will be useful,
22 * but WITHOUT ANY WARRANTY; without even the implied warranty of
23 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
24 * GNU Affero General Public License for more details.
25 *
26 * You should have received a copy of the GNU Affero General Public License
27 * along with this program. If not, see <http://www.gnu.org/licenses/>.
28 */
29
30class kolab_addressbook extends rcube_plugin
31{
32    public $task = '?(?!logout).*';
33
34    private $sources;
35    private $folders;
36    private $rc;
37    private $ui;
38
39    public $bonnie_api = false;
40
41    const GLOBAL_FIRST = 0;
42    const PERSONAL_FIRST = 1;
43    const GLOBAL_ONLY = 2;
44    const PERSONAL_ONLY = 3;
45
46    /**
47     * Startup method of a Roundcube plugin
48     */
49    public function init()
50    {
51        require_once(dirname(__FILE__) . '/lib/rcube_kolab_contacts.php');
52
53        $this->rc = rcube::get_instance();
54
55        // load required plugin
56        $this->require_plugin('libkolab');
57
58        // register hooks
59        $this->add_hook('addressbooks_list', array($this, 'address_sources'));
60        $this->add_hook('addressbook_get', array($this, 'get_address_book'));
61        $this->add_hook('config_get', array($this, 'config_get'));
62
63        if ($this->rc->task == 'addressbook') {
64            $this->add_texts('localization');
65            $this->add_hook('contact_form', array($this, 'contact_form'));
66            $this->add_hook('contact_photo', array($this, 'contact_photo'));
67            $this->add_hook('template_object_directorylist', array($this, 'directorylist_html'));
68
69            // Plugin actions
70            $this->register_action('plugin.book', array($this, 'book_actions'));
71            $this->register_action('plugin.book-save', array($this, 'book_save'));
72            $this->register_action('plugin.book-search', array($this, 'book_search'));
73            $this->register_action('plugin.book-subscribe', array($this, 'book_subscribe'));
74
75            $this->register_action('plugin.contact-changelog', array($this, 'contact_changelog'));
76            $this->register_action('plugin.contact-diff', array($this, 'contact_diff'));
77            $this->register_action('plugin.contact-restore', array($this, 'contact_restore'));
78
79            // get configuration for the Bonnie API
80            $this->bonnie_api = libkolab::get_bonnie_api();
81
82            // Load UI elements
83            if ($this->api->output->type == 'html') {
84                $this->load_config();
85                require_once($this->home . '/lib/kolab_addressbook_ui.php');
86                $this->ui = new kolab_addressbook_ui($this);
87
88                if ($this->bonnie_api) {
89                    $this->add_button(array(
90                        'command'    => 'contact-history-dialog',
91                        'class'      => 'history contact-history disabled',
92                        'classact'   => 'history contact-history active',
93                        'innerclass' => 'icon inner',
94                        'label'      => 'kolab_addressbook.showhistory',
95                        'type'       => 'link-menuitem'
96                    ), 'contactmenu');
97                }
98            }
99        }
100        else if ($this->rc->task == 'settings') {
101            $this->add_texts('localization');
102            $this->add_hook('preferences_list', array($this, 'prefs_list'));
103            $this->add_hook('preferences_save', array($this, 'prefs_save'));
104        }
105
106        $this->add_hook('folder_delete', array($this, 'prefs_folder_delete'));
107        $this->add_hook('folder_rename', array($this, 'prefs_folder_rename'));
108        $this->add_hook('folder_update', array($this, 'prefs_folder_update'));
109    }
110
111    /**
112     * Handler for the addressbooks_list hook.
113     *
114     * This will add all instances of available Kolab-based address books
115     * to the list of address sources of Roundcube.
116     * This will also hide some addressbooks according to kolab_addressbook_prio setting.
117     *
118     * @param array $p Hash array with hook parameters
119     *
120     * @return array Hash array with modified hook parameters
121     */
122    public function address_sources($p)
123    {
124        $abook_prio = $this->addressbook_prio();
125
126        // Disable all global address books
127        // Assumes that all non-kolab_addressbook sources are global
128        if ($abook_prio == self::PERSONAL_ONLY) {
129            $p['sources'] = array();
130        }
131
132        $sources = array();
133        foreach ($this->_list_sources() as $abook_id => $abook) {
134            // register this address source
135            $sources[$abook_id] = $this->abook_prop($abook_id, $abook);
136
137            // flag folders with 'i' right as writeable
138            if ($this->rc->action == 'add' && strpos($abook->rights, 'i') !== false) {
139                $sources[$abook_id]['readonly'] = false;
140            }
141        }
142
143        // Add personal address sources to the list
144        if ($abook_prio == self::PERSONAL_FIRST) {
145            // $p['sources'] = array_merge($sources, $p['sources']);
146            // Don't use array_merge(), because if you have folders name
147            // that resolve to numeric identifier it will break output array keys
148            foreach ($p['sources'] as $idx => $value)
149                $sources[$idx] = $value;
150            $p['sources'] = $sources;
151        }
152        else {
153            // $p['sources'] = array_merge($p['sources'], $sources);
154            foreach ($sources as $idx => $value)
155                $p['sources'][$idx] = $value;
156        }
157
158        return $p;
159    }
160
161    /**
162     * Helper method to build a hash array of address book properties
163     */
164    protected function abook_prop($id, $abook)
165    {
166        if ($abook->virtual) {
167            return array(
168                'id'       => $id,
169                'name'     => $abook->get_name(),
170                'listname' => $abook->get_foldername(),
171                'group'    => $abook instanceof kolab_storage_folder_user ? 'user' : $abook->get_namespace(),
172                'readonly' => true,
173                'rights'   => 'l',
174                'kolab'    => true,
175                'virtual'  => true,
176            );
177        }
178        else {
179            return array(
180                'id'       => $id,
181                'name'     => $abook->get_name(),
182                'listname' => $abook->get_foldername(),
183                'readonly' => $abook->readonly,
184                'rights'   => $abook->rights,
185                'groups'   => $abook->groups,
186                'undelete' => $abook->undelete && $this->rc->config->get('undo_timeout'),
187                'realname' => rcube_charset::convert($abook->get_realname(), 'UTF7-IMAP'), // IMAP folder name
188                'group'    => $abook->get_namespace(),
189                'subscribed' => $abook->is_subscribed(),
190                'carddavurl' => $abook->get_carddav_url(),
191                'removable'  => true,
192                'kolab'      => true,
193                'audittrail' => !empty($this->bonnie_api),
194            );
195        }
196    }
197
198    /**
199     *
200     */
201    public function directorylist_html($args)
202    {
203        $out = '';
204        $jsdata = array();
205        $sources = (array)$this->rc->get_address_sources();
206
207        // list all non-kolab sources first (also exclude hidden sources)
208        $filter = function($source){ return empty($source['kolab']) && empty($source['hidden']); };
209        foreach (array_filter($sources, $filter)  as $j => $source) {
210            $id = strval(strlen($source['id']) ? $source['id'] : $j);
211            $out .= $this->addressbook_list_item($id, $source, $jsdata) . '</li>';
212        }
213
214        // render a hierarchical list of kolab contact folders
215        kolab_storage::folder_hierarchy($this->folders, $tree);
216        if ($tree && !empty($tree->children)) {
217            $out .= $this->folder_tree_html($tree, $sources, $jsdata);
218        }
219
220        $this->rc->output->set_env('contactgroups', array_filter($jsdata, function($src){ return $src['type'] == 'group'; }));
221        $this->rc->output->set_env('address_sources', array_filter($jsdata, function($src){ return $src['type'] != 'group'; }));
222
223        $args['content'] = html::tag('ul', $args, $out, html::$common_attrib);
224        return $args;
225    }
226
227    /**
228     * Return html for a structured list <ul> for the folder tree
229     */
230    public function folder_tree_html($node, $data, &$jsdata)
231    {
232        $out = '';
233        foreach ($node->children as $folder) {
234            $id = $folder->id;
235            $source = $data[$id];
236            $is_collapsed = strpos($this->rc->config->get('collapsed_abooks',''), '&'.rawurlencode($id).'&') !== false;
237
238            if ($folder->virtual) {
239                $source = $this->abook_prop($folder->id, $folder);
240            }
241            else if (empty($source)) {
242                $this->sources[$id] = new rcube_kolab_contacts($folder->name);
243                $source = $this->abook_prop($id, $this->sources[$id]);
244            }
245
246            $content = $this->addressbook_list_item($id, $source, $jsdata);
247
248            if (!empty($folder->children)) {
249                $child_html = $this->folder_tree_html($folder, $data, $jsdata);
250
251                // copy group items...
252                if (preg_match('!<ul[^>]*>(.*)</ul>\n*$!Ums', $content, $m)) {
253                    $child_html = $m[1] . $child_html;
254                    $content = substr($content, 0, -strlen($m[0]) - 1);
255                }
256                // ... and re-create the subtree
257                if (!empty($child_html)) {
258                    $content .= html::tag('ul', array('class' => 'groups', 'style' => ($is_collapsed ? "display:none;" : null)), $child_html);
259                }
260            }
261
262            $out .= $content . '</li>';
263        }
264
265        return $out;
266    }
267
268    /**
269     *
270     */
271    protected function addressbook_list_item($id, $source, &$jsdata, $search_mode = false)
272    {
273        $current = rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC);
274
275        if (!$source['virtual']) {
276            $jsdata[$id] = $source;
277            $jsdata[$id]['name'] = html_entity_decode($source['name'], ENT_NOQUOTES, RCUBE_CHARSET);
278        }
279
280        // set class name(s)
281        $classes = array('addressbook');
282        if ($source['group'])
283            $classes[] = $source['group'];
284        if ($current === $id)
285            $classes[] = 'selected';
286        if ($source['readonly'])
287            $classes[] = 'readonly';
288        if ($source['virtual'])
289            $classes[] = 'virtual';
290        if ($source['class_name'])
291            $classes[] = $source['class_name'];
292
293        $name = !empty($source['listname']) ? $source['listname'] : (!empty($source['name']) ? $source['name'] : $id);
294        $label_id = 'kabt:' . $id;
295        $inner = ($source['virtual'] ?
296            html::a(array('tabindex' => '0'), $name) :
297            html::a(array(
298                    'href' => $this->rc->url(array('_source' => $id)),
299                    'rel' => $source['id'],
300                    'id' => $label_id,
301                    'onclick' => "return " . rcmail_output::JS_OBJECT_NAME.".command('list','" . rcube::JQ($id) . "',this)",
302                ), $name)
303        );
304
305        if (isset($source['subscribed'])) {
306            $inner .= html::span(array(
307                'class' => 'subscribed',
308                'title' => $this->gettext('foldersubscribe'),
309                'role' => 'checkbox',
310                'aria-checked' => $source['subscribed'] ? 'true' : 'false',
311            ), '');
312        }
313
314        // don't wrap in <li> but add a checkbox for search results listing
315        if ($search_mode) {
316            $jsdata[$id]['group'] = join(' ', $classes);
317
318            if (!$source['virtual']) {
319                $inner .= html::tag('input', array(
320                    'type' => 'checkbox',
321                    'name' => '_source[]',
322                    'value' => $id,
323                    'checked' => false,
324                    'aria-labelledby' => $label_id,
325                ));
326            }
327            return html::div(null, $inner);
328        }
329
330        $out .= html::tag('li', array(
331                'id' => 'rcmli' . rcube_utils::html_identifier($id, true),
332                'class' => join(' ', $classes),
333                'noclose' => true,
334            ),
335            html::div($source['subscribed'] ? 'subscribed' : null, $inner)
336        );
337
338        $groupdata = array('out' => '', 'jsdata' => $jsdata, 'source' => $id);
339        if ($source['groups'] && function_exists('rcmail_contact_groups')) {
340            $groupdata = rcmail_contact_groups($groupdata);
341        }
342
343        $jsdata = $groupdata['jsdata'];
344        $out .= $groupdata['out'];
345
346        return $out;
347    }
348
349    /**
350     * Sets autocomplete_addressbooks option according to
351     * kolab_addressbook_prio setting extending list of address sources
352     * to be used for autocompletion.
353     */
354    public function config_get($args)
355    {
356        if ($args['name'] != 'autocomplete_addressbooks' || $this->recurrent) {
357            return $args;
358        }
359
360        $abook_prio = $this->addressbook_prio();
361
362        // Get the original setting, use temp flag to prevent from an infinite recursion
363        $this->recurrent = true;
364        $sources = $this->rc->config->get('autocomplete_addressbooks');
365        $this->recurrent = false;
366
367        // Disable all global address books
368        // Assumes that all non-kolab_addressbook sources are global
369        if ($abook_prio == self::PERSONAL_ONLY) {
370            $sources = array();
371        }
372
373        if (!is_array($sources)) {
374            $sources = array();
375        }
376
377        $kolab_sources = array();
378        foreach (array_keys($this->_list_sources()) as $abook_id) {
379            if (!in_array($abook_id, $sources))
380                $kolab_sources[] = $abook_id;
381        }
382
383        // Add personal address sources to the list
384        if (!empty($kolab_sources)) {
385            if ($abook_prio == self::PERSONAL_FIRST) {
386                $sources = array_merge($kolab_sources, $sources);
387            }
388            else {
389                $sources = array_merge($sources, $kolab_sources);
390            }
391        }
392
393        $args['result'] = $sources;
394
395        return $args;
396    }
397
398
399    /**
400     * Getter for the rcube_addressbook instance
401     *
402     * @param array $p Hash array with hook parameters
403     *
404     * @return array Hash array with modified hook parameters
405     */
406    public function get_address_book($p)
407    {
408        if ($p['id']) {
409            $id     = kolab_storage::id_decode($p['id']);
410            $folder = kolab_storage::get_folder($id);
411
412            // try with unencoded (old-style) identifier
413            if ((!$folder || $folder->type != 'contact') && $id != $p['id']) {
414                $folder = kolab_storage::get_folder($p['id']);
415            }
416
417            if ($folder && $folder->type == 'contact') {
418                $p['instance'] = new rcube_kolab_contacts($folder->name);
419
420                // flag source as writeable if 'i' right is given
421                if ($p['writeable'] && $this->rc->action == 'save' && strpos($p['instance']->rights, 'i') !== false) {
422                    $p['instance']->readonly = false;
423                }
424                else if ($this->rc->action == 'delete' && strpos($p['instance']->rights, 't') !== false) {
425                    $p['instance']->readonly = false;
426                }
427            }
428        }
429
430        return $p;
431    }
432
433
434    private function _list_sources()
435    {
436        // already read sources
437        if (isset($this->sources))
438            return $this->sources;
439
440        kolab_storage::$encode_ids = true;
441        $this->sources = array();
442        $this->folders = array();
443
444        $abook_prio = $this->addressbook_prio();
445
446        // Personal address source(s) disabled?
447        if ($abook_prio == self::GLOBAL_ONLY) {
448            return $this->sources;
449        }
450
451        // get all folders that have "contact" type
452        $folders = kolab_storage::sort_folders(kolab_storage::get_folders('contact'));
453
454        if (PEAR::isError($folders)) {
455            rcube::raise_error(array(
456              'code' => 600, 'type' => 'php',
457              'file' => __FILE__, 'line' => __LINE__,
458              'message' => "Failed to list contact folders from Kolab server:" . $folders->getMessage()),
459            true, false);
460        }
461        else {
462            // we need at least one folder to prevent from errors in Roundcube core
463            // when there's also no sql nor ldap addressbook (Bug #2086)
464            if (empty($folders)) {
465                if ($folder = kolab_storage::create_default_folder('contact')) {
466                    $folders = array(new kolab_storage_folder($folder, 'contact'));
467                }
468            }
469
470            // convert to UTF8 and sort
471            foreach ($folders as $folder) {
472                // create instance of rcube_contacts
473                $abook_id = $folder->id;
474                $abook = new rcube_kolab_contacts($folder->name);
475                $this->sources[$abook_id] = $abook;
476                $this->folders[$abook_id] = $folder;
477            }
478        }
479
480        return $this->sources;
481    }
482
483
484    /**
485     * Plugin hook called before rendering the contact form or detail view
486     *
487     * @param array $p Hash array with hook parameters
488     *
489     * @return array Hash array with modified hook parameters
490     */
491    public function contact_form($p)
492    {
493        // none of our business
494        if (!is_object($GLOBALS['CONTACTS']) || !is_a($GLOBALS['CONTACTS'], 'rcube_kolab_contacts'))
495            return $p;
496
497        // extend the list of contact fields to be displayed in the 'personal' section
498        if (is_array($p['form']['personal'])) {
499            $p['form']['personal']['content']['profession']    = array('size' => 40);
500            $p['form']['personal']['content']['children']      = array('size' => 40);
501            $p['form']['personal']['content']['freebusyurl']   = array('size' => 40);
502            $p['form']['personal']['content']['pgppublickey']  = array('size' => 70);
503            $p['form']['personal']['content']['pkcs7publickey'] = array('size' => 70);
504
505            // re-order fields according to the coltypes list
506            $p['form']['contact']['content']  = $this->_sort_form_fields($p['form']['contact']['content'], $GLOBALS['CONTACTS']);
507            $p['form']['personal']['content'] = $this->_sort_form_fields($p['form']['personal']['content'], $GLOBALS['CONTACTS']);
508
509            /* define a separate section 'settings'
510            $p['form']['settings'] = array(
511                'name'    => $this->gettext('settings'),
512                'content' => array(
513                    'freebusyurl'  => array('size' => 40, 'visible' => true),
514                    'pgppublickey' => array('size' => 70, 'visible' => true),
515                    'pkcs7publickey' => array('size' => 70, 'visible' => false),
516                )
517            );
518            */
519        }
520
521        if ($this->bonnie_api && $this->rc->action == 'show' && empty($p['record']['rev'])) {
522            $this->rc->output->set_env('kolab_audit_trail', true);
523        }
524
525        return $p;
526    }
527
528    /**
529     * Plugin hook for the contact photo image
530     */
531    public function contact_photo($p)
532    {
533        // add photo data from old revision inline as data url
534        if (!empty($p['record']['rev']) && !empty($p['data'])) {
535            $p['url'] = 'data:image/gif;base64,' . base64_encode($p['data']);
536        }
537
538        return $p;
539    }
540
541    /**
542     * Handler for contact audit trail changelog requests
543     */
544    public function contact_changelog()
545    {
546        if (empty($this->bonnie_api)) {
547            return false;
548        }
549
550        $contact = rcube_utils::get_input_value('cid', rcube_utils::INPUT_POST, true);
551        $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_POST);
552
553        list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($contact, $source);
554
555        $result = $uid && $mailbox ? $this->bonnie_api->changelog('contact', $uid, $mailbox, $msguid) : null;
556        if (is_array($result) && $result['uid'] == $uid) {
557            if (is_array($result['changes'])) {
558                $rcmail = $this->rc;
559                $dtformat = $this->rc->config->get('date_format') . ' ' . $this->rc->config->get('time_format');
560                array_walk($result['changes'], function(&$change) use ($rcmail, $dtformat) {
561                  if ($change['date']) {
562                      $dt = rcube_utils::anytodatetime($change['date']);
563                      if ($dt instanceof DateTime) {
564                          $change['date'] = $rcmail->format_date($dt, $dtformat);
565                      }
566                  }
567                });
568            }
569            $this->rc->output->command('contact_render_changelog', $result['changes']);
570        }
571        else {
572            $this->rc->output->command('contact_render_changelog', false);
573        }
574
575        $this->rc->output->send();
576    }
577
578    /**
579     * Handler for audit trail diff view requests
580     */
581    public function contact_diff()
582    {
583        if (empty($this->bonnie_api)) {
584            return false;
585        }
586
587        $contact = rcube_utils::get_input_value('cid', rcube_utils::INPUT_POST, true);
588        $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_POST);
589        $rev1 = rcube_utils::get_input_value('rev1', rcube_utils::INPUT_POST);
590        $rev2 = rcube_utils::get_input_value('rev2', rcube_utils::INPUT_POST);
591
592        list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($contact, $source);
593
594        $result = $this->bonnie_api->diff('contact', $uid, $rev1, $rev2, $mailbox, $msguid);
595        if (is_array($result) && $result['uid'] == $uid) {
596            $result['rev1'] = $rev1;
597            $result['rev2'] = $rev2;
598            $result['cid'] = $contact;
599
600            // convert some properties, similar to rcube_kolab_contacts::_to_rcube_contact()
601            $keymap = array(
602                'lastmodified-date' => 'changed',
603                'additional' => 'middlename',
604                'fn' => 'name',
605                'tel' => 'phone',
606                'url' => 'website',
607                'bday' => 'birthday',
608                'note' => 'notes',
609                'role' => 'profession',
610                'title' => 'jobtitle',
611            );
612
613            $propmap = array('email' => 'address', 'website' => 'url', 'phone' => 'number');
614            $date_format = $this->rc->config->get('date_format', 'Y-m-d');
615
616            // map kolab object properties to keys and values the client expects
617            array_walk($result['changes'], function(&$change, $i) use ($keymap, $propmap, $date_format) {
618                if (array_key_exists($change['property'], $keymap)) {
619                    $change['property'] = $keymap[$change['property']];
620                }
621
622                // format date-time values
623                if ($change['property'] == 'created' || $change['property'] == 'changed') {
624                    if ($old_ = rcube_utils::anytodatetime($change['old'])) {
625                        $change['old_'] = $this->rc->format_date($old_);
626                    }
627                    if ($new_ = rcube_utils::anytodatetime($change['new'])) {
628                        $change['new_'] = $this->rc->format_date($new_);
629                    }
630                }
631                // format dates
632                else if ($change['property'] == 'birthday' || $change['property'] == 'anniversary') {
633                    if ($old_ = rcube_utils::anytodatetime($change['old'])) {
634                        $change['old_'] = $this->rc->format_date($old_, $date_format);
635                    }
636                    if ($new_ = rcube_utils::anytodatetime($change['new'])) {
637                        $change['new_'] = $this->rc->format_date($new_, $date_format);
638                    }
639                }
640                // convert email, website, phone values
641                else if (array_key_exists($change['property'], $propmap)) {
642                    $propname = $propmap[$change['property']];
643                    foreach (array('old','new') as $k) {
644                        $k_ = $k . '_';
645                        if (!empty($change[$k])) {
646                            $change[$k_] = html::quote($change[$k][$propname] ?: '--');
647                            if ($change[$k]['type']) {
648                                $change[$k_] .= '&nbsp;' . html::span('subtype', rcmail_get_type_label($change[$k]['type']));
649                            }
650                            $change['ishtml'] = true;
651                        }
652                    }
653                }
654                // serialize address structs
655                if ($change['property'] == 'address') {
656                    foreach (array('old','new') as $k) {
657                        $k_ = $k . '_';
658                        $change[$k]['zipcode'] = $change[$k]['code'];
659                        $template = $this->rc->config->get('address_template', '{'.join('} {', array_keys($change[$k])).'}');
660                        $composite = array();
661                        foreach ($change[$k] as $p => $val) {
662                            if (strlen($val))
663                                $composite['{'.$p.'}'] = $val;
664                        }
665                        $change[$k_] = preg_replace('/\{\w+\}/', '', strtr($template, $composite));
666                        if ($change[$k]['type']) {
667                            $change[$k_] .= html::div('subtype', rcmail_get_type_label($change[$k]['type']));
668                        }
669                        $change['ishtml'] = true;
670                    }
671
672                    $change['diff_'] = libkolab::html_diff($change['old_'], $change['new_'], true);
673                }
674                // localize gender values
675                else if ($change['property'] == 'gender') {
676                    if ($change['old']) $change['old_'] = $this->rc->gettext($change['old']);
677                    if ($change['new']) $change['new_'] = $this->rc->gettext($change['new']);
678                }
679                // translate 'key' entries in individual properties
680                else if ($change['property'] == 'key') {
681                    $p = $change['old'] ?: $change['new'];
682                    $t = $p['type'];
683                    $change['property'] = $t . 'publickey';
684                    $change['old'] = $change['old'] ? $change['old']['key'] : '';
685                    $change['new'] = $change['new'] ? $change['new']['key'] : '';
686                }
687                // compute a nice diff of notes
688                else if ($change['property'] == 'notes') {
689                    $change['diff_'] = libkolab::html_diff($change['old'], $change['new'], false);
690                }
691            });
692
693            $this->rc->output->command('contact_show_diff', $result);
694        }
695        else {
696            $this->rc->output->command('display_message', $this->gettext('objectdiffnotavailable'), 'error');
697        }
698
699        $this->rc->output->send();
700    }
701
702    /**
703     * Handler for audit trail revision restore requests
704     */
705    public function contact_restore()
706    {
707        if (empty($this->bonnie_api)) {
708            return false;
709        }
710
711        $success = false;
712        $contact = rcube_utils::get_input_value('cid', rcube_utils::INPUT_POST, true);
713        $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_POST);
714        $rev = rcube_utils::get_input_value('rev', rcube_utils::INPUT_POST);
715
716        list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($contact, $source, $folder);
717
718        if ($folder && ($raw_msg = $this->bonnie_api->rawdata('contact', $uid, $rev, $mailbox))) {
719            $imap = $this->rc->get_storage();
720
721            // insert $raw_msg as new message
722            if ($imap->save_message($folder->name, $raw_msg, null, false)) {
723                $success = true;
724
725                // delete old revision from imap and cache
726                $imap->delete_message($msguid, $folder->name);
727                $folder->cache->set($msguid, false);
728                $this->cache = array();
729            }
730        }
731
732        if ($success) {
733            $this->rc->output->command('display_message', $this->gettext(array('name' => 'objectrestoresuccess', 'vars' => array('rev' => $rev))), 'confirmation');
734            $this->rc->output->command('close_contact_history_dialog', $contact);
735        }
736        else {
737            $this->rc->output->command('display_message', $this->gettext('objectrestoreerror'), 'error');
738        }
739
740        $this->rc->output->send();
741    }
742
743    /**
744     * Get a previous revision of the given contact record from the Bonnie API
745     */
746    public function get_revision($cid, $source, $rev)
747    {
748        if (empty($this->bonnie_api)) {
749            return false;
750        }
751
752        list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($cid, $source);
753
754        // call Bonnie API
755        $result = $this->bonnie_api->get('contact', $uid, $rev, $mailbox, $msguid);
756        if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) {
757            $format = kolab_format::factory('contact');
758            $format->load($result['xml']);
759            $rec = $format->to_array();
760
761            if ($format->is_valid()) {
762                $rec['rev'] = $result['rev'];
763                return $rec;
764            }
765        }
766
767        return false;
768    }
769
770
771    /**
772     * Helper method to resolved the given contact identifier into uid and mailbox
773     *
774     * @return array (uid,mailbox,msguid) tuple
775     */
776    private function _resolve_contact_identity($id, $abook, &$folder = null)
777    {
778        $mailbox = $msguid = null;
779
780        $source = $this->get_address_book(array('id' => $abook));
781        if ($source['instance']) {
782            $uid = $source['instance']->id2uid($id);
783            $list = kolab_storage::id_decode($abook);
784        }
785        else {
786            return array(null, $mailbox, $msguid);
787        }
788
789        // get resolve message UID and mailbox identifier
790        if ($folder = kolab_storage::get_folder($list)) {
791            $mailbox = $folder->get_mailbox_id();
792            $msguid = $folder->cache->uid2msguid($uid);
793        }
794
795        return array($uid, $mailbox, $msguid);
796    }
797
798    /**
799     *
800     */
801    private function _sort_form_fields($contents, $source)
802    {
803      $block = array();
804
805      foreach (array_keys($source->coltypes) as $col) {
806          if (isset($contents[$col]))
807              $block[$col] = $contents[$col];
808      }
809
810      return $block;
811    }
812
813
814    /**
815     * Handler for user preferences form (preferences_list hook)
816     *
817     * @param array $args Hash array with hook parameters
818     *
819     * @return array Hash array with modified hook parameters
820     */
821    public function prefs_list($args)
822    {
823        if ($args['section'] != 'addressbook') {
824            return $args;
825        }
826
827        $ldap_public = $this->rc->config->get('ldap_public');
828
829        // Hide option if there's no global addressbook
830        if (empty($ldap_public)) {
831            return $args;
832        }
833
834        // Check that configuration is not disabled
835        $dont_override = (array) $this->rc->config->get('dont_override', array());
836        $prio          = $this->addressbook_prio();
837
838        if (!in_array('kolab_addressbook_prio', $dont_override)) {
839            // Load localization
840            $this->add_texts('localization');
841
842            $field_id = '_kolab_addressbook_prio';
843            $select   = new html_select(array('name' => $field_id, 'id' => $field_id));
844
845            $select->add($this->gettext('globalfirst'), self::GLOBAL_FIRST);
846            $select->add($this->gettext('personalfirst'), self::PERSONAL_FIRST);
847            $select->add($this->gettext('globalonly'), self::GLOBAL_ONLY);
848            $select->add($this->gettext('personalonly'), self::PERSONAL_ONLY);
849
850            $args['blocks']['main']['options']['kolab_addressbook_prio'] = array(
851                'title' => html::label($field_id, rcube::Q($this->gettext('addressbookprio'))),
852                'content' => $select->show($prio),
853            );
854        }
855
856        return $args;
857    }
858
859    /**
860     * Handler for user preferences save (preferences_save hook)
861     *
862     * @param array $args Hash array with hook parameters
863     *
864     * @return array Hash array with modified hook parameters
865     */
866    public function prefs_save($args)
867    {
868        if ($args['section'] != 'addressbook') {
869            return $args;
870        }
871
872        // Check that configuration is not disabled
873        $dont_override = (array) $this->rc->config->get('dont_override', array());
874        $key           = 'kolab_addressbook_prio';
875
876        if (!in_array('kolab_addressbook_prio', $dont_override) || !isset($_POST['_'.$key])) {
877            $args['prefs'][$key] = (int) rcube_utils::get_input_value('_'.$key, rcube_utils::INPUT_POST);
878        }
879
880        return $args;
881    }
882
883
884    /**
885     * Handler for plugin actions
886     */
887    public function book_actions()
888    {
889        $action = trim(rcube_utils::get_input_value('_act', rcube_utils::INPUT_GPC));
890
891        if ($action == 'create') {
892            $this->ui->book_edit();
893        }
894        else if ($action == 'edit') {
895            $this->ui->book_edit();
896        }
897        else if ($action == 'delete') {
898            $this->book_delete();
899        }
900    }
901
902
903    /**
904     * Handler for address book create/edit form submit
905     */
906    public function book_save()
907    {
908        $prop = array(
909            'name'    => trim(rcube_utils::get_input_value('_name', rcube_utils::INPUT_POST)),
910            'oldname' => trim(rcube_utils::get_input_value('_oldname', rcube_utils::INPUT_POST, true)), // UTF7-IMAP
911            'parent'  => trim(rcube_utils::get_input_value('_parent', rcube_utils::INPUT_POST, true)), // UTF7-IMAP
912            'type'    => 'contact',
913            'subscribed' => true,
914        );
915
916        $result = $error = false;
917        $type = strlen($prop['oldname']) ? 'update' : 'create';
918        $prop = $this->rc->plugins->exec_hook('addressbook_'.$type, $prop);
919
920        if (!$prop['abort']) {
921            if ($newfolder = kolab_storage::folder_update($prop)) {
922                $folder = $newfolder;
923                $result = true;
924            }
925            else {
926                $error = kolab_storage::$last_error;
927            }
928        }
929        else {
930            $result = $prop['result'];
931            $folder = $prop['name'];
932        }
933
934        if ($result) {
935            $kolab_folder = kolab_storage::get_folder($folder);
936
937            // get folder/addressbook properties
938            $abook = new rcube_kolab_contacts($folder);
939            $props = $this->abook_prop(kolab_storage::folder_id($folder, true), $abook);
940            $props['parent'] = kolab_storage::folder_id($kolab_folder->get_parent(), true);
941
942            $this->rc->output->show_message('kolab_addressbook.book'.$type.'d', 'confirmation');
943            $this->rc->output->command('book_update', $props, kolab_storage::folder_id($prop['oldname'], true));
944        }
945        else {
946            if (!$error) {
947                $error = $plugin['message'] ? $plugin['message'] : 'kolab_addressbook.book'.$type.'error';
948            }
949
950            $this->rc->output->show_message($error, 'error');
951        }
952
953        $this->rc->output->send('iframe');
954    }
955
956    /**
957     *
958     */
959    public function book_search()
960    {
961        $results = array();
962        $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC);
963        $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC);
964
965        kolab_storage::$encode_ids = true;
966        $search_more_results = false;
967        $this->sources = array();
968        $this->folders = array();
969
970        // find unsubscribed IMAP folders that have "event" type
971        if ($source == 'folders') {
972            foreach ((array)kolab_storage::search_folders('contact', $query, array('other')) as $folder) {
973                $this->folders[$folder->id] = $folder;
974                $this->sources[$folder->id] = new rcube_kolab_contacts($folder->name);
975            }
976        }
977        // search other user's namespace via LDAP
978        else if ($source == 'users') {
979            $limit = $this->rc->config->get('autocomplete_max', 15) * 2;  // we have slightly more space, so display twice the number
980            foreach (kolab_storage::search_users($query, 0, array(), $limit * 10) as $user) {
981                $folders = array();
982                // search for contact folders shared by this user
983                foreach (kolab_storage::list_user_folders($user, 'contact', false) as $foldername) {
984                    $folders[] = new kolab_storage_folder($foldername, 'contact');
985                }
986
987                if (count($folders)) {
988                    $userfolder = new kolab_storage_folder_user($user['kolabtargetfolder'], '', $user);
989                    $this->folders[$userfolder->id] = $userfolder;
990                    $this->sources[$userfolder->id] = $userfolder;
991
992                    foreach ($folders as $folder) {
993                        $this->folders[$folder->id] = $folder;
994                        $this->sources[$folder->id] = new rcube_kolab_contacts($folder->name);;
995                        $count++;
996                    }
997                }
998
999                if ($count >= $limit) {
1000                    $search_more_results = true;
1001                    break;
1002                }
1003            }
1004        }
1005
1006        $delim = $this->rc->get_storage()->get_hierarchy_delimiter();
1007
1008        // build results list
1009        foreach ($this->sources as $id => $source) {
1010            $folder = $this->folders[$id];
1011            $imap_path = explode($delim, $folder->name);
1012
1013            // find parent
1014            do {
1015              array_pop($imap_path);
1016              $parent_id = kolab_storage::folder_id(join($delim, $imap_path));
1017            }
1018            while (count($imap_path) > 1 && !$this->folders[$parent_id]);
1019
1020            // restore "real" parent ID
1021            if ($parent_id && !$this->folders[$parent_id]) {
1022                $parent_id = kolab_storage::folder_id($folder->get_parent());
1023            }
1024
1025            $prop = $this->abook_prop($id, $source);
1026            $prop['parent'] = $parent_id;
1027
1028            $html = $this->addressbook_list_item($id, $prop, $jsdata, true);
1029            unset($prop['group']);
1030            $prop += (array)$jsdata[$id];
1031            $prop['html'] = $html;
1032
1033            $results[] = $prop;
1034        }
1035
1036        // report more results available
1037        if ($search_more_results) {
1038            $this->rc->output->show_message('autocompletemore', 'notice');
1039        }
1040
1041        $this->rc->output->command('multi_thread_http_response', $results, rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC));
1042    }
1043
1044    /**
1045     *
1046     */
1047    public function book_subscribe()
1048    {
1049        $success = false;
1050        $id = rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC);
1051
1052        if ($id && ($folder = kolab_storage::get_folder(kolab_storage::id_decode($id)))) {
1053            if (isset($_POST['_permanent']))
1054                $success |= $folder->subscribe(intval($_POST['_permanent']));
1055            if (isset($_POST['_active']))
1056                $success |= $folder->activate(intval($_POST['_active']));
1057
1058            // list groups for this address book
1059            if (!empty($_POST['_groups'])) {
1060                $abook = new rcube_kolab_contacts($folder->name);
1061                foreach ((array)$abook->list_groups() as $prop) {
1062                    $prop['source'] = $id;
1063                    $prop['id'] = $prop['ID'];
1064                    unset($prop['ID']);
1065                    $this->rc->output->command('insert_contact_group', $prop);
1066                }
1067            }
1068        }
1069
1070        if ($success) {
1071            $this->rc->output->show_message('successfullysaved', 'confirmation');
1072        }
1073        else {
1074            $this->rc->output->show_message($this->gettext('errorsaving'), 'error');
1075        }
1076
1077        $this->rc->output->send();
1078    }
1079
1080
1081    /**
1082     * Handler for address book delete action (AJAX)
1083     */
1084    private function book_delete()
1085    {
1086        $folder = trim(rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC, true, 'UTF7-IMAP'));
1087
1088        if (kolab_storage::folder_delete($folder)) {
1089            $storage = $this->rc->get_storage();
1090            $delimiter = $storage->get_hierarchy_delimiter();
1091
1092            $this->rc->output->show_message('kolab_addressbook.bookdeleted', 'confirmation');
1093            $this->rc->output->set_env('pagecount', 0);
1094            $this->rc->output->command('set_rowcount', rcmail_get_rowcount_text(new rcube_result_set()));
1095            $this->rc->output->command('set_env', 'delimiter', $delimiter);
1096            $this->rc->output->command('list_contacts_clear');
1097            $this->rc->output->command('book_delete_done', kolab_storage::folder_id($folder, true));
1098        }
1099        else {
1100            $this->rc->output->show_message('kolab_addressbook.bookdeleteerror', 'error');
1101        }
1102
1103        $this->rc->output->send();
1104    }
1105
1106    /**
1107     * Returns value of kolab_addressbook_prio setting
1108     */
1109    private function addressbook_prio()
1110    {
1111        // Load configuration
1112        if (!$this->config_loaded) {
1113            $this->load_config();
1114            $this->config_loaded = true;
1115        }
1116
1117        $abook_prio = (int) $this->rc->config->get('kolab_addressbook_prio');
1118
1119        // Make sure any global addressbooks are defined
1120        if ($abook_prio == 0 || $abook_prio == 2) {
1121            $ldap_public = $this->rc->config->get('ldap_public');
1122
1123            if (empty($ldap_public)) {
1124                $abook_prio = 1;
1125            }
1126        }
1127
1128        return $abook_prio;
1129    }
1130
1131    /**
1132     * Hook for (contact) folder deletion
1133     */
1134    function prefs_folder_delete($args)
1135    {
1136        // ignore...
1137        if ($args['abort'] && !$args['result']) {
1138            return $args;
1139        }
1140
1141        $this->_contact_folder_rename($args['name'], false);
1142    }
1143
1144    /**
1145     * Hook for (contact) folder renaming
1146     */
1147    function prefs_folder_rename($args)
1148    {
1149        // ignore...
1150        if ($args['abort'] && !$args['result']) {
1151            return $args;
1152        }
1153
1154        $this->_contact_folder_rename($args['oldname'], $args['newname']);
1155    }
1156
1157    /**
1158     * Hook for (contact) folder updates. Forward to folder_rename handler if name was changed
1159     */
1160    function prefs_folder_update($args)
1161    {
1162        // ignore...
1163        if ($args['abort'] && !$args['result']) {
1164            return $args;
1165        }
1166
1167        if ($args['record']['name'] != $args['record']['oldname']) {
1168            $this->_contact_folder_rename($args['record']['oldname'], $args['record']['name']);
1169        }
1170    }
1171
1172    /**
1173     * Apply folder renaming or deletion to the registered birthday calendar address books
1174     */
1175    private function _contact_folder_rename($oldname, $newname = false)
1176    {
1177        $update = false;
1178        $delimiter = $this->rc->get_storage()->get_hierarchy_delimiter();
1179        $bday_addressbooks = (array)$this->rc->config->get('calendar_birthday_adressbooks', array());
1180
1181        foreach ($bday_addressbooks as $i => $id) {
1182            $folder_name = kolab_storage::id_decode($id);
1183            if ($oldname === $folder_name || strpos($folder_name, $oldname.$delimiter) === 0) {
1184                if ($newname) {  // rename
1185                    $new_folder = $newname . substr($folder_name, strlen($oldname));
1186                    $bday_addressbooks[$i] = kolab_storage::id_encode($new_folder);
1187                }
1188                else {  // delete
1189                    unset($bday_addressbooks[$i]);
1190                }
1191                $update = true;
1192            }
1193        }
1194
1195        if ($update) {
1196            $this->rc->user->save_prefs(array('calendar_birthday_adressbooks' => $bday_addressbooks));
1197        }
1198    }
1199
1200}
1201