3 * StatusNet, the distributed open-source microblogging tool
9 * LICENCE: This program is free software: you can redistribute it and/or modify
10 * it under the terms of the GNU Affero General Public License as published by
11 * the Free Software Foundation, either version 3 of the License, or
12 * (at your option) any later version.
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 * GNU Affero General Public License for more details.
19 * You should have received a copy of the GNU Affero General Public License
20 * along with this program. If not, see <http://www.gnu.org/licenses/>.
24 * @author Evan Prodromou <evan@status.net>
25 * @copyright 2010 StatusNet, Inc.
26 * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
27 * @link http://status.net/
30 if (!defined('STATUSNET')) {
35 * Utilities for turning DOMish things into Activityish things
37 * Some common functions that I didn't have the bandwidth to try to factor
38 * into some kind of reasonable superclass, so just dumped here. Might
39 * be useful to have an ActivityObject parent class or something.
43 * @author Evan Prodromou <evan@status.net>
44 * @copyright 2010 StatusNet, Inc.
45 * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
46 * @link http://status.net/
51 const ATOM = 'http://www.w3.org/2005/Atom';
58 const CONTENT = 'content';
62 * Get the permalink for an Activity object
64 * @param DOMElement $element A DOM element
66 * @return string related link, if any
69 static function getPermalink($element)
71 return self::getLink($element, 'alternate', 'text/html');
75 * Get the permalink for an Activity object
77 * @param DOMElement $element A DOM element
79 * @return string related link, if any
82 static function getLink($element, $rel, $type=null)
84 $links = $element->getElementsByTagnameNS(self::ATOM, self::LINK);
86 foreach ($links as $link) {
88 $linkRel = $link->getAttribute(self::REL);
89 $linkType = $link->getAttribute(self::TYPE);
91 if ($linkRel == $rel &&
92 (is_null($type) || $linkType == $type)) {
93 return $link->getAttribute(self::HREF);
101 * Gets the first child element with the given tag
103 * @param DOMElement $element element to pick at
104 * @param string $tag tag to look for
105 * @param string $namespace Namespace to look under
107 * @return DOMElement found element or null
110 static function child($element, $tag, $namespace=self::ATOM)
112 $els = $element->childNodes;
113 if (empty($els) || $els->length == 0) {
116 for ($i = 0; $i < $els->length; $i++) {
117 $el = $els->item($i);
118 if ($el->localName == $tag && $el->namespaceURI == $namespace) {
126 * Grab the text content of a DOM element child of the current element
128 * @param DOMElement $element Element whose children we examine
129 * @param string $tag Tag to look up
130 * @param string $namespace Namespace to use, defaults to Atom
132 * @return string content of the child
135 static function childContent($element, $tag, $namespace=self::ATOM)
137 $el = self::child($element, $tag, $namespace);
142 return $el->textContent;
147 * Get the content of an atom:entry-like object
149 * @param DOMElement $element The element to examine.
151 * @return string unencoded HTML content of the element, like "This -< is <b>HTML</b>."
153 * @todo handle remote content
154 * @todo handle embedded XML mime types
155 * @todo handle base64-encoded non-XML and non-text mime types
158 static function getContent($element)
160 $contentEl = ActivityUtils::child($element, self::CONTENT);
162 if (!empty($contentEl)) {
164 $src = $contentEl->getAttribute(self::SRC);
167 throw new ClientException(_("Can't handle remote content yet."));
170 $type = $contentEl->getAttribute(self::TYPE);
172 // slavishly following http://atompub.org/rfc4287.html#rfc.section.4.1.3.3
174 if ($type == 'text') {
175 return $contentEl->textContent;
176 } else if ($type == 'html') {
177 $text = $contentEl->textContent;
178 return htmlspecialchars_decode($text, ENT_QUOTES);
179 } else if ($type == 'xhtml') {
180 $divEl = ActivityUtils::child($contentEl, 'div');
184 $doc = $divEl->ownerDocument;
186 $children = $divEl->childNodes;
188 for ($i = 0; $i < $children->length; $i++) {
189 $child = $children->item($i);
190 $text .= $doc->saveXML($child);
193 } else if (in_array(array('text/xml', 'application/xml'), $type) ||
194 preg_match('#(+|/)xml$#', $type)) {
195 throw new ClientException(_("Can't handle embedded XML content yet."));
196 } else if (strncasecmp($type, 'text/', 5)) {
197 return $contentEl->textContent;
199 throw new ClientException(_("Can't handle embedded Base64 content yet."));
206 * A noun-ish thing in the activity universe
208 * The activity streams spec talks about activity objects, while also having
209 * a tag activity:object, which is in fact an activity object. Aaaaaah!
211 * This is just a thing in the activity universe. Can be the subject, object,
212 * or indirect object (target!) of an activity verb. Rotten name, and I'm
213 * propagating it. *sigh*
217 * @author Evan Prodromou <evan@status.net>
218 * @copyright 2010 StatusNet, Inc.
219 * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
220 * @link http://status.net/
225 const ARTICLE = 'http://activitystrea.ms/schema/1.0/article';
226 const BLOGENTRY = 'http://activitystrea.ms/schema/1.0/blog-entry';
227 const NOTE = 'http://activitystrea.ms/schema/1.0/note';
228 const STATUS = 'http://activitystrea.ms/schema/1.0/status';
229 const FILE = 'http://activitystrea.ms/schema/1.0/file';
230 const PHOTO = 'http://activitystrea.ms/schema/1.0/photo';
231 const ALBUM = 'http://activitystrea.ms/schema/1.0/photo-album';
232 const PLAYLIST = 'http://activitystrea.ms/schema/1.0/playlist';
233 const VIDEO = 'http://activitystrea.ms/schema/1.0/video';
234 const AUDIO = 'http://activitystrea.ms/schema/1.0/audio';
235 const BOOKMARK = 'http://activitystrea.ms/schema/1.0/bookmark';
236 const PERSON = 'http://activitystrea.ms/schema/1.0/person';
237 const GROUP = 'http://activitystrea.ms/schema/1.0/group';
238 const PLACE = 'http://activitystrea.ms/schema/1.0/place';
239 const COMMENT = 'http://activitystrea.ms/schema/1.0/comment';
242 // Atom elements we snarf
244 const TITLE = 'title';
245 const SUMMARY = 'summary';
247 const SOURCE = 'source';
251 const EMAIL = 'email';
265 * This probably needs to be refactored
266 * to generate a local class (ActivityPerson, ActivityFile, ...)
267 * based on the object type.
269 * @param DOMElement $element DOM thing to turn into an Activity thing
272 function __construct($element = null)
274 if (empty($element)) {
278 $this->element = $element;
280 if ($element->tagName == 'author') {
282 $this->type = self::PERSON; // XXX: is this fair?
283 $this->title = $this->_childContent($element, self::NAME);
284 $this->id = $this->_childContent($element, self::URI);
286 if (empty($this->id)) {
287 $email = $this->_childContent($element, self::EMAIL);
288 if (!empty($email)) {
290 $this->id = 'mailto:'.$email;
296 $this->type = $this->_childContent($element, Activity::OBJECTTYPE,
299 if (empty($this->type)) {
300 $this->type = ActivityObject::NOTE;
303 $this->id = $this->_childContent($element, self::ID);
304 $this->title = $this->_childContent($element, self::TITLE);
305 $this->summary = $this->_childContent($element, self::SUMMARY);
307 $this->source = $this->_getSource($element);
309 $this->content = ActivityUtils::getContent($element);
311 $this->link = ActivityUtils::getPermalink($element);
313 // XXX: grab PoCo stuff
316 // Some per-type attributes...
317 if ($this->type == self::PERSON || $this->type == self::GROUP) {
318 $this->displayName = $this->title;
320 // @fixme we may have multiple avatars with different resolutions specified
321 $this->avatar = ActivityUtils::getLink($element, 'avatar');
325 private function _childContent($element, $tag, $namespace=ActivityUtils::ATOM)
327 return ActivityUtils::childContent($element, $tag, $namespace);
330 // Try to get a unique id for the source feed
332 private function _getSource($element)
334 $sourceEl = ActivityUtils::child($element, 'source');
336 if (empty($sourceEl)) {
339 $href = ActivityUtils::getLink($sourceEl, 'self');
343 return ActivityUtils::childContent($sourceEl, 'id');
348 static function fromNotice($notice)
350 $object = new ActivityObject();
352 $object->type = ActivityObject::NOTE;
354 $object->id = $notice->uri;
355 $object->title = $notice->content;
356 $object->content = $notice->rendered;
357 $object->link = $notice->bestUrl();
362 static function fromProfile($profile)
364 $object = new ActivityObject();
366 $object->type = ActivityObject::PERSON;
367 $object->id = $profile->getUri();
368 $object->title = $this->getBestName();
369 $object->link = $profile->profileurl;
374 function asString($tag='activity:object')
376 $xs = new XMLStringer(true);
378 $xs->elementStart($tag);
380 $xs->element('activity:object-type', null, $this->type);
382 $xs->element(self::ID, null, $this->id);
384 if (!empty($this->title)) {
385 $xs->element(self::TITLE, null, $this->title);
388 if (!empty($this->summary)) {
389 $xs->element(self::SUMMARY, null, $this->summary);
392 if (!empty($this->content)) {
393 // XXX: assuming HTML content here
394 $xs->element(self::CONTENT, array('type' => 'html'), $this->content);
397 if (!empty($this->link)) {
398 $xs->element('link', array('rel' => 'alternate', 'type' => 'text/html'),
402 $xs->elementEnd($tag);
404 return $xs->getString();
409 * Utility class to hold a bunch of constant defining default verb types
413 * @author Evan Prodromou <evan@status.net>
414 * @copyright 2010 StatusNet, Inc.
415 * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
416 * @link http://status.net/
421 const POST = 'http://activitystrea.ms/schema/1.0/post';
422 const SHARE = 'http://activitystrea.ms/schema/1.0/share';
423 const SAVE = 'http://activitystrea.ms/schema/1.0/save';
424 const FAVORITE = 'http://activitystrea.ms/schema/1.0/favorite';
425 const PLAY = 'http://activitystrea.ms/schema/1.0/play';
426 const FOLLOW = 'http://activitystrea.ms/schema/1.0/follow';
427 const FRIEND = 'http://activitystrea.ms/schema/1.0/make-friend';
428 const JOIN = 'http://activitystrea.ms/schema/1.0/join';
429 const TAG = 'http://activitystrea.ms/schema/1.0/tag';
431 // Custom OStatus verbs for the flipside until they're standardized
432 const DELETE = 'http://ostatus.org/schema/1.0/unfollow';
433 const UNFAVORITE = 'http://ostatus.org/schema/1.0/unfavorite';
434 const UNFOLLOW = 'http://ostatus.org/schema/1.0/unfollow';
435 const LEAVE = 'http://ostatus.org/schema/1.0/leave';
438 class ActivityContext
443 public $attention = array();
444 public $conversation;
446 const THR = 'http://purl.org/syndication/thread/1.0';
447 const GEORSS = 'http://www.georss.org/georss';
448 const OSTATUS = 'http://ostatus.org/schema/1.0';
450 const INREPLYTO = 'in-reply-to';
454 const POINT = 'point';
456 const ATTENTION = 'ostatus:attention';
457 const CONVERSATION = 'ostatus:conversation';
459 function __construct($element)
461 $replyToEl = ActivityUtils::child($element, self::INREPLYTO, self::THR);
463 if (!empty($replyToEl)) {
464 $this->replyToID = $replyToEl->getAttribute(self::REF);
465 $this->replyToUrl = $replyToEl->getAttribute(self::HREF);
468 $this->location = $this->getLocation($element);
470 $this->conversation = ActivityUtils::getLink($element, self::CONVERSATION);
472 // Multiple attention links allowed
474 $links = $element->getElementsByTagNameNS(ActivityUtils::ATOM, ActivityUtils::LINK);
476 for ($i = 0; $i < $links->length; $i++) {
478 $link = $links->item($i);
480 $linkRel = $link->getAttribute(ActivityUtils::REL);
482 if ($linkRel == self::ATTENTION) {
483 $this->attention[] = $link->getAttribute(self::HREF);
489 * Parse location given as a GeoRSS-simple point, if provided.
490 * http://www.georss.org/simple
492 * @param feed item $entry
493 * @return mixed Location or false
495 function getLocation($dom)
497 $points = $dom->getElementsByTagNameNS(self::GEORSS, self::POINT);
499 for ($i = 0; $i < $points->length; $i++) {
500 $point = $points->item($i)->textContent;
501 $point = str_replace(',', ' ', $point); // per spec "treat commas as whitespace"
502 $point = preg_replace('/\s+/', ' ', $point);
503 $point = trim($point);
504 $coords = explode(' ', $point);
505 if (count($coords) == 2) {
506 list($lat, $lon) = $coords;
507 if (is_numeric($lat) && is_numeric($lon)) {
508 common_log(LOG_INFO, "Looking up location for $lat $lon from georss");
509 return Location::fromLatLon($lat, $lon);
512 common_log(LOG_ERR, "Ignoring bogus georss:point value $point");
520 * An activity in the ActivityStrea.ms world
522 * An activity is kind of like a sentence: someone did something
525 * 'someone' is the 'actor'; 'did something' is the verb;
526 * 'something else' is the object.
530 * @author Evan Prodromou <evan@status.net>
531 * @copyright 2010 StatusNet, Inc.
532 * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
533 * @link http://status.net/
538 const SPEC = 'http://activitystrea.ms/spec/1.0/';
539 const SCHEMA = 'http://activitystrea.ms/schema/1.0/';
542 const OBJECT = 'object';
543 const ACTOR = 'actor';
544 const SUBJECT = 'subject';
545 const OBJECTTYPE = 'object-type';
546 const CONTEXT = 'context';
547 const TARGET = 'target';
549 const ATOM = 'http://www.w3.org/2005/Atom';
551 const AUTHOR = 'author';
552 const PUBLISHED = 'published';
553 const UPDATED = 'updated';
555 public $actor; // an ActivityObject
556 public $verb; // a string (the URL)
557 public $object; // an ActivityObject
558 public $target; // an ActivityObject
559 public $context; // an ActivityObject
560 public $time; // Time of the activity
561 public $link; // an ActivityObject
562 public $entry; // the source entry
563 public $feed; // the source feed
565 public $summary; // summary of activity
566 public $content; // HTML content of activity
567 public $id; // ID of the activity
568 public $title; // title of the activity
571 * Turns a regular old Atom <entry> into a magical activity
573 * @param DOMElement $entry Atom entry to poke at
574 * @param DOMElement $feed Atom feed, for context
577 function __construct($entry = null, $feed = null)
579 if (is_null($entry)) {
583 $this->entry = $entry;
586 $pubEl = $this->_child($entry, self::PUBLISHED, self::ATOM);
588 if (!empty($pubEl)) {
589 $this->time = strtotime($pubEl->textContent);
591 // XXX technically an error; being liberal. Good idea...?
592 $updateEl = $this->_child($entry, self::UPDATED, self::ATOM);
593 if (!empty($updateEl)) {
594 $this->time = strtotime($updateEl->textContent);
600 $this->link = ActivityUtils::getPermalink($entry);
602 $verbEl = $this->_child($entry, self::VERB);
604 if (!empty($verbEl)) {
605 $this->verb = trim($verbEl->textContent);
607 $this->verb = ActivityVerb::POST;
608 // XXX: do other implied stuff here
611 $objectEl = $this->_child($entry, self::OBJECT);
613 if (!empty($objectEl)) {
614 $this->object = new ActivityObject($objectEl);
616 $this->object = new ActivityObject($entry);
619 $actorEl = $this->_child($entry, self::ACTOR);
621 if (!empty($actorEl)) {
623 $this->actor = new ActivityObject($actorEl);
625 } else if (!empty($feed) &&
626 $subjectEl = $this->_child($feed, self::SUBJECT)) {
628 $this->actor = new ActivityObject($subjectEl);
630 } else if ($authorEl = $this->_child($entry, self::AUTHOR, self::ATOM)) {
632 $this->actor = new ActivityObject($authorEl);
634 } else if (!empty($feed) && $authorEl = $this->_child($feed, self::AUTHOR,
637 $this->actor = new ActivityObject($authorEl);
640 $contextEl = $this->_child($entry, self::CONTEXT);
642 if (!empty($contextEl)) {
643 $this->context = new ActivityContext($contextEl);
645 $this->context = new ActivityContext($entry);
648 $targetEl = $this->_child($entry, self::TARGET);
650 if (!empty($targetEl)) {
651 $this->target = new ActivityObject($targetEl);
654 $this->summary = ActivityUtils::childContent($entry, 'summary');
655 $this->id = ActivityUtils::childContent($entry, 'id');
656 $this->content = ActivityUtils::getContent($entry);
660 * Returns an Atom <entry> based on this activity
662 * @return DOMElement Atom entry
665 function toAtomEntry()
670 function asString($namespace=false)
672 $xs = new XMLStringer(true);
675 $attrs = array('xmlns' => 'http://www.w3.org/2005/Atom',
676 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
677 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0');
682 $xs->elementStart('entry', $attrs);
684 $xs->element('id', null, $this->id);
685 $xs->element('title', null, $this->title);
686 $xs->element('published', null, common_date_iso8601($this->time));
687 $xs->element('content', array('type' => 'html'), $this->content);
689 if (!empty($this->summary)) {
690 $xs->element('summary', null, $this->summary);
693 if (!empty($this->link)) {
694 $xs->element('link', array('rel' => 'alternate',
695 'type' => 'text/html'),
702 $xs->raw($this->actor->asString());
703 $xs->element('activity:verb', null, $this->verb);
704 $xs->raw($this->object->asString());
706 $xs->elementEnd('entry');
708 return $xs->getString();
711 private function _child($element, $tag, $namespace=self::SPEC)
713 return ActivityUtils::child($element, $tag, $namespace);