1: <?php
2: /**
3: * Jonah_Driver:: is responsible for storing, searching, sorting and filtering
4: * locally generated and managed articles. Aggregation is left to Hippo.
5: *
6: * Copyright 2002-2012 Horde LLC (http://www.horde.org/)
7: *
8: * See the enclosed file LICENSE for license information (BSD). If you did not
9: * did not receive this file, see http://cvs.horde.org/co.php/jonah/LICENSE.
10: *
11: * @author Chuck Hagenbuch <chuck@horde.org>
12: * @author Marko Djukic <marko@oblo.com>
13: * @author Jan Schneider <jan@horde.org>
14: * @author Ben Klang <ben@alkaloid.net>
15: * @author Michael J. Rubinsky <mrubinsk@horde.org>
16: * @package Jonah
17: */
18: class Jonah_Driver
19: {
20: /**
21: * Hash containing connection parameters.
22: *
23: * @var array
24: */
25: protected $_params = array();
26:
27: /**
28: * Constructs a new Driver storage object.
29: *
30: * @param array $params A hash containing connection parameters.
31: */
32: public function __construct($params = array())
33: {
34: $this->_params = $params;
35: }
36:
37: /**
38: * Remove a channel from storage.
39: *
40: * @param array $info A channel info array. (@TODO: Look at passing just
41: * the id?)
42: */
43: public function deleteChannel($info)
44: {
45: return $this->_deleteChannel($info['channel_id']);
46: }
47:
48: /**
49: * Fetches the requested channel, while actually passing on the request to
50: * the backend _getChannel() function to do the real work.
51: *
52: * @param integer $channel_id The channel id to fetch.
53: *
54: * @return array The channel details as an array
55: * @throws InvalidArgumentException
56: */
57: public function getChannel($channel_id)
58: {
59: static $channel = array();
60:
61: /* We need a non empty channel id. */
62: if (empty($channel_id)) {
63: throw new InvalidArgumentException(_("Missing channel id."));
64: }
65:
66: /* Cache the fetching of channels. */
67: if (!isset($channel[$channel_id])) {
68: $channel[$channel_id] = $this->_getChannel($channel_id);
69: if (empty($channel[$channel_id]['channel_link'])) {
70: $channel[$channel_id]['channel_official'] =
71: Horde::url('delivery/html.php', true, -1)->add('channel_id', $channel_id)->setRaw(false);
72: } else {
73: $channel[$channel_id]['channel_official'] = str_replace(array('%25c', '%c'), array('%c', $channel_id), $channel[$channel_id]['channel_link']);
74: }
75:
76: }
77:
78: return $channel[$channel_id];
79: }
80:
81: /**
82: * Returns the most recent or all stories from a channel.
83: *
84: * @param integer $criteria An associative array of attributes on which
85: * the resulting stories should be filtered.
86: * Examples:
87: * 'channel' => string Channel slug
88: * 'channel_id' => int Channel ID
89: * 'author' => string Story author
90: * 'updated-min' => Horde_Date Only return
91: * stories updated
92: * on or after this
93: * date
94: * 'updated-max' => Horde_Date Only return
95: * stories updated
96: * on or before this
97: * date
98: * 'published-min' => Horde_Date Only return
99: * stories
100: * published on or
101: * after this date
102: * 'published-max' => Horde_Date Only return
103: * stories
104: * published on or
105: * before date
106: * 'tags' => array Array of tag names ANY of
107: * which may match the story to
108: * be included
109: * 'alltags' => array Array of tag names ALL of
110: * which must be associated
111: * with the story to be
112: * included
113: * 'keywords' => array Array of strings ALL of
114: * which matching must
115: * include
116: * 'published' => boolean Whether to return only
117: * published stories;
118: * null will return both
119: * published and
120: * unpublished
121: * 'startnumber' => int Story number to begin
122: * 'endnumber' => int Story number to end
123: * 'limit' => int Max number of stories
124: *
125: * @param integer $order How to order the results for internal
126: * channels. Possible values are the
127: * Jonah::ORDER_* constants.
128: *
129: * @return array The specified number (or less, if there are fewer) of
130: * stories from the given channel.
131: * @throws InvalidArgumentException
132: */
133: public function getStories($criteria, $order = Jonah::ORDER_PUBLISHED)
134: {
135: // Convert a channel slug into a channel ID if necessary
136: if (isset($criteria['channel']) && !isset($criteria['channel_id'])) {
137: $criteria['channel_id'] = $this->getIdBySlug($criteria['channel']);
138: }
139:
140: // Validate that we have proper Horde_Date objects
141: if (isset($criteria['updated-min'])) {
142: if (!is_a($criteria['updated-min'], 'Horde_Date')) {
143: throw new InvalidArgumentException("Invalid date object provided for update start date.");
144: }
145: }
146: if (isset($criteria['updated-max'])) {
147: if (!is_a($criteria['updated-max'], 'Horde_Date')) {
148: throw new InvalidArgumentException("Invalid date object provided for update end date.");
149: }
150: }
151: if (isset($criteria['published-min'])) {
152: if (!is_a($criteria['published-min'], 'Horde_Date')) {
153: throw new InvalidArgumentException("Invalid date object provided for published start date.");
154: }
155: }
156: if (isset($criteria['published-max'])) {
157: if (!is_a($criteria['published-max'], 'Horde_Date')) {
158: throw new InvalidArgumentException("Invalid date object provided for published end date.");
159: }
160: }
161:
162: // Collect the applicable tag IDs
163: $criteria['tagIDs'] = array();
164: if (isset($criteria['tags'])) {
165: $criteria['tagIDs'] = array_merge($criteria['tagIDs'], $this->getTagIds($criteria['tags']));
166: }
167: if (isset($criteria['alltags'])) {
168: $criteria['tagIDs'] = array_merge($criteria['tagIDs'], $this->getTagIds($criteria['alltags']));
169: }
170:
171: return $this->_getStories($criteria, $order);
172: }
173:
174: /**
175: * Save the provided story to storage.
176: *
177: * @param array $info The story information array. Passed by reference so
178: * we can add/change the id when saved.
179: */
180: public function saveStory(&$info)
181: {
182: $this->_saveStory($info);
183: }
184:
185: /**
186: * Retrieve the requested story from storage.
187: *
188: * @param integer $channel_id The channel id to obtain story from.
189: * @param integer $story_id The story id to obtain.
190: * @param boolean $read Increment the read counter?
191: *
192: * @return array The story information array
193: */
194: public function getStory($channel_id, $story_id, $read = false)
195: {
196: $channel = $this->getChannel($channel_id);
197: $story = $this->_getStory($story_id, $read);
198:
199: /* Format story link. */
200: $story['link'] = $this->getStoryLink($channel, $story);
201:
202: /* Format dates. */
203: $date_format = $GLOBALS['prefs']->getValue('date_format');
204: $story['updated_date'] = strftime($date_format, $story['updated']);
205: if (!empty($story['published'])) {
206: $story['published_date'] = strftime($date_format, $story['published']);
207: }
208:
209: return $story;
210: }
211:
212: /**
213: * Returns the official link to a story.
214: *
215: * @param array $channel A channel hash.
216: * @param array $story A story hash.
217: *
218: * @return Horde_Url The story link.
219: */
220: public function getStoryLink($channel, $story)
221: {
222: if (!empty($story['url']) && empty($story['body'])) {
223: $url = $story['url'];
224: } elseif ((empty($story['url']) || !empty($story['body'])) &&
225: !empty($channel['channel_story_url'])) {
226: $url = $channel['channel_story_url'];
227: } else {
228: $url = Horde::url('stories/view.php', true, -1)->add(array('channel_id' => '%c', 'id' => '%s'))->setRaw(false);
229: }
230: return new Horde_Url(str_replace(array('%25c', '%25s', '%c', '%s'),
231: array('%c', '%s', $channel['channel_id'], $story['id']),
232: $url));
233: }
234:
235: /**
236: */
237: public function getChecksum($story)
238: {
239: return md5($story['title'] . $story['description']);
240: }
241:
242: /**
243: */
244: public function getIntervalLabel($seconds = null)
245: {
246: $interval = array(1 => _("none"),
247: 1800 => _("30 mins"),
248: 3600 => _("1 hour"),
249: 7200 => _("2 hours"),
250: 14400 => _("4 hours"),
251: 28800 => _("8 hours"),
252: 43200 => _("12 hours"),
253: 86400 => _("24 hours"));
254:
255: if ($seconds === null) {
256: return $interval;
257: } else {
258: return $interval[$seconds];
259: }
260: }
261:
262: /**
263: * Returns the stories of a channel rendered with the specified template.
264: *
265: * @param integer $channel_id The news channel to get stories from.
266: * @param string $tpl The name of the template to use.
267: * @param integer $max The maximum number of stories to get. If
268: * null, all stories will be returned.
269: * @param integer $from The number of the story to start with.
270: * @param integer $order How to sort the results for internal channels
271: * Possible values are the Jonah::ORDER_*
272: * constants.
273: *
274: * @TODO: This doesn't belong in a storage driver class. Move it to a
275: * view or possible a static method in Jonah::?
276: *
277: * @return string The rendered story listing.
278: */
279: public function renderChannel($channel_id, $tpl, $max = 10, $from = 0, $order = Jonah::ORDER_PUBLISHED)
280: {
281: $channel = $this->getChannel($channel_id);
282:
283: $templates = Horde::loadConfiguration('templates.php', 'templates', 'jonah');
284: $escape = !isset($templates[$tpl]['escape']) || !empty($templates[$tpl]['escape']);
285: $template = new Horde_Template();
286:
287: if ($escape) {
288: $channel['channel_name'] = htmlspecialchars($channel['channel_name']);
289: $channel['channel_desc'] = htmlspecialchars($channel['channel_desc']);
290: }
291: $template->set('channel', $channel, true);
292:
293: /* Get one story more than requested to see if there are more stories. */
294: if ($max !== null) {
295: $stories = $this->getStories(
296: array('channel_id' => $channel_id,
297: 'published' => true,
298: 'startnumber' => $from,
299: 'limit' => $max),
300: $order);
301: } else {
302: $stories = $this->getStories(array('channel_id' => $channel_id,
303: 'published' => true),
304: $order);
305: $max = count($stories);
306: }
307:
308: if (!$stories) {
309: $template->set('error', _("No stories are currently available."), true);
310: $template->set('stories', false, true);
311: $template->set('image', false, true);
312: $template->set('form', false, true);
313: } else {
314: /* Escape. */
315: if ($escape) {
316: array_walk($stories, array($this, '_escapeStories'));
317: }
318:
319: /* Process story summaries. */
320: array_walk($stories, array($this, '_escapeStoryDescriptions'));
321:
322: $template->set('error', false, true);
323: $template->set('story_marker', Horde::img('story_marker.png'));
324: $template->set('image', false, true);
325: $template->set('form', false, true);
326: if ($from) {
327: $template->set('previous', max(0, $from - $max), true);
328: } else {
329: $template->set('previous', false, true);
330: }
331: if ($from && !empty($channel['channel_page_link'])) {
332: $template->set('previous_link',
333: str_replace(
334: array('%25c', '%25n', '%c', '%n'),
335: array('%c', '%n', $channel['channel_id'], max(0, $from - $max)),
336: $channel['channel_page_link']),
337: true);
338: } else {
339: $template->set('previous_link', false, true);
340: }
341: $more = count($stories) > $max;
342: if ($more) {
343: $template->set('next', $from + $max, true);
344: array_pop($stories);
345: } else {
346: $template->set('next', false, true);
347: }
348: if ($more && !empty($channel['channel_page_link'])) {
349: $template->set('next_link',
350: str_replace(
351: array('%25c', '%25n', '%c', '%n'),
352: array('%c', '%n', $channel['channel_id'], $from + $max),
353: $channel['channel_page_link']),
354: true);
355: } else {
356: $template->set('next_link', false, true);
357: }
358:
359: $template->set('stories', $stories, true);
360: }
361:
362: return $template->parse($templates[$tpl]['template']);
363: }
364:
365: /**
366: * @TODO: Move to a view class or static Jonah:: method?
367: */
368: protected function _escapeStories(&$value, $key)
369: {
370: $value['title'] = htmlspecialchars($value['title']);
371: $value['description'] = htmlspecialchars($value['description']);
372: if (isset($value['link'])) {
373: $value['link'] = htmlspecialchars($value['link']);
374: }
375: if (empty($value['body_type']) || $value['body_type'] != 'richtext') {
376: $value['body'] = htmlspecialchars($value['body']);
377: }
378: }
379:
380: /**
381: * @TODO: Move to a view class or static Jonah:: method?
382: */
383: protected function _escapeStoryDescriptions(&$value, $key)
384: {
385: $value['description'] = nl2br($value['description']);
386: }
387:
388: /**
389: * Returns the provided story as a MIME part.
390: *
391: * @param array $story A data array representing a story.
392: *
393: * @return MIME_Part The MIME message part containing the story parts.
394: * @TODO: Refactor to use new Horde MIME library
395: */
396: protected function getStoryAsMessage($story)
397: {
398: require_once 'Horde/MIME/Part.php';
399:
400: /* Add the story to the message based on the story's body type. */
401: switch ($story['body_type']) {
402: case 'richtext':
403: /* Get a plain text version of a richtext story. */
404: $body_html = $story['body'];
405: $body_text = $GLOBALS['injector']->getInstance('Horde_Core_Factory_TextFilter')->filter($body_html, 'html2text');
406:
407: /* Add description. */
408: $body_html = '<p>' . $GLOBALS['injector']->getInstance('Horde_Core_Factory_TextFilter')->filter($story['desc'], 'text2html', array('parselevel' => Horde_Text_Filter_Text2html::MICRO, 'callback' => null)) . "</p>\n" . $body_html;
409: $body_text = Horde_String::wrap(' ' . $story['description'], 70) . "\n\n" . $body_text;
410:
411: /* Add the text version of the story to the base message. */
412: $message_text = new MIME_Part('text/plain');
413: $message_text->setCharset('UTF-8');
414: $message_text->setContents($message_text->replaceEOL($body_text));
415: $message_text->setDescription(_("Plaintext Version of Story"));
416:
417: /* Add an HTML version of the story to the base message. */
418: $message_html = new MIME_Part('text/html', Horde_String::wrap($body_html),
419: 'UTF-8', 'inline');
420: $message_html->setDescription(_("HTML Version of Story"));
421:
422: /* Add the two parts as multipart/alternative. */
423: $basepart = new MIME_Part('multipart/alternative');
424: $basepart->addPart($message_text);
425: $basepart->addPart($message_html);
426:
427: return $basepart;
428:
429: case 'text':
430: /* This is just a plain text story. */
431: $message_text = new MIME_Part('text/plain');
432: $message_text->setContents($message_text->replaceEOL($story['description'] . "\n\n" . $story['body']));
433: $message_text->setCharset('UTF-8');
434:
435: return $message_text;
436: }
437: }
438:
439: /**
440: * Stubs for the tag functions. If supported by the backend, these need
441: * to be implemented in the concrete Jonah_Driver_* class.
442: *
443: * @TODO: These will be moved to a new Tagger class and will interface
444: * with the Content_Tagger api.
445: */
446: function writeTags($resource_id, $channel_id, $tags)
447: {
448: return PEAR::raiseError(_("Tag support not enabled in backend."));
449: }
450:
451: function readTags($resource_id)
452: {
453: return PEAR::raiseError(_("Tag support not enabled in backend."));
454: }
455:
456: function listTagInfo($tags = array(), $channel_id = null)
457: {
458: return PEAR::raiseError(_("Tag support not enabled in backend."));
459: }
460:
461: function searchTagsById($ids, $max = 10, $from = 0, $channel_id = array(),
462: $order = Jonah::ORDER_PUBLISHED)
463: {
464: return PEAR::raiseError(_("Tag support not enabled in backend."));
465: }
466:
467: function getTagNames($ids)
468: {
469: return PEAR::raiseError(_("Tag support not enabled in backend."));
470: }
471:
472: function getIdBySlug($channel)
473: {
474: return $this->_getIdBySlug($channel);
475: }
476:
477: }
478: