1<?php
2/**
3 * The Kronolith_Driver_Resource class implements the Kronolith_Driver API for
4 * storing resource calendars in a SQL backend.
5 *
6 * Copyright 1999-2017 Horde LLC (http://www.horde.org/)
7 *
8 * See the enclosed file COPYING for license information (GPL). If you
9 * did not receive this file, see http://www.horde.org/licenses/gpl.
10 *
11 * @author  Luc Saillard <luc.saillard@fr.alcove.com>
12 * @author  Chuck Hagenbuch <chuck@horde.org>
13 * @author  Jan Schneider <jan@horde.org>
14 * @author  Michael J Rubinsky <mrubinsk@horde.org>
15 * @package Kronolith
16 */
17class Kronolith_Driver_Resource_Sql extends Kronolith_Driver
18{
19    /**
20     * The main event storage driver.
21     *
22     * @var Kronolith_Driver
23     */
24    protected $_driver;
25
26    /**
27     * Column information as Horde_Db_Adapter_Base_Column objects.
28     *
29     * @var array
30     */
31    protected $_columns = array();
32
33    /**
34     * The class name of the event object to instantiate.
35     *
36     * @var string
37     */
38    protected $_eventClass = 'Kronolith_Event_Resource_Sql';
39
40    /**
41     * Attempts to open a connection to the SQL server.
42     *
43     * @throws Kronolith_Exception
44     */
45    public function initialize()
46    {
47        if (empty($this->_params['db'])) {
48            throw new InvalidArgumentException('Missing required Horde_Db_Adapter instance');
49        }
50        try {
51            $this->_db = $this->_params['db'];
52        } catch (Horde_Exception $e) {
53            throw new Kronolith_Exception($e);
54        }
55
56        $this->_params = array_merge(array(
57            'table' => 'kronolith_resources'
58        ), $this->_params);
59
60        $this->_driver = Kronolith::getDriver();
61        $this->_columns = $this->_db->columns($this->_params['table']);
62    }
63
64    /**
65     * Selects a calendar as the currently opened calendar.
66     *
67     * @param string $calendar  A calendar identifier.
68     */
69    public function open($calendar)
70    {
71        $this->calendar = $calendar;
72        $this->_driver->open($calendar);
73    }
74
75    /**
76     * Lists all events in the time range, optionally restricting results to
77     * only events with alarms.
78     *
79     * @param Horde_Date $startDate  The start of range date.
80     * @param Horde_Date $endDate    The end of date range.
81     * @param array $options         Additional options:
82     *   - show_recurrence: (boolean) Return every instance of a recurring
83     *                       event?
84     *                      DEFAULT: false (Only return recurring events once
85     *                      inside $startDate - $endDate range)
86     *   - has_alarm:       (boolean) Only return events with alarms.
87     *                      DEFAULT: false (Return all events)
88     *   - json:            (boolean) Store the results of the event's toJson()
89     *                      method?
90     *                      DEFAULT: false
91     *   - cover_dates:     (boolean) Add the events to all days that they
92     *                      cover?
93     *                      DEFAULT: true
94     *   - hide_exceptions: (boolean) Hide events that represent exceptions to
95     *                      a recurring event.
96     *                      DEFAULT: false (Do not hide exception events)
97     *   - fetch_tags:      (boolean) Fetch tags for all events.
98     *                      DEFAULT: false (Do not fetch event tags)
99     *
100     * @throws Kronolith_Exception
101     */
102    public function listEvents(Horde_Date $startDate = null,
103                               Horde_Date $endDate = null,
104                               array $options = array())
105    {
106        $json = !empty($options['json']);
107        $options['json'] = false;
108        $events = $this->_driver->listEvents($startDate, $endDate, $options);
109        $results = array();
110
111        foreach ($events as $period_key => $period) {
112            foreach ($period as $event_id => $event) {
113                $resource_event = $this->_buildResourceEvent($event);
114                $results[$period_key][$event_id] = $json
115                    ? $resource_event->toJson()
116                    : $resource_event;
117            }
118        }
119
120        return $results;
121    }
122
123    protected function _buildResourceEvent($driver_event)
124    {
125        $resource_event = new $this->_eventClass($this);
126        $resource_event->fromDriver($driver_event->toProperties(true));
127        $resource_event->calendar = $this->calendar;
128
129        return $resource_event;
130    }
131
132    /**
133     * Get an event or events with the given UID value.
134     *
135     * @param string $uid       The UID to match
136     * @param array $calendars  A restricted array of calendar ids to search
137     * @param boolean $getAll   Return all matching events?
138     *
139     * @return Kronolith_Event
140     * @throws Kronolith_Exception
141     * @throws Horde_Exception_NotFound
142     */
143    public function getByUID($uid, $calendars = null, $getAll = false)
144    {
145       $event = new $this->_eventClass($this);
146       $driver_event = $this->_driver->getByUID($uid, $calendars, $getAll);
147       $event->fromDriver($driver_event->toProperties(true));
148       $event->calendar = $this->calendar;
149       return $event;
150    }
151
152    /**
153     * @throws Kronolith_Exception
154     * @throws Horde_Exception_NotFound
155     */
156    public function getEvent($eventId = null)
157    {
158        if (!strlen($eventId)) {
159            $event = new $this->_eventClass($this);
160            $event->calendar = $this->calendar;
161
162            return $event;
163        }
164
165        $driver_event = $this->_driver->getEvent($eventId);
166        $event = $this->_buildResourceEvent($driver_event);
167
168        return $event;
169    }
170
171    /**
172     * Saves an event in the backend.
173     *
174     * If it is a new event, it is added, otherwise the event is updated.
175     *
176     * @param Kronolith_Event $event  The event to save.
177     *
178     * @return string  The event id.
179     * @throws Horde_Mime_Exception
180     * @throws Kronolith_Exception
181     */
182    public function saveEvent(Kronolith_Event $event)
183    {
184        return $this->_driver->saveEvent($event);
185    }
186
187    /**
188     * Delete an event.
189     *
190     * Since this is the Kronolith_Resource's version of the event, if we
191     * delete it, we must also make sure to remove it from the event that
192     * it is attached to. Not sure if there is a better way to do this...
193     *
194     * @param string|Kronolith_Event_Resource_Sql $eventId  The ID of the event
195     *                                                      to delete.
196     * @param boolean $silent  Don't send notifications, used when deleting
197     *                         events in bulk from maintenance tasks.
198     * @param boolean $keep_bound  If true, does not remove the resource from
199     *                             the bound event. @since 4.2.2
200     *
201     * @throws Kronolith_Exception
202     * @throws Horde_Exception_NotFound
203     */
204    public function deleteEvent($eventId, $silent = false, $keep_bound = false)
205    {
206        if ($eventId instanceof Kronolith_Event_Resource_Sql) {
207            $delete_event = $eventId;
208            $eventId = $delete_event->id;
209        } else {
210            $delete_event = $this->getEvent($eventId);
211        }
212
213        if ($keep_bound) {
214            return;
215        }
216
217        $uid = $delete_event->uid;
218        $events = $this->_driver->getByUID($uid, null, true);
219        foreach ($events as $e) {
220            $resources = $e->getResources();
221            if (count($resources)) {
222                $r = $this->getResource($this->getResourceIdByCalendar($delete_event->calendar));
223                $e->removeResource($r);
224                $e->save();
225            }
226        }
227        $this->_driver->open($this->calendar);
228        $this->_driver->deleteEvent($delete_event, $silent);
229    }
230
231    /**
232     * Save or update a Kronolith_Resource
233     *
234     * @param Kronolith_Resource_Base $resource
235     *
236     * @return Kronolith_Resource object
237     * @throws Kronolith_Exception, Horde_Exception_PermissionDenied
238     */
239    public function save(Kronolith_Resource_Base $resource)
240    {
241        if (!$GLOBALS['registry']->isAdmin() &&
242            !$GLOBALS['injector']->getInstance('Horde_Core_Perms')->hasAppPermission('resource_management')) {
243            throw new Horde_Exception_PermissionDenied();
244        }
245        if ($resource->getId()) {
246            $query = 'UPDATE ' . $this->_params['table'] . ' SET resource_name = ?, '
247                . 'resource_calendar = ? , resource_description = ?, '
248                . 'resource_response_type = ?, resource_type = ?, '
249                . 'resource_members = ?, resource_email = ? WHERE resource_id = ?';
250
251            $values = array($this->convertToDriver($resource->get('name')),
252                            $resource->get('calendar'),
253                            $this->convertToDriver($resource->get('description')),
254                            $resource->get('response_type'),
255                            $resource->get('type'),
256                            serialize($resource->get('members')),
257                            $resource->get('email'),
258                            $resource->getId());
259
260            try {
261                $this->_db->update($query, $values);
262            } catch (Horde_Db_Exception $e) {
263                throw new Kronolith_Exception($e);
264            }
265        } else {
266            $query = 'INSERT INTO ' . $this->_params['table']
267                . ' (resource_name, resource_calendar, '
268                .  'resource_description, resource_response_type, '
269                . ' resource_type, resource_members, resource_email)'
270                . ' VALUES (?, ?, ?, ?, ?, ?, ?)';
271            $values = array($this->convertToDriver($resource->get('name')),
272                            $resource->get('calendar'),
273                            $this->convertToDriver($resource->get('description')),
274                            $resource->get('response_type'),
275                            $resource->get('type'),
276                            serialize($resource->get('members')),
277                            $resource->get('email'));
278            try {
279                $id = $this->_db->insert($query, $values);
280            } catch (Horde_Db_Exception $e) {
281                throw new Kronolith_Exception($e);
282            }
283            $resource->setId($id);
284        }
285
286        return $resource;
287    }
288
289    /**
290     * Removes a resource from storage, along with any events in the resource's
291     * calendar.
292     *
293     * @param Kronolith_Resource_Base $resource  The kronolith resource to remove
294     *
295     * @throws Kronolith_Exception, Horde_Exception_PermissionDenied
296     */
297    public function delete($resource)
298    {
299        if (!$GLOBALS['registry']->isAdmin() &&
300            !$GLOBALS['injector']->getInstance('Horde_Core_Perms')->hasAppPermission('resource_management')) {
301            throw new Horde_Exception_PermissionDenied();
302        }
303
304        if (!$resource->getId()) {
305            throw new Kronolith_Exception(_("Resource not valid."));
306        }
307
308        // Get group memberships and remove from group.
309        $groups = $this->getGroupMemberships($resource->getId());
310        foreach ($groups as $id) {
311            $rg = $this->getResource($id);
312            $members = $rg->get('members');
313            unset($members[array_search($resource->getId(), $members)]);
314            $rg->set('members', $members);
315            $rg->save();
316        }
317
318        $this->_deleteResourceCalendar($resource->get('calendar'));
319        try {
320            $query = 'DELETE FROM ' . $this->_params['table'] . ' WHERE resource_id = ?';
321            $this->_db->delete($query, array($resource->getId()));
322        } catch (Horde_Db_Exception $e) {
323            throw new Kronolith_Exception($e);
324        }
325    }
326
327    /**
328     * Obtain a Kronolith_Resource by the resource's id
329     *
330     * @param integer $id  The key for the Kronolith_Resource
331     *
332     * @return Kronolith_Resource_Base
333     * @throws Kronolith_Exception
334     */
335    public function getResource($id)
336    {
337        $query = 'SELECT resource_id, resource_name, resource_calendar, '
338            . 'resource_description, resource_response_type, resource_type, '
339            . 'resource_members, resource_email FROM ' . $this->_params['table']
340            . ' WHERE resource_id = ?';
341
342        try {
343            $results = $this->_db->selectOne($query, array($id));
344        } catch (Horde_Db_Exception $e) {
345            throw new Kronolith_Exception($e);
346        }
347        if (!count($results)) {
348            throw new Horde_Exception_NotFound('Resource not found');
349        }
350
351        $class = 'Kronolith_Resource_' . $results['resource_type'];
352        if (!class_exists($class)) {
353            throw new Kronolith_Exception('Could not load the class definition for ' . $class);
354        }
355
356        return new $class($this->_fromDriver($results));
357    }
358
359    /**
360     * Obtain the resource id associated with the given calendar uid.
361     *
362     * @param string $calendar  The calendar's uid.
363     *
364     * @return integer  The Kronolith_Resource id.
365     * @throws Kronolith_Exception
366     */
367    public function getResourceIdByCalendar($calendar)
368    {
369        $query = 'SELECT resource_id FROM ' . $this->_params['table']
370            . ' WHERE resource_calendar = ?';
371        try {
372            $result = $this->_db->selectValue($query, array($calendar));
373        } catch (Horde_Db_Exception $e) {
374            throw new Kronolith_Exception($e);
375        }
376        if (empty($result)) {
377            throw new Horde_Exception_NotFound('Resource not found');
378        }
379
380        return $result;
381    }
382
383    /**
384     * Determine if the provided calendar id represents a resource's calendar.
385     *
386     * @param string $calendar  The calendar identifier to check.
387     *
388     * @return boolean
389     */
390    public function isResourceCalendar($calendar)
391    {
392        $query = 'SELECT count(*) FROM ' . $this->_params['table']
393            . ' WHERE resource_calendar = ?';
394        try {
395            return $this->_db->selectValue($query, array($calendar)) > 0;
396        } catch (Horde_Db_Exception $e) {
397            throw new Kronolith_Exception($e);
398        }
399    }
400
401    /**
402     * Return a list of Kronolith_Resources
403     *
404     * Right now, all users have Horde_Perms::READ, but only system admins have
405     * Horde_Perms::EDIT | Horde_Perms::DELETE
406     *
407     * @param integer $perms   A Horde_Perms::* constant.
408     * @param array $filter    A hash of field/values to filter on.
409     * @param string $orderby  Field to order results by. Null for no ordering.
410     *
411     * @return an array of Kronolith_Resource objects.
412     * @throws Kronolith_Exception
413     */
414    public function listResources($perms = Horde_Perms::READ, array $filter = array(), $orderby = null)
415    {
416        if (($perms & (Horde_Perms::EDIT | Horde_Perms::DELETE)) &&
417            !$GLOBALS['registry']->isAdmin()) {
418            return array();
419        }
420
421        $query = 'SELECT resource_id, resource_name, resource_calendar, resource_description,'
422            . ' resource_response_type, resource_type, resource_members, resource_email FROM '
423            . $this->_params['table'];
424        if (count($filter)) {
425            $clause = ' WHERE ';
426            $i = 0;
427            $c = count($filter);
428            foreach (array_keys($filter) as $field) {
429                $clause .= 'resource_' . $field . ' = ?' . (($i++ < ($c - 1)) ? ' AND ' : '');
430            }
431            $query .= $clause;
432        }
433
434        if (!empty($orderby)) {
435            $query .= ' ORDER BY resource_' . $orderby;
436        }
437
438        try {
439            $results = $this->_db->selectAll($query, $filter);
440        } catch (Horde_Db_Exception $e) {
441            throw new Kronolith_Exception($e);
442        }
443        $return = array();
444        foreach ($results as $row) {
445            $class = 'Kronolith_Resource_' . $row['resource_type'];
446            $return[$row['resource_id']] = new $class($this->_fromDriver(array_merge(array('resource_id' => $row['resource_id']), $row)));
447        }
448
449        return $return;
450    }
451
452    /**
453     * Obtain the group id for each group the specified resource is a member of.
454     *
455     * @param integer $resource_id  The resource id to check for.
456     *
457     * @return array  An array of group ids.
458     * @throws Kronolith_Exception
459     */
460    public function getGroupMemberships($resource_id)
461    {
462        $groups = $this->listResources(Horde_Perms::READ, array('type' => Kronolith_Resource::TYPE_GROUP));
463        $in = array();
464        foreach ($groups as $group) {
465            $members = $group->get('members');
466            if (array_search($resource_id, $members) !== false) {
467                $in[] = $group->getId();
468            }
469        }
470
471        return $in;
472    }
473
474    /**
475     * Converts a value from the driver's charset to the default
476     * charset.
477     *
478     * @param mixed $value  A value to convert.
479     *
480     * @return mixed  The converted value.
481     */
482    public function convertFromDriver($value)
483    {
484        return Horde_String::convertCharset($value, $this->_params['charset'], 'UTF-8');
485    }
486
487    /**
488     * Converts a value from the default charset to the driver's
489     * charset.
490     *
491     * @param mixed $value  A value to convert.
492     *
493     * @return mixed  The converted value.
494     */
495    public function convertToDriver($value)
496    {
497        return Horde_String::convertCharset($value, 'UTF-8', $this->_params['charset']);
498    }
499
500    /**
501     * Delete the resource calendar
502     *
503     * @param string $calendar  The calendar id.
504     */
505    public function _deleteResourceCalendar($calendar)
506    {
507        $this->open($calendar);
508        $events = $this->listEvents(null, null, array('cover_dates' => false));
509        foreach ($events as $dayevents) {
510            foreach ($dayevents as $event) {
511                $this->deleteEvent($event, true);
512            }
513        }
514    }
515
516    /**
517     * Convert from driver keys and charset to Kronolith keys and charset.
518     *
519     * @param array $params  The key/values to convert.
520     *
521     * @return array  An array of converted values.
522     */
523    protected function _fromDriver(array $params)
524    {
525        $return = array();
526
527        foreach ($params as $field => $value) {
528            switch ($field) {
529            case 'resource_description':
530                $value = $this->_columns['resource_description']
531                    ->binaryToString($value);
532                // Fall through.
533            case 'resource_name':
534                $value = $this->convertFromDriver($value);
535                break;
536            case 'resource_members':
537                $value = $this->_columns['resource_members']
538                    ->binaryToString($value);
539                $value = @unserialize($value);
540                break;
541            }
542
543            $return[str_replace('resource_', '', $field)] = $value;
544        }
545
546        return $return;
547    }
548
549    /**
550     * Helper function to update an existing event's tags to tagger storage.
551     *
552     * @param Kronolith_Event $event  The event to update
553     */
554    protected function _updateTags(Kronolith_Event $event)
555    {
556        // noop
557    }
558
559    /**
560     * Helper function to add tags from a newly creted event to the tagger.
561     *
562     * @param Kronolith_Event $event  The event to save tags to storage for.
563     */
564    protected function _addTags(Kronolith_Event $event)
565    {
566        // noop
567    }
568
569    protected function _handleNotifications(Kronolith_Event $event, $action)
570    {
571        // noop
572    }
573}
574