diff --git a/classes/Notice.php b/classes/Notice.php index 157fdf2dc4..21795ae21b 100644 --- a/classes/Notice.php +++ b/classes/Notice.php @@ -429,6 +429,12 @@ class Notice extends Memcached_DataObject $notice->saveGroups(); } + if (isset($peopletags)) { + $notice->saveProfileTags($peopletags); + } else { + $notice->saveProfileTags(); + } + if (isset($urls)) { $notice->saveKnownUrls($urls); } else { @@ -797,6 +803,7 @@ class Notice extends Memcached_DataObject } $users = $this->getSubscribedUsers(); + $ptags = $this->getProfileTags(); // FIXME: kind of ignoring 'transitional'... // we'll probably stop supporting inboxless mode @@ -817,6 +824,18 @@ class Notice extends Memcached_DataObject } } + foreach ($ptags as $ptag) { + $users = $ptag->getUserSubscribers(); + foreach ($users as $id) { + if (!array_key_exists($id, $ni)) { + $user = User::staticGet('id', $id); + if (!$user->hasBlocked($profile)) { + $ni[$id] = NOTICE_INBOX_SOURCE_PROFILE_TAG; + } + } + } + } + foreach ($recipients as $recipient) { if (!array_key_exists($recipient, $ni)) { $ni[$recipient] = NOTICE_INBOX_SOURCE_REPLY; @@ -913,6 +932,39 @@ class Notice extends Memcached_DataObject return $ids; } + function getProfileTags() + { + // Don't save ptags for repeats, for now. + + if (!empty($this->repeat_of)) { + return array(); + } + + // XXX: cache me + + $ptags = array(); + + $ptagi = new Profile_tag_inbox(); + + $ptagi->selectAdd(); + $ptagi->selectAdd('profile_tag_id'); + + $ptagi->notice_id = $this->id; + + if ($ptagi->find()) { + while ($ptagi->fetch()) { + $profile_list = Profile_list::staticGet('id', $ptagi->profile_tag_id); + if ($profile_list) { + $ptags[] = $profile_list; + } + } + } + + $ptagi->free(); + + return $ptags; + } + /** * Record this notice to the given group inboxes for delivery. * Overrides the regular parsing of !group markup. @@ -1034,6 +1086,69 @@ class Notice extends Memcached_DataObject return true; } + /** + * record targets into profile_tag_inbox. + * @return array of Profile_list objects + */ + function saveProfileTags($known=array()) + { + // Don't save ptags for repeats, for now + + if (!empty($this->repeat_of)) { + return array(); + } + + if (is_array($known)) { + $ptags = $known; + } else { + $ptags = array(); + } + + $ptag = new Profile_tag(); + $ptag->tagged = $this->profile_id; + + if($ptag->find()) { + while($ptag->fetch()) { + $plist = Profile_list::getByTaggerAndTag($ptag->tagger, $ptag->tag); + $ptags[] = clone($plist); + } + } + + foreach ($ptags as $target) { + $this->addToProfileTagInbox($target); + } + + return $ptags; + } + + function addToProfileTagInbox($plist) + { + $ptagi = Profile_tag_inbox::pkeyGet(array('profile_tag_id' => $plist->id, + 'notice_id' => $this->id)); + + if (empty($ptagi)) { + + $ptagi = new Profile_tag_inbox(); + + $ptagi->query('BEGIN'); + $ptagi->profile_tag_id = $plist->id; + $ptagi->notice_id = $this->id; + $ptagi->created = $this->created; + + $result = $ptagi->insert(); + if (!$result) { + common_log_db_error($ptagi, 'INSERT', __FILE__); + throw new ServerException(_('Problem saving profile_tag inbox.')); + } + + $ptagi->query('COMMIT'); + + self::blow('profile_tag:notice_ids:%d', $ptagi->profile_tag_id); + } + + return true; + } + /** * Save reply records indicating that this notice needs to be * delivered to the local users with the given URIs. diff --git a/classes/Profile.php b/classes/Profile.php index 88edf5cbb3..963b41e0d4 100644 --- a/classes/Profile.php +++ b/classes/Profile.php @@ -339,6 +339,183 @@ class Profile extends Memcached_DataObject return $groups; } + function isTagged($peopletag) + { + $tag = Profile_tag::pkeyGet(array('tagger' => $peopletag->tagger, + 'tagged' => $this->id, + 'tag' => $peopletag->tag)); + return !empty($tag); + } + + function canTag($tagged) + { + if (empty($tagged)) { + return false; + } + + if ($tagged->id == $this->id) { + return true; + } + + $all = common_config('peopletag', 'allow_tagging', 'all'); + $local = common_config('peopletag', 'allow_tagging', 'local'); + $remote = common_config('peopletag', 'allow_tagging', 'remote'); + $subs = common_config('peopletag', 'allow_tagging', 'subs'); + + if ($all) { + return true; + } + + $tagged_user = $tagged->getUser(); + if (!empty($tagged_user)) { + if ($local) { + return true; + } + } else if ($subs) { + return (Subscription::exists($this, $tagged) || + Subscription::exists($tagged, $this)); + } else if ($remote) { + return true; + } + return false; + } + + function getOwnedTags($auth_user, $offset=0, $limit=null, $since_id=0, $max_id=0) + { + $tags = new Profile_list(); + $tags->tagger = $this->id; + + if (($auth_user instanceof User || $auth_user instanceof Profile) && + $auth_user->id === $this->id) { + // no condition, get both private and public tags + } else { + $tags->private = false; + } + + $tags->selectAdd('id as "cursor"'); + + if ($since_id>0) { + $tags->whereAdd('id > '.$since_id); + } + + if ($max_id>0) { + $tags->whereAdd('id <= '.$max_id); + } + + if($offset>=0 && !is_null($limit)) { + $tags->limit($offset, $limit); + } + + $tags->orderBy('id DESC'); + $tags->find(); + + return $tags; + } + + function getOtherTags($auth_user=null, $offset=0, $limit=null, $since_id=0, $max_id=0) + { + $lists = new Profile_list(); + + $tags = new Profile_tag(); + $tags->tagged = $this->id; + + $lists->joinAdd($tags); + #@fixme: postgres (round(date_part('epoch', my_date))) + $lists->selectAdd('unix_timestamp(profile_tag.modified) as "cursor"'); + + if ($auth_user instanceof User || $auth_user instanceof Profile) { + $lists->whereAdd('( ( profile_list.private = false ) ' . + 'OR ( profile_list.tagger = ' . $auth_user->id . ' AND ' . + 'profile_list.private = true ) )'); + } else { + $lists->private = false; + } + + if ($since_id>0) { + $lists->whereAdd('cursor > '.$since_id); + } + + if ($max_id>0) { + $lists->whereAdd('cursor <= '.$max_id); + } + + if($offset>=0 && !is_null($limit)) { + $lists->limit($offset, $limit); + } + + $lists->orderBy('profile_tag.modified DESC'); + $lists->find(); + + return $lists; + } + + function getPrivateTags($offset=0, $limit=null, $since_id=0, $max_id=0) + { + $tags = new Profile_list(); + $tags->private = true; + $tags->tagger = $this->id; + + if ($since_id>0) { + $tags->whereAdd('id > '.$since_id); + } + + if ($max_id>0) { + $tags->whereAdd('id <= '.$max_id); + } + + if($offset>=0 && !is_null($limit)) { + $tags->limit($offset, $limit); + } + + $tags->orderBy('id DESC'); + $tags->find(); + + return $tags; + } + + function hasLocalTags() + { + $tags = new Profile_tag(); + + $tags->joinAdd(array('tagger', 'user:id')); + $tags->whereAdd('tagged = '.$this->id); + $tags->whereAdd('tagger != '.$this->id); + + $tags->limit(0, 1); + $tags->fetch(); + + return ($tags->N == 0) ? false : true; + } + + function getTagSubscriptions($offset=0, $limit=null, $since_id=0, $max_id=0) + { + $lists = new Profile_list(); + $subs = new Profile_tag_subscription(); + + $lists->joinAdd($subs); + #@fixme: postgres (round(date_part('epoch', my_date))) + $lists->selectAdd('unix_timestamp(profile_tag_subscription.created) as "cursor"'); + + $lists->whereAdd('profile_tag_subscription.profile_id = '.$this->id); + + if ($since_id>0) { + $lists->whereAdd('cursor > '.$since_id); + } + + if ($max_id>0) { + $lists->whereAdd('cursor <= '.$max_id); + } + + if($offset>=0 && !is_null($limit)) { + $lists->limit($offset, $limit); + } + + $lists->orderBy('"cursor" DESC'); + $lists->find(); + + return $lists; + } + function avatarUrl($size=AVATAR_PROFILE_SIZE) { $avatar = $this->getAvatar($size); @@ -385,6 +562,32 @@ class Profile extends Memcached_DataObject return new ArrayWrapper($profiles); } + + function getTaggedSubscribers($tag) + { + $qry = + 'SELECT profile.* ' . + 'FROM profile JOIN (subscription, profile_tag, profile_list) ' . + 'ON profile.id = subscription.subscriber ' . + 'AND profile.id = profile_tag.tagged ' . + 'AND profile_tag.tagger = profile_list.tagger AND profile_tag.tag = profile_list.tag ' . + 'WHERE subscription.subscribed = %d ' . + 'AND subscription.subscribed != subscription.subscriber ' . + 'AND profile_tag.tagger = %d AND profile_tag.tag = "%s" ' . + 'AND profile_list.private = false ' . + 'ORDER BY subscription.created DESC'; + + $profile = new Profile(); + $tagged = array(); + + $cnt = $profile->query(sprintf($qry, $this->id, $this->id, $tag)); + + while ($profile->fetch()) { + $tagged[] = clone($profile); + } + return $tagged; + } + function subscriptionCount() { $c = Cache::instance(); diff --git a/classes/Profile_list.php b/classes/Profile_list.php new file mode 100644 index 0000000000..df7bef5201 --- /dev/null +++ b/classes/Profile_list.php @@ -0,0 +1,929 @@ +. + * + * @category Notices + * @package StatusNet + * @author Shashi Gowda + * @license GNU Affero General Public License http://www.gnu.org/licenses/ + */ + +if (!defined('STATUSNET') && !defined('LACONICA')) { + exit(1); +} + +/** + * Table Definition for profile_list + */ +require_once INSTALLDIR.'/classes/Memcached_DataObject.php'; + +class Profile_list extends Memcached_DataObject +{ + ###START_AUTOCODE + /* the code below is auto generated do not remove the above tag */ + + public $__table = 'profile_list'; // table name + public $id; // int(4) primary_key not_null + public $tagger; // int(4) + public $tag; // varchar(64) + public $description; // text + public $private; // tinyint(1) + public $created; // datetime not_null default_0000-00-00%2000%3A00%3A00 + public $modified; // timestamp not_null default_CURRENT_TIMESTAMP + public $uri; // varchar(255) unique_key + public $mainpage; // varchar(255) + public $tagged_count; // smallint + public $subscriber_count; // smallint + + /* Static get */ + function staticGet($k,$v=NULL) { return DB_DataObject::staticGet('Profile_list',$k,$v); } + + /* the code above is auto generated do not remove the tag below */ + ###END_AUTOCODE + + /** + * return a profile_list record, given its tag and tagger. + * + * @param array $kv ideally array('tag' => $tag, 'tagger' => $tagger) + * + * @return Profile_list a Profile_list object with the given tag and tagger. + */ + + function pkeyGet($kv) + { + return Memcached_DataObject::pkeyGet('Profile_list', $kv); + } + + /** + * get the tagger of this profile_list object + * + * @return Profile the tagger + */ + + function getTagger() + { + return Profile::staticGet('id', $this->tagger); + } + + /** + * return a string to identify this + * profile_list in the user interface etc. + * + * @return String + */ + + function getBestName() + { + return $this->tag; + } + + /** + * return a uri string for this profile_list + * + * @return String uri + */ + + function getUri() + { + $uri = null; + if (Event::handle('StartProfiletagGetUri', array($this, &$uri))) { + if (!empty($this->uri)) { + $uri = $this->uri; + } else { + $uri = common_local_url('profiletagbyid', + array('id' => $this->id, 'tagger_id' => $this->tagger)); + } + } + Event::handle('EndProfiletagGetUri', array($this, &$uri)); + return $uri; + } + + /** + * return a url to the homepage of this item + * + * @return String home url + */ + + function homeUrl() + { + $url = null; + if (Event::handle('StartUserPeopletagHomeUrl', array($this, &$url))) { + // normally stored in mainpage, but older ones may be null + if (!empty($this->mainpage)) { + $url = $this->mainpage; + } else { + $url = common_local_url('showprofiletag', + array('tagger' => $this->getTagger()->nickname, + 'tag' => $this->tag)); + } + } + Event::handle('EndUserPeopletagHomeUrl', array($this, &$url)); + return $url; + } + + /** + * return an immutable url for this object + * + * @return String permalink + */ + + function permalink() + { + $url = null; + if (Event::handle('StartProfiletagPermalink', array($this, &$url))) { + $url = common_local_url('profiletagbyid', + array('id' => $this->id)); + } + Event::handle('EndProfiletagPermalink', array($this, &$url)); + return $url; + } + + /** + * Query notices by users associated with this tag, + * but first check the cache before hitting the DB. + * + * @param integer $offset offset + * @param integer $limit maximum no of results + * @param integer $since_id=null since this id + * @param integer $max_id=null maximum id in result + * + * @return Notice the query + */ + + function getNotices($offset, $limit, $since_id=null, $max_id=null) + { + $ids = Notice::stream(array($this, '_streamDirect'), + array(), + 'profile_tag:notice_ids:' . $this->id, + $offset, $limit, $since_id, $max_id); + + return Notice::getStreamByIds($ids); + } + + /** + * Query notices by users associated with this tag from the database. + * + * @param integer $offset offset + * @param integer $limit maximum no of results + * @param integer $since_id=null since this id + * @param integer $max_id=null maximum id in result + * + * @return array array of notice ids. + */ + + function _streamDirect($offset, $limit, $since_id, $max_id) + { + $inbox = new Profile_tag_inbox(); + + $inbox->profile_tag_id = $this->id; + + $inbox->selectAdd(); + $inbox->selectAdd('notice_id'); + + if ($since_id != 0) { + $inbox->whereAdd('notice_id > ' . $since_id); + } + + if ($max_id != 0) { + $inbox->whereAdd('notice_id <= ' . $max_id); + } + + $inbox->orderBy('notice_id DESC'); + + if (!is_null($offset)) { + $inbox->limit($offset, $limit); + } + + $ids = array(); + + if ($inbox->find()) { + while ($inbox->fetch()) { + $ids[] = $inbox->notice_id; + } + } + + return $ids; + } + + /** + * Get subscribers (local and remote) to this people tag + * Order by reverse chronology + * + * @param integer $offset offset + * @param integer $limit maximum no of results + * @param integer $since_id=null since unix timestamp + * @param integer $upto=null maximum unix timestamp when subscription was made + * + * @return Profile results + */ + + function getSubscribers($offset=0, $limit=null, $since=0, $upto=0) + { + $subs = new Profile(); + $sub = new Profile_tag_subscription(); + $sub->profile_tag_id = $this->id; + + $subs->joinAdd($sub); + $subs->selectAdd('unix_timestamp(profile_tag_subscription.' . + 'created) as "cursor"'); + + if ($since != 0) { + $subs->whereAdd('cursor > ' . $since); + } + + if ($upto != 0) { + $subs->whereAdd('cursor <= ' . $upto); + } + + if ($limit != null) { + $subs->limit($offset, $limit); + } + + $subs->orderBy('profile_tag_subscription.created DESC'); + $subs->find(); + + return $subs; + } + + /** + * Get all and only local subscribers to this people tag + * used for distributing notices to user inboxes. + * + * @return array ids of users + */ + + function getUserSubscribers() + { + // XXX: cache this + + $user = new User(); + if(common_config('db','quote_identifiers')) + $user_table = '"user"'; + else $user_table = 'user'; + + $qry = + 'SELECT id ' . + 'FROM '. $user_table .' JOIN profile_tag_subscription '. + 'ON '. $user_table .'.id = profile_tag_subscription.profile_id ' . + 'WHERE profile_tag_subscription.profile_tag_id = %d '; + + $user->query(sprintf($qry, $this->id)); + + $ids = array(); + + while ($user->fetch()) { + $ids[] = $user->id; + } + + $user->free(); + + return $ids; + } + + /** + * Check to see if a given profile has + * subscribed to this people tag's timeline + * + * @param mixed $id User or Profile object or integer id + * + * @return boolean subscription status + */ + + function hasSubscriber($id) + { + if (!is_numeric($id)) { + $id = $id->id; + } + + $sub = Profile_tag_subscription::pkeyGet(array('profile_tag_id' => $this->id, + 'profile_id' => $id)); + return !empty($sub); + } + + /** + * Get profiles tagged with this people tag, + * include modified timestamp as a "cursor" field + * order by descending order of modified time + * + * @param integer $offset offset + * @param integer $limit maximum no of results + * @param integer $since_id=null since unix timestamp + * @param integer $upto=null maximum unix timestamp when subscription was made + * + * @return Profile results + */ + + function getTagged($offset=0, $limit=null, $since=0, $upto=0) + { + $tagged = new Profile(); + $tagged->joinAdd(array('id', 'profile_tag:tagged')); + + #@fixme: postgres + $tagged->selectAdd('unix_timestamp(profile_tag.modified) as "cursor"'); + $tagged->whereAdd('profile_tag.tagger = '.$this->tagger); + $tagged->whereAdd("profile_tag.tag = '{$this->tag}'"); + + if ($since != 0) { + $tagged->whereAdd('cursor > ' . $since); + } + + if ($upto != 0) { + $tagged->whereAdd('cursor <= ' . $upto); + } + + if ($limit != null) { + $tagged->limit($offset, $limit); + } + + $tagged->orderBy('profile_tag.modified DESC'); + $tagged->find(); + + return $tagged; + } + + /** + * Gracefully delete one or many people tags + * along with their members and subscriptions data + * + * @return boolean success + */ + + function delete() + { + // force delete one item at a time. + if (empty($this->id)) { + $this->find(); + while ($this->fetch()) { + $this->delete(); + } + } + + Profile_tag::cleanup($this); + Profile_tag_subscription::cleanup($this); + + return parent::delete(); + } + + /** + * Update a people tag gracefully + * also change "tag" fields in profile_tag table + * + * @param Profile_list $orig Object's original form + * + * @return boolean success + */ + + function update($orig=null) + { + $result = true; + + if (!is_object($orig) && !$orig instanceof Profile_list) { + parent::update($orig); + } + + // if original tag was different + // check to see if the new tag already exists + // if not, rename the tag correctly + if($orig->tag != $this->tag || $orig->tagger != $this->tagger) { + $existing = Profile_list::getByTaggerAndTag($this->tagger, $this->tag); + if(!empty($existing)) { + throw new ServerException(_('The tag you are trying to rename ' . + 'to already exists.')); + } + // move the tag + // XXX: allow OStatus plugin to send out profile tag + $result = Profile_tag::moveTag($orig, $this); + } + parent::update($orig); + return $result; + } + + /** + * return an xml string representing this people tag + * as the author of an atom feed + * + * @return string atom author element + */ + + function asAtomAuthor() + { + $xs = new XMLStringer(true); + + $tagger = $this->getTagger(); + $xs->elementStart('author'); + $xs->element('name', null, '@' . $tagger->nickname . '/' . $this->tag); + $xs->element('uri', null, $this->permalink()); + $xs->elementEnd('author'); + + return $xs->getString(); + } + + /** + * return an xml string to represent this people tag + * as the subject of an activitystreams feed. + * + * @return string activitystreams subject + */ + + function asActivitySubject() + { + return $this->asActivityNoun('subject'); + } + + /** + * return an xml string to represent this people tag + * as a noun in an activitystreams feed. + * + * @param string $element the xml tag + * + * @return string activitystreams noun + */ + + function asActivityNoun($element) + { + $noun = ActivityObject::fromPeopletag($this); + return $noun->asString('activity:' . $element); + } + + /** + * get the cached number of profiles tagged with this + * people tag, re-count if the argument is true. + * + * @param boolean $recount whether to ignore cache + * + * @return integer count + */ + + function taggedCount($recount=false) + { + if (!$recount) { + return $this->tagged_count; + } + + $tags = new Profile_tag(); + $tags->tag = $this->tag; + $tags->tagger = $this->tagger; + $orig = clone($this); + $this->tagged_count = (int) $tags->count('distinct tagged'); + $this->update($orig); + + return $this->tagged_count; + } + + /** + * get the cached number of profiles subscribed to this + * people tag, re-count if the argument is true. + * + * @param boolean $recount whether to ignore cache + * + * @return integer count + */ + + function subscriberCount($recount=false) + { + if ($recount) { + return $this->subscriber_count; + } + + $sub = new Profile_tag_subscription(); + $sub->profile_tag_id = $this->id; + $orig = clone($this); + $this->subscriber_count = (int) $sub->count('distinct profile_id'); + $this->update($orig); + + return $this->subscriber_count; + } + + /** + * get the Profile_list object by the + * given tagger and with given tag + * + * @param integer $tagger the id of the creator profile + * @param integer $tag the tag + * + * @return integer count + */ + + static function getByTaggerAndTag($tagger, $tag) + { + $ptag = Profile_list::pkeyGet(array('tagger' => $tagger, 'tag' => $tag)); + return $ptag; + } + + /** + * create a profile_list record for a tag, tagger pair + * if it doesn't exist, return it. + * + * @param integer $tagger the tagger + * @param string $tag the tag + * @param string $description description + * @param boolean $private protected or not + * + * @return Profile_list the people tag object + */ + + static function ensureTag($tagger, $tag, $description=null, $private=false) + { + $ptag = Profile_list::getByTaggerAndTag($tagger, $tag); + + if(empty($ptag->id)) { + $args = array( + 'tag' => $tag, + 'tagger' => $tagger, + 'description' => $description, + 'private' => $private + ); + + $new_tag = Profile_list::saveNew($args); + + return $new_tag; + } + return $ptag; + } + + /** + * get the maximum number of characters + * that can be used in the description of + * a people tag. + * + * determined by $config['peopletag']['desclimit'] + * if not set, falls back to $config['site']['textlimit'] + * + * @return integer maximum number of characters + */ + + static function maxDescription() + { + $desclimit = common_config('peopletag', 'desclimit'); + // null => use global limit (distinct from 0!) + if (is_null($desclimit)) { + $desclimit = common_config('site', 'textlimit'); + } + return $desclimit; + } + + /** + * check if the length of given text exceeds + * character limit. + * + * @param string $desc the description + * + * @return boolean is the descripition too long? + */ + + static function descriptionTooLong($desc) + { + $desclimit = self::maxDescription(); + return ($desclimit > 0 && !empty($desc) && (mb_strlen($desc) > $desclimit)); + } + + /** + * save a new people tag, this should be always used + * since it makes uri, homeurl, created and modified + * timestamps and performs checks. + * + * @param array $fields an array with fields and their values + * + * @return mixed Profile_list on success, false on fail + */ + static function saveNew($fields) { + + extract($fields); + + $ptag = new Profile_list(); + + $ptag->query('BEGIN'); + + if (empty($tagger)) { + throw new Exception(_('No tagger specified.')); + } + + if (empty($tag)) { + throw new Exception(_('No tag specified.')); + } + + if (empty($mainpage)) { + $mainpage = null; + } + + if (empty($uri)) { + // fill in later... + $uri = null; + } + + if (empty($mainpage)) { + $mainpage = null; + } + + if (empty($description)) { + $description = null; + } + + if (empty($private)) { + $private = false; + } + + $ptag->tagger = $tagger; + $ptag->tag = $tag; + $ptag->description = $description; + $ptag->private = $private; + $ptag->uri = $uri; + $ptag->mainpage = $mainpage; + $ptag->created = common_sql_now(); + $ptag->modified = common_sql_now(); + + $result = $ptag->insert(); + + if (!$result) { + common_log_db_error($ptag, 'INSERT', __FILE__); + throw new ServerException(_('Could not create profile tag.')); + } + + if (!isset($uri) || empty($uri)) { + $orig = clone($ptag); + $ptag->uri = common_local_url('profiletagbyid', array('id' => $ptag->id, 'tagger_id' => $ptag->tagger)); + $result = $ptag->update($orig); + if (!$result) { + common_log_db_error($ptag, 'UPDATE', __FILE__); + throw new ServerException(_('Could not set profile tag URI.')); + } + } + + if (!isset($mainpage) || empty($mainpage)) { + $orig = clone($ptag); + $user = User::staticGet('id', $ptag->tagger); + if(!empty($user)) { + $ptag->mainpage = common_local_url('showprofiletag', array('tag' => $ptag->tag, 'tagger' => $user->nickname)); + } else { + $ptag->mainpage = $uri; // assume this is a remote peopletag and the uri works + } + + $result = $ptag->update($orig); + if (!$result) { + common_log_db_error($ptag, 'UPDATE', __FILE__); + throw new ServerException(_('Could not set profile tag mainpage.')); + } + } + return $ptag; + } + + /** + * get all items at given cursor position for api + * + * @param callback $fn a function that takes the following arguments in order: + * $offset, $limit, $since_id, $max_id + * and returns a Profile_list object after making the DB query + * @param array $args arguments required for $fn + * @param integer $cursor the cursor + * @param integer $count max. number of results + * + * Algorithm: + * - if cursor is 0, return empty list + * - if cursor is -1, get first 21 items, next_cursor = 20th prev_cursor = 0 + * - if cursor is +ve get 22 consecutive items before starting at cursor + * - return items[1..20] if items[0] == cursor else return items[0..21] + * - prev_cursor = items[1] + * - next_cursor = id of the last item being returned + * + * - if cursor is -ve get 22 consecutive items after cursor starting at cursor + * - return items[1..20] + * + * @returns array (array (mixed items), int next_cursor, int previous_cursor) + */ + + // XXX: This should be in Memcached_DataObject... eventually. + + static function getAtCursor($fn, $args, $cursor, $count=20) + { + $items = array(); + + $since_id = 0; + $max_id = 0; + $next_cursor = 0; + $prev_cursor = 0; + + if($cursor > 0) { + // if cursor is +ve fetch $count+2 items before cursor starting at cursor + $max_id = $cursor; + $fn_args = array_merge($args, array(0, $count+2, 0, $max_id)); + $list = call_user_func_array($fn, $fn_args); + while($list->fetch()) { + $items[] = clone($list); + } + + if ((isset($items[0]->cursor) && $items[0]->cursor == $cursor) || + $items[0]->id == $cursor) { + array_shift($items); + $prev_cursor = isset($items[0]->cursor) ? + -$items[0]->cursor : -$items[0]->id; + } else { + if (count($items) > $count+1) { + array_shift($items); + } + // this means the cursor item has been deleted, check to see if there are more + $fn_args = array_merge($args, array(0, 1, $cursor)); + $more = call_user_func($fn, $fn_args); + if (!$more->fetch() || empty($more)) { + // no more items. + $prev_cursor = 0; + } else { + $prev_cursor = isset($items[0]->cursor) ? + -$items[0]->cursor : -$items[0]->id; + } + } + + if (count($items)==$count+1) { + // this means there is a next page. + $next = array_pop($items); + $next_cursor = isset($next->cursor) ? + $items[$count-1]->cursor : $items[$count-1]->id; + } + + } else if($cursor < -1) { + // if cursor is -ve fetch $count+2 items created after -$cursor-1 + $cursor = abs($cursor); + $since_id = $cursor-1; + + $fn_args = array_merge($args, array(0, $count+2, $since_id)); + $list = call_user_func_array($fn, $fn_args); + while($list->fetch()) { + $items[] = clone($list); + } + + $end = count($items)-1; + if ((isset($items[$end]->cursor) && $items[$end]->cursor == $cursor) || + $items[$end]->id == $cursor) { + array_pop($items); + $next_cursor = isset($items[$end-1]->cursor) ? + $items[$end-1]->cursor : $items[$end-1]->id; + } else { + $next_cursor = isset($items[$end]->cursor) ? + $items[$end]->cursor : $items[$end]->id; + if ($end > $count) array_pop($items); // excess item. + + // check if there are more items for next page + $fn_args = array_merge($args, array(0, 1, 0, $cursor)); + $more = call_user_func_array($fn, $fn_args); + if (!$more->fetch() || empty($more)) { + $next_cursor = 0; + } + } + + if (count($items) == $count+1) { + // this means there is a previous page. + $prev = array_shift($items); + $prev_cursor = isset($prev->cursor) ? + -$items[0]->cursor : -$items[0]->id; + } + } else if($cursor == -1) { + $fn_args = array_merge($args, array(0, $count+1)); + $list = call_user_func_array($fn, $fn_args); + + while($list->fetch()) { + $items[] = clone($list); + } + + if (count($items)==$count+1) { + $next = array_pop($items); + if(isset($next->cursor)) { + $next_cursor = $items[$count-1]->cursor; + } else { + $next_cursor = $items[$count-1]->id; + } + } + + } + return array($items, $next_cursor, $prev_cursor); + } + + /** + * save a collection of people tags into the cache + * + * @param string $ckey cache key + * @param Profile_list &$tag the results to store + * @param integer $offset offset for slicing results + * @param integer $limit maximum number of results + * + * @return boolean success + */ + + static function setCache($ckey, &$tag, $offset=0, $limit=null) { + $cache = Cache::instance(); + if (empty($cache)) { + return false; + } + $str = ''; + $tags = array(); + while ($tag->fetch()) { + $str .= $tag->tagger . ':' . $tag->tag . ';'; + $tags[] = clone($tag); + } + $str = substr($str, 0, -1); + if ($offset>=0 && !is_null($limit)) { + $tags = array_slice($tags, $offset, $limit); + } + + $tag = new ArrayWrapper($tags); + + return self::cacheSet($ckey, $str); + } + + /** + * get people tags from the cache + * + * @param string $ckey cache key + * @param integer $offset offset for slicing + * @param integer $limit limit + * + * @return Profile_list results + */ + + static function getCached($ckey, $offset=0, $limit=null) { + + $keys_str = self::cacheGet($ckey); + if ($keys_str === false) { + return false; + } + + $pairs = explode(';', $keys_str); + $keys = array(); + foreach ($pairs as $pair) { + $keys[] = explode(':', $pair); + } + + if ($offset>=0 && !is_null($limit)) { + $keys = array_slice($keys, $offset, $limit); + } + return self::getByKeys($keys); + } + + /** + * get Profile_list objects from the database + * given their (tag, tagger) key pairs. + * + * @param array $keys array of array(tagger, tag) + * + * @return Profile_list results + */ + + static function getByKeys($keys) { + $cache = Cache::instance(); + + if (!empty($cache)) { + $tags = array(); + + foreach ($keys as $key) { + $t = Profile_list::getByTaggerAndTag($key[0], $key[1]); + if (!empty($t)) { + $tags[] = $t; + } + } + return new ArrayWrapper($tags); + } else { + $tag = new Profile_list(); + if (empty($keys)) { + //if no IDs requested, just return the tag object + return $tag; + } + + $pairs = array(); + foreach ($keys as $key) { + $pairs[] = '(' . $key[0] . ', "' . $key[1] . '")'; + } + + $tag->whereAdd('(tagger, tag) in (' . implode(', ', $pairs) . ')'); + + $tag->find(); + + $temp = array(); + + while ($tag->fetch()) { + $temp[$tag->tagger.'-'.$tag->tag] = clone($tag); + } + + $wrapped = array(); + + foreach ($keys as $key) { + $id = $key[0].'-'.$key[1]; + if (array_key_exists($id, $temp)) { + $wrapped[] = $temp[$id]; + } + } + + return new ArrayWrapper($wrapped); + } + } +} diff --git a/classes/Profile_tag.php b/classes/Profile_tag.php index ab6bab0964..183f79145a 100644 --- a/classes/Profile_tag.php +++ b/classes/Profile_tag.php @@ -22,31 +22,91 @@ class Profile_tag extends Memcached_DataObject /* the code above is auto generated do not remove the tag below */ ###END_AUTOCODE - static function getTags($tagger, $tagged) { - $tags = array(); + function pkeyGet($kv) { + return Memcached_DataObject::pkeyGet('Profile_tag', $kv); + } - # XXX: store this in memcached + function links() + { + return array('tagger,tag' => 'profile_list:tagger,tag'); + } - $profile_tag = new Profile_tag(); - $profile_tag->tagger = $tagger; - $profile_tag->tagged = $tagged; + function getMeta() + { + return Profile_list::pkeyGet(array('tagger' => $this->tagger, 'tag' => $this->tag)); + } - $profile_tag->find(); + static function getTags($tagger, $tagged, $auth_user=null) { - while ($profile_tag->fetch()) { - $tags[] = $profile_tag->tag; + $profile_list = new Profile_list(); + $include_priv = 1; + + if (!($auth_user instanceof User || + $auth_user instanceof Profile) || + ($auth_user->id !== $tagger)) { + + $profile_list->private = false; + $include_priv = 0; } - $profile_tag->free(); + $key = sprintf('profile_tag:tagger_tagged_privacy:%d-%d-%d', $tagger, $tagged, $include_priv); + $tags = Profile_list::getCached($key); + if ($tags !== false) { + return $tags; + } + + $profile_tag = new Profile_tag(); + $profile_list->tagger = $tagger; + $profile_tag->tagged = $tagged; + + $profile_list->selectAdd(); + + // only fetch id, tag, mainpage and + // private hoping this will be faster + $profile_list->selectAdd('profile_list.id, ' . + 'profile_list.tag, ' . + 'profile_list.mainpage, ' . + 'profile_list.private'); + $profile_list->joinAdd($profile_tag); + $profile_list->find(); + + Profile_list::setCache($key, $profile_list); + + return $profile_list; + } + + static function getTagsArray($tagger, $tagged, $auth_user_id=null) + { + $ptag = new Profile_tag(); + $ptag->tagger = $tagger; + $ptag->tagged = $tagged; + + if ($tagger != $auth_user_id) { + $list = new Profile_list(); + $list->private = false; + $ptag->joinAdd($list); + $ptag->selectAdd(); + $ptag->selectAdd('profile_tag.tag'); + } + + $tags = array(); + $ptag->find(); + while ($ptag->fetch()) { + $tags[] = $ptag->tag; + } + $ptag->free(); return $tags; } - static function setTags($tagger, $tagged, $newtags) { - $newtags = array_unique($newtags); - $oldtags = Profile_tag::getTags($tagger, $tagged); + static function setTags($tagger, $tagged, $newtags, $privacy=array()) { - # Delete stuff that's old that not in new + $newtags = array_unique($newtags); + $oldtags = self::getTagsArray($tagger, $tagged, $tagger); + + $ptag = new Profile_tag(); + + # Delete stuff that's in old and not in new $to_delete = array_diff($oldtags, $newtags); @@ -54,48 +114,161 @@ class Profile_tag extends Memcached_DataObject $to_insert = array_diff($newtags, $oldtags); - $profile_tag = new Profile_tag(); - - $profile_tag->tagger = $tagger; - $profile_tag->tagged = $tagged; - - $profile_tag->query('BEGIN'); - foreach ($to_delete as $deltag) { - $profile_tag->tag = $deltag; - $result = $profile_tag->delete(); - if (!$result) { - common_log_db_error($profile_tag, 'DELETE', __FILE__); - return false; - } + self::unTag($tagger, $tagged, $deltag); } foreach ($to_insert as $instag) { - $profile_tag->tag = $instag; - $result = $profile_tag->insert(); - if (!$result) { - common_log_db_error($profile_tag, 'INSERT', __FILE__); - return false; - } + $private = isset($privacy[$instag]) ? $privacy[$instag] : false; + self::setTag($tagger, $tagged, $instag, null, $private); } - - $profile_tag->query('COMMIT'); - return true; } - # Return profiles with a given tag - static function getTagged($tagger, $tag) { - $profile = new Profile(); - $profile->query('SELECT profile.* ' . - 'FROM profile JOIN profile_tag ' . - 'ON profile.id = profile_tag.tagged ' . - 'WHERE profile_tag.tagger = ' . $tagger . ' ' . - 'AND profile_tag.tag = "' . $tag . '" '); - $tagged = array(); - while ($profile->fetch()) { - $tagged[] = clone($profile); + # set a single tag + static function setTag($tagger, $tagged, $tag, $desc=null, $private=false) { + + $ptag = Profile_tag::pkeyGet(array('tagger' => $tagger, + 'tagged' => $tagged, + 'tag' => $tag)); + + # if tag already exists, return it + if(!empty($ptag)) { + return $ptag; } - return $tagged; + + $tagger_profile = Profile::staticGet('id', $tagger); + $tagged_profile = Profile::staticGet('id', $tagged); + + if (Event::handle('StartTagProfile', array($tagger_profile, $tagged_profile, $tag))) { + + if (!$tagger_profile->canTag($tagged_profile)) { + throw new ClientException(_('You cannot tag this user.')); + return false; + } + + $tags = new Profile_list(); + $tags->tagger = $tagger; + $count = (int) $tags->count('distinct tag'); + + if ($count >= common_config('peopletag', 'maxtags')) { + throw new ClientException(sprintf(_('You already have created %d or more tags ' . + 'which is the maximum allowed number of tags. ' . + 'Try using or deleting some existing tags.'), + common_config('peopletag', 'maxtags'))); + return false; + } + + $plist = new Profile_list(); + $plist->query('BEGIN'); + + $profile_list = Profile_list::ensureTag($tagger, $tag, $desc, $private); + + if ($profile_list->taggedCount() >= common_config('peopletag', 'maxpeople')) { + throw new ClientException(sprintf(_('You already have %d or more people tagged %s ' . + 'which is the maximum allowed number.' . + 'Try untagging others with the same tag first.'), + common_config('peopletag', 'maxpeople'), $tag)); + return false; + } + + $newtag = new Profile_tag(); + + $newtag->tagger = $tagger; + $newtag->tagged = $tagged; + $newtag->tag = $tag; + + $result = $newtag->insert(); + + + if (!$result) { + common_log_db_error($newtag, 'INSERT', __FILE__); + return false; + } + + try { + $plist->query('COMMIT'); + Event::handle('EndTagProfile', array($newtag)); + } catch (Exception $e) { + $newtag->delete(); + $profile_list->delete(); + throw $e; + return false; + } + + $profile_list->taggedCount(true); + self::blowCaches($tagger, $tagged); + } + + return $newtag; + } + + static function unTag($tagger, $tagged, $tag) { + $ptag = Profile_tag::pkeyGet(array('tagger' => $tagger, + 'tagged' => $tagged, + 'tag' => $tag)); + if (!$ptag) { + return true; + } + + if (Event::handle('StartUntagProfile', array($ptag))) { + $orig = clone($ptag); + $result = $ptag->delete(); + if (!$result) { + common_log_db_error($this, 'DELETE', __FILE__); + return false; + } + Event::handle('EndUntagProfile', array($orig)); + if ($result) { + $profile_list = Profile_list::pkeyGet(array('tag' => $tag, 'tagger' => $tagger)); + $profile_list->taggedCount(true); + self::blowCaches($tagger, $tagged); + return true; + } + return false; + } + } + + // @fixme: move this to Profile_list? + static function cleanup($profile_list) { + $ptag = new Profile_tag(); + $ptag->tagger = $profile_list->tagger; + $ptag->tag = $profile_list->tag; + $ptag->find(); + + while($ptag->fetch()) { + if (Event::handle('StartUntagProfile', array($ptag))) { + $orig = clone($ptag); + $result = $ptag->delete(); + if (!$result) { + common_log_db_error($this, 'DELETE', __FILE__); + } + Event::handle('EndUntagProfile', array($orig)); + } + } + } + + // move a tag! + static function moveTag($orig, $new) { + $tags = new Profile_tag(); + $qry = 'UPDATE profile_tag SET ' . + 'tag = "%s", tagger = "%s" ' . + 'WHERE tag = "%s" ' . + 'AND tagger = "%s"'; + $result = $tags->query(sprintf($qry, $new->tag, $new->tagger, + $orig->tag, $orig->tagger)); + + if (!$result) { + common_log_db_error($tags, 'UPDATE', __FILE__); + return false; + } + return true; + } + + static function blowCaches($tagger, $tagged) { + foreach (array(0, 1) as $perm) { + self::blow(sprintf('profile_tag:tagger_tagged_privacy:%d-%d-%d', $tagger, $tagged, $perm)); + } + return true; } } diff --git a/classes/Profile_tag_inbox.php b/classes/Profile_tag_inbox.php new file mode 100644 index 0000000000..dd517b3088 --- /dev/null +++ b/classes/Profile_tag_inbox.php @@ -0,0 +1,27 @@ +private) { + return false; + } + + if (Event::handle('StartSubscribePeopletag', array($peopletag, $profile))) { + $args = array('profile_tag_id' => $peopletag->id, + 'profile_id' => $profile->id); + $existing = Profile_tag_subscription::pkeyGet($args); + if(!empty($existing)) { + return $existing; + } + + $sub = new Profile_tag_subscription(); + $sub->profile_tag_id = $peopletag->id; + $sub->profile_id = $profile->id; + $sub->created = common_sql_now(); + + $result = $sub->insert(); + + if (!$result) { + common_log_db_error($sub, 'INSERT', __FILE__); + throw new Exception(_("Adding people tag subscription failed.")); + } + + $ptag = Profile_list::staticGet('id', $peopletag->id); + $ptag->subscriberCount(true); + + Event::handle('EndSubscribePeopletag', array($peopletag, $profile)); + return $ptag; + } + } + + static function remove($peopletag, $profile) + { + $sub = Profile_tag_subscription::pkeyGet(array('profile_tag_id' => $peopletag->id, + 'profile_id' => $profile->id)); + + if (empty($sub)) { + // silence is golden? + return true; + } + + if (Event::handle('StartUnsubscribePeopletag', array($peopletag, $profile))) { + $result = $sub->delete(); + + if (!$result) { + common_log_db_error($sub, 'DELETE', __FILE__); + throw new Exception(_("Removing people tag subscription failed.")); + } + + $peopletag->subscriberCount(true); + + Event::handle('EndUnsubscribePeopletag', array($peopletag, $profile)); + return true; + } + } + + // called if a tag gets deleted / made private + static function cleanup($profile_list) { + $subs = new self(); + $subs->profile_tag_id = $profile_list->id; + $subs->find(); + + while($subs->fetch()) { + $profile = Profile::staticGet('id', $subs->profile_id); + Event::handle('StartUnsubscribePeopletag', array($profile_list, $profile)); + // Delete anyway + $subs->delete(); + Event::handle('StartUnsubscribePeopletag', array($profile_list, $profile)); + } + } +} diff --git a/classes/User.php b/classes/User.php index 970e167a3b..be362e67fa 100644 --- a/classes/User.php +++ b/classes/User.php @@ -504,12 +504,12 @@ class User extends Memcached_DataObject function getSelfTags() { - return Profile_tag::getTags($this->id, $this->id); + return Profile_tag::getTagsArray($this->id, $this->id, $this->id); } - function setSelfTags($newtags) + function setSelfTags($newtags, $privacy) { - return Profile_tag::setTags($this->id, $this->id, $newtags); + return Profile_tag::setTags($this->id, $this->id, $newtags, $privacy); } function block($other) diff --git a/classes/statusnet.ini b/classes/statusnet.ini index bf8d173805..6b68dfe713 100644 --- a/classes/statusnet.ini +++ b/classes/statusnet.ini @@ -460,6 +460,43 @@ tagger = K tagged = K tag = K +[profile_list] +id = 129 +tagger = 129 +tag = 130 +description = 34 +private = 17 +created = 142 +modified = 384 +uri = 130 +mainpage = 130 +tagged_count = 129 +subscriber_count = 129 + +[profile_list__keys] +id = U +tagger = K +tag = K + +[profile_tag_inbox] +profile_tag_id = 129 +notice_id = 129 +created = 142 + +[profile_tag_inbox__keys] +profile_tag_id = K +notice_id = K + +[profile_tag_subscription] +profile_tag_id = 129 +profile_id = 129 +created = 142 +modified = 384 + +[profile_tag_subscription__keys] +profile_tag_id = K +profile_id = K + [queue_item] id = 129 frame = 194 diff --git a/classes/statusnet.links.ini b/classes/statusnet.links.ini index b9dd5af0c9..28bf03fd45 100644 --- a/classes/statusnet.links.ini +++ b/classes/statusnet.links.ini @@ -55,3 +55,23 @@ file_id = file:id file_id = file:id post_id = notice:id +[profile_list] +tagger = profile:id + +[profile_tag] +tagger = profile:id +tagged = profile:id +; in class definition: +;tag,tagger = profile_list:tag,tagger + +[profile_list] +tagger = profile:id + +[profile_tag_inbox] +profile_tag_id = profile_list:id +notice_id = notice:id + +[profile_tag_subscription] +profile_tag_id = profile_list:id +profile_id = profile:id +