3 * StatusNet - the distributed open-source microblogging tool
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU Affero General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <http://www.gnu.org/licenses/>.
20 * @author Shashi Gowda <connect2shashi@gmail.com>
21 * @license GNU Affero General Public License http://www.gnu.org/licenses/
24 if (!defined('GNUSOCIAL')) { exit(1); }
26 class Profile_list extends Managed_DataObject
28 public $__table = 'profile_list'; // table name
29 public $id; // int(4) primary_key not_null
30 public $tagger; // int(4)
31 public $tag; // varchar(64)
32 public $description; // text
33 public $private; // tinyint(1)
34 public $created; // datetime not_null default_0000-00-00%2000%3A00%3A00
35 public $modified; // timestamp not_null default_CURRENT_TIMESTAMP
36 public $uri; // varchar(191) unique_key not 255 because utf8mb4 takes more space
37 public $mainpage; // varchar(191) not 255 because utf8mb4 takes more space
38 public $tagged_count; // smallint
39 public $subscriber_count; // smallint
41 public static function schemaDef()
45 'id' => array('type' => 'serial', 'not null' => true, 'description' => 'unique identifier'),
46 'tagger' => array('type' => 'int', 'not null' => true, 'description' => 'user making the tag'),
47 'tag' => array('type' => 'varchar', 'length' => 64, 'not null' => true, 'description' => 'people tag'),
48 'description' => array('type' => 'text', 'description' => 'description of the people tag'),
49 'private' => array('type' => 'int', 'size' => 'tiny', 'default' => 0, 'description' => 'is this tag private'),
51 'created' => array('type' => 'timestamp', 'not null' => true, 'description' => 'date the tag was added'),
52 'modified' => array('type' => 'timestamp', 'not null' => true, 'description' => 'date the tag was modified'),
54 'uri' => array('type' => 'varchar', 'length' => 191, 'description' => 'universal identifier'),
55 'mainpage' => array('type' => 'varchar', 'length' => 191, 'description' => 'page to link to'),
56 'tagged_count' => array('type' => 'int', 'default' => 0, 'description' => 'number of people tagged with this tag by this user'),
57 'subscriber_count' => array('type' => 'int', 'default' => 0, 'description' => 'number of subscribers to this tag'),
59 'primary key' => array('tagger', 'tag'),
60 'unique keys' => array(
61 'profile_list_id_key' => array('id')
63 'foreign keys' => array(
64 'profile_list_tagger_fkey' => array('profile', array('tagger' => 'id')),
67 'profile_list_modified_idx' => array('modified'),
68 'profile_list_tag_idx' => array('tag'),
69 'profile_list_tagger_tag_idx' => array('tagger', 'tag'),
70 'profile_list_tagged_count_idx' => array('tagged_count'),
71 'profile_list_subscriber_count_idx' => array('subscriber_count'),
77 * get the tagger of this profile_list object
79 * @return Profile the tagger
84 return Profile::getByID($this->tagger);
88 * return a string to identify this
89 * profile_list in the user interface etc.
94 function getBestName()
100 * return a uri string for this profile_list
108 if (Event::handle('StartProfiletagGetUri', array($this, &$uri))) {
109 if (!empty($this->uri)) {
112 $uri = common_local_url('profiletagbyid',
113 array('id' => $this->id, 'tagger_id' => $this->tagger));
116 Event::handle('EndProfiletagGetUri', array($this, &$uri));
121 * return a url to the homepage of this item
123 * @return String home url
129 if (Event::handle('StartUserPeopletagHomeUrl', array($this, &$url))) {
130 // normally stored in mainpage, but older ones may be null
131 if (!empty($this->mainpage)) {
132 $url = $this->mainpage;
134 $url = common_local_url('showprofiletag',
135 array('nickname' => $this->getTagger()->nickname,
136 'tag' => $this->tag));
139 Event::handle('EndUserPeopletagHomeUrl', array($this, &$url));
144 * return an immutable url for this object
146 * @return String permalink
152 if (Event::handle('StartProfiletagPermalink', array($this, &$url))) {
153 $url = common_local_url('profiletagbyid',
154 array('id' => $this->id));
156 Event::handle('EndProfiletagPermalink', array($this, &$url));
161 * Query notices by users associated with this tag,
162 * but first check the cache before hitting the DB.
164 * @param integer $offset offset
165 * @param integer $limit maximum no of results
166 * @param integer $since_id=null since this id
167 * @param integer $max_id=null maximum id in result
169 * @return Notice the query
172 function getNotices($offset, $limit, $since_id=null, $max_id=null)
174 // FIXME: Use something else than Profile::current() to avoid
175 // possible confusion between session user and queue processing.
176 $stream = new PeopletagNoticeStream($this, Profile::current());
178 return $stream->getNotices($offset, $limit, $since_id, $max_id);
182 * Get subscribers (local and remote) to this people tag
183 * Order by reverse chronology
185 * @param integer $offset offset
186 * @param integer $limit maximum no of results
187 * @param integer $since_id=null since unix timestamp
188 * @param integer $upto=null maximum unix timestamp when subscription was made
190 * @return Profile results
193 function getSubscribers($offset=0, $limit=null, $since=0, $upto=0)
195 $subs = new Profile();
198 array('id', 'profile_tag_subscription:profile_id')
200 $subs->whereAdd('profile_tag_subscription.profile_tag_id = ' . $this->id);
202 $subs->selectAdd('unix_timestamp(profile_tag_subscription.' .
203 'created) as "cursor"');
206 $subs->whereAdd('cursor > ' . $since);
210 $subs->whereAdd('cursor <= ' . $upto);
213 if ($limit != null) {
214 $subs->limit($offset, $limit);
217 $subs->orderBy('profile_tag_subscription.created DESC');
224 * Get all and only local subscribers to this people tag
225 * used for distributing notices to user inboxes.
227 * @return array ids of users
230 function getUserSubscribers()
235 if(common_config('db','quote_identifiers'))
236 $user_table = '"user"';
237 else $user_table = 'user';
241 'FROM '. $user_table .' JOIN profile_tag_subscription '.
242 'ON '. $user_table .'.id = profile_tag_subscription.profile_id ' .
243 'WHERE profile_tag_subscription.profile_tag_id = %d ';
245 $user->query(sprintf($qry, $this->id));
249 while ($user->fetch()) {
259 * Check to see if a given profile has
260 * subscribed to this people tag's timeline
262 * @param mixed $id User or Profile object or integer id
264 * @return boolean subscription status
267 function hasSubscriber($id)
269 if (!is_numeric($id)) {
273 $sub = Profile_tag_subscription::pkeyGet(array('profile_tag_id' => $this->id,
274 'profile_id' => $id));
279 * Get profiles tagged with this people tag,
280 * include modified timestamp as a "cursor" field
281 * order by descending order of modified time
283 * @param integer $offset offset
284 * @param integer $limit maximum no of results
285 * @param integer $since_id=null since unix timestamp
286 * @param integer $upto=null maximum unix timestamp when subscription was made
288 * @return Profile results
291 function getTagged($offset=0, $limit=null, $since=0, $upto=0)
293 $tagged = new Profile();
294 $tagged->joinAdd(array('id', 'profile_tag:tagged'));
297 $tagged->selectAdd('unix_timestamp(profile_tag.modified) as "cursor"');
298 $tagged->whereAdd('profile_tag.tagger = '.$this->tagger);
299 $tagged->whereAdd("profile_tag.tag = '{$this->tag}'");
302 $tagged->whereAdd('cursor > ' . $since);
306 $tagged->whereAdd('cursor <= ' . $upto);
309 if ($limit != null) {
310 $tagged->limit($offset, $limit);
313 $tagged->orderBy('profile_tag.modified DESC');
320 * Gracefully delete one or many people tags
321 * along with their members and subscriptions data
323 * @return boolean success
326 function delete($useWhere=false)
328 // force delete one item at a time.
329 if (empty($this->id)) {
331 while ($this->fetch()) {
336 Profile_tag::cleanup($this);
337 Profile_tag_subscription::cleanup($this);
339 self::blow('profile:lists:%d', $this->tagger);
341 return parent::delete($useWhere);
345 * Update a people tag gracefully
346 * also change "tag" fields in profile_tag table
348 * @param Profile_list $dataObject Object's original form
350 * @return boolean success
353 function update($dataObject=false)
355 if (!is_object($dataObject) && !$dataObject instanceof Profile_list) {
356 return parent::update($dataObject);
361 // if original tag was different
362 // check to see if the new tag already exists
363 // if not, rename the tag correctly
364 if($dataObject->tag != $this->tag || $dataObject->tagger != $this->tagger) {
365 $existing = Profile_list::getByTaggerAndTag($this->tagger, $this->tag);
366 if(!empty($existing)) {
367 // TRANS: Server exception.
368 throw new ServerException(_('The tag you are trying to rename ' .
369 'to already exists.'));
372 // XXX: allow OStatus plugin to send out profile tag
373 $result = Profile_tag::moveTag($dataObject, $this);
375 return parent::update($dataObject);
379 * return an xml string representing this people tag
380 * as the author of an atom feed
382 * @return string atom author element
385 function asAtomAuthor()
387 $xs = new XMLStringer(true);
389 $tagger = $this->getTagger();
390 $xs->elementStart('author');
391 $xs->element('name', null, '@' . $tagger->nickname . '/' . $this->tag);
392 $xs->element('uri', null, $this->permalink());
393 $xs->elementEnd('author');
395 return $xs->getString();
399 * return an xml string to represent this people tag
400 * as a noun in an activitystreams feed.
402 * @param string $element the xml tag
404 * @return string activitystreams noun
407 function asActivityNoun($element)
409 $noun = ActivityObject::fromPeopletag($this);
410 return $noun->asString('activity:' . $element);
414 * get the cached number of profiles tagged with this
415 * people tag, re-count if the argument is true.
417 * @param boolean $recount whether to ignore cache
419 * @return integer count
422 function taggedCount($recount=false)
424 $keypart = sprintf('profile_list:tagged_count:%d:%s',
428 $count = self::cacheGet($keypart);
430 if ($count === false) {
431 $tags = new Profile_tag();
433 $tags->tag = $this->tag;
434 $tags->tagger = $this->tagger;
436 $count = $tags->count('distinct tagged');
438 self::cacheSet($keypart, $count);
445 * get the cached number of profiles subscribed to this
446 * people tag, re-count if the argument is true.
448 * @param boolean $recount whether to ignore cache
450 * @return integer count
453 function subscriberCount($recount=false)
455 $keypart = sprintf('profile_list:subscriber_count:%d',
458 $count = self::cacheGet($keypart);
460 if ($count === false) {
462 $sub = new Profile_tag_subscription();
463 $sub->profile_tag_id = $this->id;
464 $count = (int) $sub->count('distinct profile_id');
466 self::cacheSet($keypart, $count);
473 * get the cached number of profiles subscribed to this
474 * people tag, re-count if the argument is true.
476 * @param boolean $recount whether to ignore cache
478 * @return integer count
481 function blowNoticeStreamCache($all=false)
483 self::blow('profile_list:notice_ids:%d', $this->id);
485 self::blow('profile_list:notice_ids:%d;last', $this->id);
490 * get the Profile_list object by the
491 * given tagger and with given tag
493 * @param integer $tagger the id of the creator profile
494 * @param integer $tag the tag
496 * @return integer count
499 static function getByTaggerAndTag($tagger, $tag)
501 $ptag = Profile_list::pkeyGet(array('tagger' => $tagger, 'tag' => $tag));
506 * create a profile_list record for a tag, tagger pair
507 * if it doesn't exist, return it.
509 * @param integer $tagger the tagger
510 * @param string $tag the tag
511 * @param string $description description
512 * @param boolean $private protected or not
514 * @return Profile_list the people tag object
517 static function ensureTag($tagger, $tag, $description=null, $private=false)
519 $ptag = Profile_list::getByTaggerAndTag($tagger, $tag);
521 if(empty($ptag->id)) {
525 'description' => $description,
526 'private' => $private
529 $new_tag = Profile_list::saveNew($args);
537 * get the maximum number of characters
538 * that can be used in the description of
541 * determined by $config['peopletag']['desclimit']
542 * if not set, falls back to $config['site']['textlimit']
544 * @return integer maximum number of characters
547 static function maxDescription()
549 $desclimit = common_config('peopletag', 'desclimit');
550 // null => use global limit (distinct from 0!)
551 if (is_null($desclimit)) {
552 $desclimit = common_config('site', 'textlimit');
558 * check if the length of given text exceeds
561 * @param string $desc the description
563 * @return boolean is the descripition too long?
566 static function descriptionTooLong($desc)
568 $desclimit = self::maxDescription();
569 return ($desclimit > 0 && !empty($desc) && (mb_strlen($desc) > $desclimit));
573 * save a new people tag, this should be always used
574 * since it makes uri, homeurl, created and modified
575 * timestamps and performs checks.
577 * @param array $fields an array with fields and their values
579 * @return mixed Profile_list on success, false on fail
581 static function saveNew(array $fields) {
584 $ptag = new Profile_list();
586 $ptag->query('BEGIN');
588 if (empty($tagger)) {
589 // TRANS: Server exception saving new tag without having a tagger specified.
590 throw new Exception(_('No tagger specified.'));
594 // TRANS: Server exception saving new tag without having a tag specified.
595 throw new Exception(_('No tag specified.'));
598 if (empty($mainpage)) {
607 if (empty($mainpage)) {
611 if (empty($description)) {
615 if (empty($private)) {
619 $ptag->tagger = $tagger;
621 $ptag->description = $description;
622 $ptag->private = $private;
624 $ptag->mainpage = $mainpage;
625 $ptag->created = common_sql_now();
626 $ptag->modified = common_sql_now();
628 $result = $ptag->insert();
631 common_log_db_error($ptag, 'INSERT', __FILE__);
632 // TRANS: Server exception saving new tag.
633 throw new ServerException(_('Could not create profile tag.'));
636 if (!isset($uri) || empty($uri)) {
637 $orig = clone($ptag);
638 $ptag->uri = common_local_url('profiletagbyid', array('id' => $ptag->id, 'tagger_id' => $ptag->tagger));
639 $result = $ptag->update($orig);
641 common_log_db_error($ptag, 'UPDATE', __FILE__);
642 // TRANS: Server exception saving new tag.
643 throw new ServerException(_('Could not set profile tag URI.'));
647 if (!isset($mainpage) || empty($mainpage)) {
648 $orig = clone($ptag);
649 $user = User::getKV('id', $ptag->tagger);
651 $ptag->mainpage = common_local_url('showprofiletag', array('tag' => $ptag->tag, 'nickname' => $user->getNickname()));
653 $ptag->mainpage = $uri; // assume this is a remote peopletag and the uri works
656 $result = $ptag->update($orig);
658 common_log_db_error($ptag, 'UPDATE', __FILE__);
659 // TRANS: Server exception saving new tag.
660 throw new ServerException(_('Could not set profile tag mainpage.'));
667 * get all items at given cursor position for api
669 * @param callback $fn a function that takes the following arguments in order:
670 * $offset, $limit, $since_id, $max_id
671 * and returns a Profile_list object after making the DB query
672 * @param array $args arguments required for $fn
673 * @param integer $cursor the cursor
674 * @param integer $count max. number of results
677 * - if cursor is 0, return empty list
678 * - if cursor is -1, get first 21 items, next_cursor = 20th prev_cursor = 0
679 * - if cursor is +ve get 22 consecutive items before starting at cursor
680 * - return items[1..20] if items[0] == cursor else return items[0..21]
681 * - prev_cursor = items[1]
682 * - next_cursor = id of the last item being returned
684 * - if cursor is -ve get 22 consecutive items after cursor starting at cursor
685 * - return items[1..20]
687 * @returns array (array (mixed items), int next_cursor, int previous_cursor)
690 // XXX: This should be in Memcached_DataObject... eventually.
692 static function getAtCursor($fn, array $args, $cursor, $count=20)
702 // if cursor is +ve fetch $count+2 items before cursor starting at cursor
704 $fn_args = array_merge($args, array(0, $count+2, 0, $max_id));
705 $list = call_user_func_array($fn, $fn_args);
706 while($list->fetch()) {
707 $items[] = clone($list);
710 if ((isset($items[0]->cursor) && $items[0]->cursor == $cursor) ||
711 $items[0]->id == $cursor) {
713 $prev_cursor = isset($items[0]->cursor) ?
714 -$items[0]->cursor : -$items[0]->id;
716 if (count($items) > $count+1) {
719 // this means the cursor item has been deleted, check to see if there are more
720 $fn_args = array_merge($args, array(0, 1, $cursor));
721 $more = call_user_func($fn, $fn_args);
722 if (!$more->fetch() || empty($more)) {
726 $prev_cursor = isset($items[0]->cursor) ?
727 -$items[0]->cursor : -$items[0]->id;
731 if (count($items)==$count+1) {
732 // this means there is a next page.
733 $next = array_pop($items);
734 $next_cursor = isset($next->cursor) ?
735 $items[$count-1]->cursor : $items[$count-1]->id;
738 } else if($cursor < -1) {
739 // if cursor is -ve fetch $count+2 items created after -$cursor-1
740 $cursor = abs($cursor);
741 $since_id = $cursor-1;
743 $fn_args = array_merge($args, array(0, $count+2, $since_id));
744 $list = call_user_func_array($fn, $fn_args);
745 while($list->fetch()) {
746 $items[] = clone($list);
749 $end = count($items)-1;
750 if ((isset($items[$end]->cursor) && $items[$end]->cursor == $cursor) ||
751 $items[$end]->id == $cursor) {
753 $next_cursor = isset($items[$end-1]->cursor) ?
754 $items[$end-1]->cursor : $items[$end-1]->id;
756 $next_cursor = isset($items[$end]->cursor) ?
757 $items[$end]->cursor : $items[$end]->id;
758 if ($end > $count) array_pop($items); // excess item.
760 // check if there are more items for next page
761 $fn_args = array_merge($args, array(0, 1, 0, $cursor));
762 $more = call_user_func_array($fn, $fn_args);
763 if (!$more->fetch() || empty($more)) {
768 if (count($items) == $count+1) {
769 // this means there is a previous page.
770 $prev = array_shift($items);
771 $prev_cursor = isset($prev->cursor) ?
772 -$items[0]->cursor : -$items[0]->id;
774 } else if($cursor == -1) {
775 $fn_args = array_merge($args, array(0, $count+1));
776 $list = call_user_func_array($fn, $fn_args);
778 while($list->fetch()) {
779 $items[] = clone($list);
782 if (count($items)==$count+1) {
783 $next = array_pop($items);
784 if(isset($next->cursor)) {
785 $next_cursor = $items[$count-1]->cursor;
787 $next_cursor = $items[$count-1]->id;
792 return array($items, $next_cursor, $prev_cursor);
796 * save a collection of people tags into the cache
798 * @param string $ckey cache key
799 * @param Profile_list &$tag the results to store
800 * @param integer $offset offset for slicing results
801 * @param integer $limit maximum number of results
803 * @return boolean success
806 static function setCache($ckey, &$tag, $offset=0, $limit=null) {
807 $cache = Cache::instance();
813 while ($tag->fetch()) {
814 $str .= $tag->tagger . ':' . $tag->tag . ';';
815 $tags[] = clone($tag);
817 $str = substr($str, 0, -1);
818 if ($offset>=0 && !is_null($limit)) {
819 $tags = array_slice($tags, $offset, $limit);
822 $tag = new ArrayWrapper($tags);
824 return self::cacheSet($ckey, $str);
828 * get people tags from the cache
830 * @param string $ckey cache key
831 * @param integer $offset offset for slicing
832 * @param integer $limit limit
834 * @return Profile_list results
837 static function getCached($ckey, $offset=0, $limit=null) {
839 $keys_str = self::cacheGet($ckey);
840 if ($keys_str === false) {
844 $pairs = explode(';', $keys_str);
846 foreach ($pairs as $pair) {
847 $keys[] = explode(':', $pair);
850 if ($offset>=0 && !is_null($limit)) {
851 $keys = array_slice($keys, $offset, $limit);
853 return self::getByKeys($keys);
857 * get Profile_list objects from the database
858 * given their (tag, tagger) key pairs.
860 * @param array $keys array of array(tagger, tag)
862 * @return Profile_list results
865 static function getByKeys(array $keys) {
866 $cache = Cache::instance();
868 if (!empty($cache)) {
871 foreach ($keys as $key) {
872 $t = Profile_list::getByTaggerAndTag($key[0], $key[1]);
877 return new ArrayWrapper($tags);
879 $tag = new Profile_list();
881 //if no IDs requested, just return the tag object
886 foreach ($keys as $key) {
887 $pairs[] = '(' . $key[0] . ', "' . $key[1] . '")';
890 $tag->whereAdd('(tagger, tag) in (' . implode(', ', $pairs) . ')');
896 while ($tag->fetch()) {
897 $temp[$tag->tagger.'-'.$tag->tag] = clone($tag);
902 foreach ($keys as $key) {
903 $id = $key[0].'-'.$key[1];
904 if (array_key_exists($id, $temp)) {
905 $wrapped[] = $temp[$id];
909 return new ArrayWrapper($wrapped);
915 $result = parent::insert();
917 self::blow('profile:lists:%d', $this->tagger);