#!/usr/bin/perl package LJ::Tags; use strict; # # name: LJ::Tags::get_usertagsmulti # class: tags # des: Gets a bunch of tags for the specified list of users. # args: uobj* # des-uobj: One or more user ids or objects to load the tags for. # returns: Hashref; { userid => *tagref*, userid => *tagref*, ... } where *tagref* is the # return value of LJ::Tags::get_usertags -- undef on failure # sub get_usertagsmulti { return {} if $LJ::DISABLED{tags}; # get input users my @uobjs = grep { defined } map { LJ::want_user($_) } @_; return {} unless @uobjs; # now setup variables we'll need my @memkeys; # memcache keys to fetch my @resjids; # final list of journal ids my $res = {}; # { jid => { tagid => {}, ... }, ... }; results return hashref my %jid2cid; # ( jid => cid ); cross reference journals to clusters my %need; # ( cid => { jid => 1 } ); what we still need # prepopulate our structures foreach my $u (@uobjs) { $jid2cid{$u->{userid}} = $u->{clusterid}; $need{$u->{clusterid}}->{$u->{userid}} = 1; push @memkeys, [ $u->{userid}, "tags:$u->{userid}" ]; } # gather data from memcache if available my $memc = LJ::MemCache::get_multi(@memkeys) || {}; foreach my $key (keys %$memc) { if ($key =~ /^tags:(\d+)$/) { my $jid = $1; my $cid = $jid2cid{$jid}; # set this up in our return hash $res->{$jid} = $memc->{$key}; # no longer need this user delete $need{$cid}->{$jid}; # delete cluster if no more users delete $need{$cid} unless %{$need{$cid}}; } } # now, what we need per cluster... foreach my $cid (keys %need) { # get db for this cluster my $dbcr = LJ::get_cluster_def_reader($cid) or next; # useful sql my $in = join(',', map { $_ + 0 } keys %{$need{$cid}}); # get the tags from the database my $tagrows = $dbcr->selectall_arrayref(qq{ SELECT journalid, kwid, parentkwid, display FROM usertags WHERE journalid IN ($in) }); next if $dbcr->err || ! $tagrows; # break down into data structures my %tags; # ( jid => ( id => display ) ) $tags{$_->[0]}->{$_->[1]} = $_->[3] foreach @$tagrows; # if they have no tags... next unless %tags; # create SQL for finding the proper ids... (userid = ? AND kwid IN (...)) OR (userid = ? ...) ... my @stmts; foreach my $uid (keys %tags) { push @stmts, "(userid = " . ($uid+0) . " AND kwid IN (" . join(',', map { $_+0 } keys %{$tags{$uid}}) . "))"; } my $where = join(' OR ', @stmts); # get the keyword ids they have used as tags my $rows = $dbcr->selectall_arrayref("SELECT userid, kwid, keyword FROM userkeywords WHERE $where"); next if $dbcr->err || ! $rows; # now turn this into a tentative results hash: { userid => { tagid => { name => tagname, ... }, ... } } foreach my $row (@$rows) { $res->{$row->[0]}->{$row->[1]} = { name => $row->[2], security => { public => 0, groups => {}, private => 0, friends => 0 }, uses => 0, display => $tags{$row->[0]}->{$row->[1]}, }; } @resjids = keys %$res; # get security counts my $ids = join(',', map { $_+0 } @resjids); # populate security counts my $counts = $dbcr->selectall_arrayref("SELECT journalid, kwid, security, entryct FROM logkwsum WHERE journalid IN ($ids)"); next if $dbcr->err || ! $counts; # setup some helper values my $public_mask = 1 << 31; my $friends_mask = 1 << 0; # melt this information down into the hashref foreach my $row (@$counts) { my ($jid, $kwid, $sec, $ct) = @$row; # make sure this journal and keyword are present in the results already # so we don't auto-vivify something with security that has no keyword with it next unless $res->{$jid} && $res->{$jid}->{$kwid}; # add these to the total uses $res->{$jid}->{$kwid}->{uses} += $ct; if ($sec & $public_mask) { $res->{$jid}->{$kwid}->{security}->{public} += $ct; $res->{$jid}->{$kwid}->{security_level} = 'public'; } elsif ($sec & $friends_mask) { $res->{$jid}->{$kwid}->{security}->{friends} += $ct; $res->{$jid}->{$kwid}->{security_level} = 'friends' unless $res->{$jid}->{$kwid}->{security_level} eq 'public'; } elsif ($sec) { # if $sec is true (>0), and not friends/public, then it's a group. but it's # still in the form of a number, and we want to know which group it is. so # we must convert the mask back to a bit number with LJ::bit_breakdown. but # we will only ever have one mask, so we just accept that. my $grpid = (LJ::bit_breakdown($sec))[0] + 0; $res->{$jid}->{$kwid}->{security}->{groups}->{$grpid} += $ct; $res->{$jid}->{$kwid}->{security_level} ||= 'group'; } else { # $sec must be 0 $res->{$jid}->{$kwid}->{security}->{private} += $ct; } } # default securities to private and store to memcache foreach my $jid (@resjids) { $res->{$jid}->{$_}->{security_level} ||= 'private' foreach keys %{$res->{$jid}}; LJ::MemCache::add([ $jid, "tags:$jid" ], $res->{$jid}); } } return $res; } # # name: LJ::Tags::get_usertags # class: tags # des: Returns the tags that a user has defined for their account. # args: uobj, opts? # des-uobj: User object to get tags for. # des-opts: Optional hashref; key can be 'remote' to filter tags to only ones that remote can see # returns: Hashref; key being tag id, value being a large hashref (FIXME: document) # sub get_usertags { return {} if $LJ::DISABLED{tags}; my $u = LJ::want_user(shift) or return undef; my $opts = shift() || {}; # get tags for this user my $tags = LJ::Tags::get_usertagsmulti($u); return undef unless $tags; # get the tags for this user my $res = $tags->{$u->{userid}} || {}; return {} unless %$res; # now if they provided a remote, remove the ones they don't want to see; note that # remote may be undef so we have to check exists if (exists $opts->{remote}) { # never going to cull anything if it's you, so return it return $res if LJ::u_equals($u, $opts->{remote}); # setup helper variables from u to remote my ($is_friend, $grpmask) = (0, 0); if ($opts->{remote}) { $is_friend = LJ::is_friend($u, $opts->{remote}); $grpmask = LJ::get_groupmask($u, $opts->{remote}); } # figure out what we need to purge my @purge; TAG: foreach my $tagid (keys %$res) { my $sec = $res->{$tagid}->{security_level}; next TAG if $sec eq 'public'; next TAG if $is_friend && $sec eq 'friends'; if ($grpmask && $sec eq 'group') { foreach my $grpid (%{$res->{$tagid}->{security}->{groups}}) { next TAG if $grpmask & (1 << $grpid); } } push @purge, $tagid; } delete $res->{$_} foreach @purge; } return $res; } # # name: LJ::Tags::get_entry_tags # class: tags # des: Gets tags that have been used on an entry # args: uuserid, jitemid # des-uuserid: User id or object of account with entry # des-jitemid: Journal itemid of entry; may also be arrayref of jitemids in journal. # returns: Hashref; { jitemid => { tagid => tagname, tagid => tagname, ... }, ... } # sub get_logtags { return {} if $LJ::DISABLED{tags}; my $u = LJ::want_user(shift); return undef unless $u; # handle magic jitemid parameter my $jitemid = shift; unless (ref $jitemid eq 'ARRAY') { $jitemid = [ $jitemid+0 ]; return undef unless $jitemid->[0]; } return undef unless @$jitemid; # transform to a call to get_logtagsmulti my $ret = LJ::Tags::get_logtagsmulti({ $u->{clusterid} => [ map { [ $u->{userid}, $_ ] } @$jitemid ] }); return undef unless $ret && ref $ret eq 'HASH'; # now construct result hashref return { map { $_ => $ret->{"$u->{userid} $_"} } @$jitemid }; } # # name: LJ::Tags::get_logtagsmulti # class: tags # des: Load tags on a given set of entries # args: idsbyc # des-idsbyc: { clusterid => [ [ jid, jitemid ], [ jid, jitemid ], ... ] } # returns: hashref with "jid jitemid" keys, value of each being a hashref of # { tagid => tagname, ... } # sub get_logtagsmulti { return {} if $LJ::DISABLED{tags}; # get parameter (only one!) my $idsbycluster = shift; return undef unless $idsbycluster && ref $idsbycluster eq 'HASH'; # the mass of variables to make this mess work! my @jids; # journalids we've seen my @memkeys; # memcache keys to load my %ret; # ( jid => { jitemid => [ tagid, tagid, ... ], ... } ); storage for data pre-final conversion my %set; # ( jid => ( jitemid => [ tagid, tagid, ... ] ) ); for setting in memcache my $res = {}; # { "jid jitemid" => { tagid => kw, tagid => kw, ... } }; final results hashref for return my %need; # ( cid => { jid => { jitemid => 1, jitemid => 1 } } ); what still needs loading my %jid2cid; # ( jid => cid ); map of journal id to clusterid # construct memcache keys for loading below foreach my $cid (keys %$idsbycluster) { foreach my $row (@{$idsbycluster->{$cid} || []}) { $need{$cid}->{$row->[0]}->{$row->[1]} = 1; $jid2cid{$row->[0]} = $cid; push @memkeys, [ $row->[0], "logtag:$row->[0]:$row->[1]" ]; } } # now hit up memcache to try to find what we can my $memc = LJ::MemCache::get_multi(@memkeys) || {}; foreach my $key (keys %$memc) { if ($key =~ /^logtag:(\d+):(\d+)$/) { my ($jid, $jitemid) = ($1, $2); my $cid = $jid2cid{$jid}; # save memcache output hashref to out %ret var $ret{$jid}->{$jitemid} = $memc->{$key}; # no longer need this jid->jitemid combo delete $need{$cid}->{$jid}->{$jitemid}; # no longer need this user if no more jitemids for them delete $need{$cid}->{$jid} unless %{$need{$cid}->{$jid}}; # delete cluster from need if no more users on it delete $need{$cid} unless %{$need{$cid}}; } } # iterate over clusters and construct SQL to get the data... foreach my $cid (keys %need) { my $dbcm = LJ::get_cluster_master($cid) or return undef; # list of (jid, jitemid) pairs that we get from %need my @bind; foreach my $jid (keys %{$need{$cid} || {}}) { push @bind, ($jid, $_) foreach keys %{$need{$cid}->{$jid} || {}}; } # @bind is always even (from above), so the count of query elements we need is the # number of items in @bind, divided by 2 my $sql = join(' OR ', map { "(journalid = ? AND jitemid = ?)" } 1..(scalar(@bind)/2)); # prepare the query to run my $sth = $dbcm->prepare("SELECT journalid, jitemid, kwid FROM logtags WHERE ($sql)"); return undef if $dbcm->err || ! $sth; # execute, fail on error $sth->execute(@bind); return undef if $sth->err; # get data into %set so we add it to memcache later while (my ($jid, $jitemid, $kwid) = $sth->fetchrow_array) { push @{$set{$jid}->{$jitemid} ||= []}, $kwid; } } # now add the things to memcache that we loaded from the clusters and also # transport them into the $ret hashref or returning to the user foreach my $jid (keys %set) { foreach my $jitemid (keys %{$set{$jid}}) { LJ::MemCache::add([ $jid, "logtag:$jid:$jitemid" ], $set{$jid}->{$jitemid}); $ret{$jid}->{$jitemid} = $set{$jid}->{$jitemid}; } } # quickly load all tags for the users we've found @jids = keys %ret; my $utags = LJ::Tags::get_usertagsmulti(@jids); return undef unless $utags; # last step: convert keywordids to keywords foreach my $jid (@jids) { my $tags = $utags->{$jid}; next unless $tags; # transpose data from %ret into $res hashref which has (kwid => keyword) pairs foreach my $jitemid (keys %{$ret{$jid}}) { $res->{"$jid $jitemid"}->{$_} = $tags->{$_}->{name} foreach @{$ret{$jid}->{$jitemid} || []}; } } # finally return the result hashref return $res; } # # name: LJ::Tags::can_add_tags # class: tags # des: Determines if one account is allowed to add tags to another's post # args: u, remote # des-u: User id or object of account tags are being added to # des-remote: User id or object of account performing the action # returns: 1 if allowed, 0 if not, undef on error # sub can_add_tags { return undef if $LJ::DISABLED{tags}; my $u = LJ::want_user(shift); my $remote = LJ::want_user(shift); return undef unless $u && $remote; return undef unless $remote->{journaltype} eq 'P'; return undef if LJ::is_banned($remote, $u); # get permission hashref and check it; note that we fall back to the control # permission, which will allow people to add even if they can't add by default my $perms = LJ::Tags::get_permission_levels($u); return LJ::Tags::_remote_satisfies_permission($u, $remote, $perms->{add}) || LJ::Tags::_remote_satisfies_permission($u, $remote, $perms->{control}); } # # name: LJ::Tags::can_control_tags # class: tags # des: Determines if one account is allowed to control (add, edit, delete) the tags of another # args: u, remote # des-u: User id or object of account tags are being edited on # des-remote: User id or object of account performing the action # returns: 1 if allowed, 0 if not, undef on error # sub can_control_tags { return undef if $LJ::DISABLED{tags}; my $u = LJ::want_user(shift); my $remote = LJ::want_user(shift); return undef unless $u && $remote; return undef unless $remote->{journaltype} eq 'P'; return undef if LJ::is_banned($remote, $u); # get permission hashref and check it my $perms = LJ::Tags::get_permission_levels($u); return LJ::Tags::_remote_satisfies_permission($u, $remote, $perms->{control}); } # helper sub internal used by can_*_tags functions sub _remote_satisfies_permission { my ($u, $remote, $perm) = @_; return undef unless $u && $remote && $perm; # permission checks if ($perm eq 'public') { return 1; } elsif ($perm eq 'none') { return 0; } elsif ($perm eq 'friends') { return LJ::is_friend($u, $remote); } elsif ($perm eq 'private') { return LJ::can_manage($remote, $u); } elsif ($perm =~ /^group:(\d+)$/) { my $grpid = $1+0; return undef unless $grpid >= 1 && $grpid <= 30; my $mask = LJ::get_groupmask($u, $remote); return ($mask & (1 << $grpid)) ? 1 : 0; } else { # else, problem! return undef; } } # # name: LJ::Tags::get_permission_levels # class: tags # des: Gets the permission levels on an account # args: uobj # des-uobj: User id or object of account to get permissions for # returns: Hashref; keys one of 'add', 'control'; values being 'private' (only the account # in question), 'friends' (all friends), 'public' (everybody), 'group:N' (one # friend group with given id), or 'none' (nobody can) # sub get_permission_levels { return { add => 'none', control => 'none' } if $LJ::DISABLED{tags}; my $u = LJ::want_user(shift); return undef unless $u; # get the prop LJ::load_user_props($u, 'opt_tagpermissions'); # return defaults for accounts unless ($u->{opt_tagpermissions}) { if ($u->{journaltype} eq 'C') { # communities are members (friends) add, private (maintainers) control return { add => 'friends', control => 'private' }; } elsif ($u->{journaltype} eq 'P') { # people let friends add, self control return { add => 'private', control => 'private' }; } else { # other account types can't add tags return { add => 'none', control => 'none' }; } } # now split and return my ($add, $control) = split(/\s*,\s*/, $u->{opt_tagpermissions}); return { add => $add, control => $control }; } # # name: LJ::Tags::is_valid_tagstring # class: tags # des: Determines if a string contains a valid list of tags. # args: tagstring, listref? # des-tagstring: Opaque tag string provided by the user. # des-listref: If specified, return valid list of canonical tags in arrayref here. # returns: 1 if list is valid, 0 if not. # sub is_valid_tagstring { my ($tagstring, $listref) = @_; return 0 unless $tagstring; $listref ||= []; # setup helper subs my $valid_tag = sub { my $tag = shift; return 0 if $tag =~ /^_/; # reserved for future use (starting with underscore) return 0 if $tag =~ /[\<\>\r\n\t]/; # no HTML, newlines, tabs, etc return 0 unless $tag =~ /^(?:.+\s?)+$/; # one or more "words" return 1; }; my $canonical_tag = sub { my $tag = shift; $tag = LJ::trim($tag); $tag =~ s/\s+/ /g; # condense multiple spaces to a single space $tag = LJ::text_trim($tag, LJ::BMAX_KEYWORD, LJ::CMAX_KEYWORD); $tag = lc $tag if $tag !~ /[\x7f-\xff]/; return $tag; }; # now iterate my @list = grep { length $_ } # only keep things that are something map { LJ::trim($_) } # remove leading/trailing spaces split(/\s*,\s*/, $tagstring); # split on comma with optional spaces return 0 unless @list; # now validate each one as we go foreach my $tag (@list) { # canonicalize and determine validity $tag = $canonical_tag->($tag); return 0 unless $valid_tag->($tag); # now push on our list push @$listref, $tag; } # well, it must have been okay if we got here return 1; } # # name: LJ::Tags::get_security_breakdown # class: tags # des: Returns a list of security levels that apply to the given security information. # args: security, allowmask # des-security: 'private', 'public', or 'usemask' # des-allowmask: a bitmask in standard allowmask form # returns: List of broken down security levels to use for logkwsum table. # sub get_security_breakdown { my ($sec, $mask) = @_; my @out; if ($sec eq 'private') { @out = (0); } elsif ($sec eq 'public') { @out = (1 << 31); } else { # have to get each group bit into a mask foreach my $bit (0..30) { # include 0 for friends only if ($mask & (1 << $bit)) { push @out, (1 << $bit); } } } return @out; } # # name: LJ::Tags::update_logtags # class: tags # des: Updates the tags on an entry. Tags not in the list you provide are deleted. # args: uobj, jitemid, uobj, tags, # des-uobj: User id or object of account with entry # des-jitemid: Journal itemid of entry to tag # des-opts: Hashref; keys being the action and values of the key being an arrayref of # tags to involve in the action. Possible actions are 'add', 'set', and # 'delete'. With those, the value is a hashref of the tags (textual tags) # to add, set, or delete. Other actions are 'add_ids', 'set_ids', and # 'delete_ids'. The value arrayref should then contain the tag ids to # act with. Can also specify 'add_string', 'set_string', or 'delete_string' # as a comma separated list of user-supplied tags which are then canonicalized # and used. 'remote' is the remote user taking the actions (required). # returns: 1 on success, undef on error # sub update_logtags { return undef if $LJ::DISABLED{tags}; my $u = LJ::want_user(shift); my $jitemid = shift() + 0; return undef unless $u && $jitemid; return undef unless $u->writer; # ensure we have an options hashref my $opts = shift; return undef unless $opts && ref $opts eq 'HASH'; # perform set logic? my $do_set = exists $opts->{set} || exists $opts->{set_ids} || exists $opts->{set_string}; # now get extra options my $remote = LJ::want_user(delete $opts->{remote}); return undef unless $remote || $opts->{force}; # get access levels my $can_control = LJ::Tags::can_control_tags($u, $remote); my $can_add = $can_control || LJ::Tags::can_add_tags($u, $remote); return undef unless $can_add || $opts->{force}; # load the user's tags my $utags = LJ::Tags::get_usertags($u); return undef unless $utags; # take arrayrefs of tag strings and stringify them for validation foreach my $verb (qw(add set delete)) { # if given tags, combine into a string if ($opts->{$verb}) { $opts->{"${verb}_string"} = join(',', @{$opts->{$verb}}); $opts->{$verb} = []; } # now validate the string, if we have one if ($opts->{"${verb}_string"}) { $opts->{$verb} = []; return undef unless LJ::Tags::is_valid_tagstring($opts->{"${verb}_string"}, $opts->{$verb}); } # and turn everything into ids $opts->{"${verb}_ids"} ||= []; foreach my $kw (@{$opts->{$verb} || []}) { my $kwid = LJ::get_keyword_id($u, $kw, $can_control); if ($can_control) { # error if we failed to create return undef unless $kwid; } else { # if we're not creating, who cares, just skip; also skip if the keyword # is not really a tag (don't promote it) next unless $kwid && $utags->{$kwid}; } # create it if necessary LJ::Tags::create_usertag($u, $kw, { display => 1 }) unless $utags->{$kwid}; push @{$opts->{"${verb}_ids"}}, $kwid; } } # setup %add/%delete hashes, for easier duplicate removal my %add = ( map { $_ => 1 } @{$opts->{add_ids} || []} ); my %delete = ( map { $_ => 1 } @{$opts->{delete_ids} || []} ); # used to keep counts in sync my $tags = LJ::Tags::get_logtags($u, $jitemid); return undef unless $tags; # now get tags for this entry; which there might be none, so make it a hashref $tags = $tags->{$jitemid} || {}; # set is broken down into add/delete as necessary if ($do_set || ($opts->{set_ids} && @{$opts->{set_ids}})) { # mark everything to delete, we'll fix it shortly $delete{$_} = 1 foreach keys %{$tags}; # and now go through the set we want, things that are in the delete # pile are just nudge so we don't touch them, and everything else we # throw in the add pile foreach my $id (@{$opts->{set_ids}}) { $add{$id} = 1 unless delete $delete{$id}; } } # now don't readd things we already have delete $add{$_} foreach keys %{$tags}; # but delete nothing if we're not a controller %delete = () unless $can_control || $opts->{force}; # bail out if nothing needs to be done return 1 unless %add || %delete; # %add and %delete are accurate, but we need to track necessary # security updates; this is a hash of keyword ids and a modification # value (a delta; +/-N) to be applied to that row later my %security; # get the security of this post for use in %security; do this now so # we don't interrupt the transaction below my $l2row = LJ::get_log2_row($u, $jitemid); return undef unless $l2row; # calculate security masks my @sec = LJ::Tags::get_security_breakdown($l2row->{security}, $l2row->{allowmask}); # setup a rollback bail path so that we can undo everything we've done # if anything fails in the middle; and if the rollback fails, scream loudly # and burst into flames! my $rollback = sub { die $u->errstr unless $u->rollback; return undef; }; # start the big transaction, for great justice! $u->begin_work; # process additions first my @bind; foreach my $kwid (keys %add) { $security{$kwid}++; push @bind, $u->{userid}, $jitemid, $kwid; } # now add all to both tables; only do 100 rows (300 bind vars) at a time while (my @list = splice(@bind, 0, 300)) { my $sql = join(',', map { "(?,?,?)" } 1..(scalar(@list)/3)); $u->do("REPLACE INTO logtags (journalid, jitemid, kwid) VALUES $sql", undef, @list); return $rollback->() if $u->err; $u->do("REPLACE INTO logtagsrecent (journalid, jitemid, kwid) VALUES $sql", undef, @list); return $rollback->() if $u->err; } # now process deletions @bind = (); foreach my $kwid (keys %delete) { $security{$kwid}--; push @bind, $kwid; } # now run the SQL while (my @list = splice(@bind, 0, 100)) { my $sql = join(',', map { $_ + 0 } @list); $u->do("DELETE FROM logtags WHERE journalid = ? AND jitemid = ? AND kwid IN ($sql)", undef, $u->{userid}, $jitemid); return $rollback->() if $u->err; $u->do("DELETE FROM logtagsrecent WHERE journalid = ? AND kwid IN ($sql) AND jitemid = ?", undef, $u->{userid}, $jitemid); return $rollback->() if $u->err; } # now handle lazy cleaning of this table for these tag ids; note that the # %security hash contains all of the keywords we've operated on in total my @kwids = keys %security; my $sql = join(',', map { $_ + 0 } @kwids); my $sth = $u->prepare("SELECT kwid, COUNT(*) FROM logtagsrecent WHERE journalid = ? AND kwid IN ($sql) GROUP BY 1"); return $rollback->() if $u->err || ! $sth; $sth->execute($u->{userid}); return $rollback->() if $sth->err; # now iterate over counts and find ones that are too high my %delrecent; # kwid => [ jitemid, jitemid, ... ] while (my ($kwid, $ct) = $sth->fetchrow_array) { next unless $ct > 120; # get the times of the entries, the user time (lastn view uses user time), sort it, and then # we can chop off jitemids that fall below the threshold -- but only in this keyword and only clean # up some number at a time (25 at most, starting at our threshold) my $sth2 = $u->prepare(qq{ SELECT t.jitemid FROM logtagsrecent t, log2 l WHERE t.journalid = l.journalid AND t.jitemid = l.jitemid AND t.journalid = ? AND t.kwid = ? ORDER BY l.eventtime DESC LIMIT 100,25 }); return $rollback->() if $u->err || ! $sth2; $sth2->execute($u->{userid}, $kwid); return $rollback->() if $sth2->err; # push these onto the hash for deleting below while (my $jit = $sth2->fetchrow_array) { push @{$delrecent{$kwid} ||= []}, $jit; } } # now delete any recents we need to into this format: # (kwid = 3 AND jitemid IN (2, 3, 4)) OR (kwid = ...) OR ... # but only if we have some to delete if (%delrecent) { my $del = join(' OR ', map { "(kwid = " . ($_+0) . " AND jitemid IN (" . join(',', map { $_+0 } @{$delrecent{$_}}) . "))" } keys %delrecent); $u->do("DELETE FROM logtagsrecent WHERE journalid = ? AND ($del)", undef, $u->{userid}); return $rollback->() if $u->err; } # now we must get the current security values in order to come up with a proper update; note that # we select for update, which locks it so we have a consistent view of the rows $sth = $u->prepare("SELECT kwid, security, entryct FROM logkwsum WHERE journalid = ? AND kwid IN ($sql) FOR UPDATE"); return $rollback->() if $u->err || ! $sth; $sth->execute($u->{userid}); return $rollback->() if $sth->err; # now iterate and get the security counts my %counts; while (my ($kwid, $sec, $ct) = $sth->fetchrow_array) { $counts{$kwid}->{$sec} = $ct; } # now we want to update them, and delete any at 0 my (@replace, @delete); foreach my $kwid (@kwids) { foreach my $sec (@sec) { if (exists $counts{$kwid} && exists $counts{$kwid}->{$sec}) { # an old one exists my $new = $counts{$kwid}->{$sec} + $security{$kwid}; if ($new > 0) { # update it push @replace, [ $kwid, $sec, $new ]; } else { # delete this one push @delete, [ $kwid, $sec ]; } } else { # add a new one push @replace, [ $kwid, $sec, $security{$kwid} ]; } } } # handle deletes in one move; well, 100 at a time while (my @list = splice(@delete, 0, 100)) { my $sql = join(' OR ', map { "(kwid = ? AND security = ?)" } 1..scalar(@list)); $u->do("DELETE FROM logkwsum WHERE journalid = ? AND ($sql)", undef, $u->{userid}, map { @$_ } @list); return $rollback->() if $u->err; } # handle replaces and inserts while (my @list = splice(@replace, 0, 100)) { my $sql = join(',', map { "(?,?,?,?)" } 1..scalar(@list)); $u->do("REPLACE INTO logkwsum (journalid, kwid, security, entryct) VALUES $sql", undef, map { $u->{userid}, @$_ } @list); return $rollback->() if $u->err; } # commit everything and smack caches and we're done! die $u->errstr unless $u->commit; LJ::Tags::reset_cache($u); LJ::Tags::reset_cache($u => $jitemid); return 1; } # # name: LJ::Tags::delete_logtags # class: tags # des: Deletes all tags on an entry. # args: uobj, jitemid # des-uobj: User id or object of account with entry # des-jitemid: Journal itemid of entry to delete tags from # returns: undef on error; 1 on success # sub delete_logtags { return undef if $LJ::DISABLED{tags}; my $u = LJ::want_user(shift); my $jitemid = shift() + 0; return undef unless $u && $jitemid; # maybe this is ghetto, but it does all of the logic we would otherwise # have to duplicate here, so no sense in doing that. return LJ::Tags::update_logtags($u, $jitemid, { set_string => "", force => 1, }); } # # name: LJ::Tags::reset_cache # class: tags # des: Clears out all cached information for a user's tags. # args: uobj, jitemid? # des-uobj: User id or object of account to clear cache for # des-jitemid: Either a single jitemid or an arrayref of jitemids to clear for the user. If # not present, the user's tags cache is cleared. If present, the cache for those # entries only are cleared. # returns: undef on error; 1 on success # sub reset_cache { return undef if $LJ::DISABLED{tags}; while (my ($u, $jitemid) = splice(@_, 0, 2)) { next unless $u = LJ::want_user($u); # standard user tags cleanup unless ($jitemid) { LJ::MemCache::delete([ $u->{userid}, "tags:$u->{userid}" ]); } # now, cleanup entries if necessary if ($jitemid) { $jitemid = [ $jitemid ] unless ref $jitemid eq 'ARRAY'; LJ::MemCache::delete([ $u->{userid}, "logtag:$u->{userid}:$_" ]) foreach @$jitemid; } } return 1; } # # name: LJ::Tags::create_usertag # class: tags # des: Creates tags for a user, returning the keyword ids allocated. # args: uobj, kw, opts? # des-uobj: User object to create tag on. # des-kw: Tag string (comma separated list of tags) to create. # des-opts: Optional; hashref, possible keys being 'display' and value being whether or # not this tag should be a display tag and 'parenttagid' being the tagid of a # parent tag for heirarchy. # returns: undef on error, else a hashref of { keyword => tagid } for each keyword defined # sub create_usertag { return undef if $LJ::DISABLED{tags}; my $u = LJ::want_user(shift); my $kw = shift; my $opts = shift || {}; return undef unless $u && $kw; my $tags = []; my $isvalid = LJ::Tags::is_valid_tagstring($kw, $tags); return undef unless $isvalid; my $display = $opts->{display} ? 1 : 0; my $parentkwid = $opts->{parenttagid} ? ($opts->{parenttagid}+0) : undef; my %res; foreach my $tag (@$tags) { my $kwid = LJ::get_keyword_id($u, $tag); return undef unless $kwid; $res{$tag} = $kwid; } my $ct = scalar keys %res; my $bind = join(',', map { "(?,?,?,?)" } 1..$ct); $u->do("INSERT IGNORE INTO usertags (journalid, kwid, parentkwid, display) VALUES $bind", undef, map { $u->{userid}, $_, $parentkwid, $display } values %res); return undef if $u->err; LJ::Tags::reset_cache($u); return \%res; } # # name: LJ::Tags::validate_tag # class: tags # des: Check the validity of a single tag. # args: tag # des-tag: The tag to check. # returns: If valid, the canonicalized tag, else, undef. # sub validate_tag { my $tag = shift; return undef unless $tag; my $list = []; return undef unless LJ::Tags::is_valid_tagstring($tag, $list); return undef if scalar(@$list) > 1; return $list->[0]; } # # name: LJ::Tags::delete_usertag # class: tags # des: Deletes a tag for a user, and all mappings. # args: uobj, type, tag # des-uobj: User object to delete tag on. # des-type: Either 'id' or 'name', indicating the type of the third parameter. # des-tag: If type is 'id', this is the tag id (kwid). If type is 'name', this is the name of the # tag that we want to delete from the user. # returns: undef on error, 1 for success, 0 for tag not found # sub delete_usertag { return undef if $LJ::DISABLED{tags}; my $u = LJ::want_user(shift); return undef unless $u; my ($type, $val) = @_; my $kwid; if ($type eq 'name') { my $tag = LJ::Tags::validate_tag($val); return undef unless $tag; $kwid = LJ::get_keyword_id($u, $tag, 0); } elsif ($type eq 'id') { $kwid = $val + 0; } return undef unless $kwid; # escape sub my $rollback = sub { die $u->errstr unless $u->rollback; return undef; }; # start the big transaction $u->begin_work; # get items this keyword is on my $sth = $u->prepare('SELECT jitemid FROM logtags WHERE journalid = ? AND kwid = ? FOR UPDATE'); return $rollback->() if $u->err || ! $sth; # now get the items $sth->execute($u->{userid}, $kwid); return $rollback->() if $sth->err; # now get list of jitemids for later cache clearing my @jitemids; push @jitemids, $_ while $_ = $sth->fetchrow_array; # delete this tag's information from the relevant tables foreach my $table (qw(usertags logtags logtagsrecent logkwsum)) { # no error checking, we're just deleting data that's already semi-unlinked due # to us already updating the userprop above $u->do("DELETE FROM $table WHERE journalid = ? AND kwid = ?", undef, $u->{userid}, $kwid); } # all done with our updates die $u->errstr unless $u->commit; # reset caches, have to do both of these, one for the usertags one for logtags LJ::Tags::reset_cache($u); LJ::Tags::reset_cache($u => \@jitemids); return 1; } # # name: LJ::Tags::rename_usertag # class: tags # des: Deletes a tag for a user, and all mappings. # args: uobj, type, tag, newname # des-uobj: User object to delete tag on. # des-type: Either 'id' or 'name', indicating the type of the third parameter. # des-tag: If type is 'id', this is the tag id (kwid). If type is 'name', this is the name of the # tag that we want to rename for the user. # des-newname: The new name of this tag. # returns: undef on error, 1 for success, 0 for tag not found # sub rename_usertag { return undef if $LJ::DISABLED{tags}; # FIXME/TODO: make this function do merging? my $u = LJ::want_user(shift); return undef unless $u; my ($type, $val, $newname) = @_; return undef unless $type && $val && $newname; # validate new tag $newname = LJ::Tags::validate_tag($newname); return undef unless $newname; # get a list of keyword ids to operate on my $kwid; if ($type eq 'name') { $val = LJ::Tags::validate_tag($val); return undef unless $val; $kwid = LJ::get_keyword_id($u, $val, 0); } elsif ($type eq 'id') { $kwid = $val + 0; } return undef unless $kwid; # see if this is already a keyword my $newkwid = LJ::get_keyword_id($u, $newname); return undef unless $newkwid; # see if the tag we're renaming TO already exists as a keyword, # if so, don't allow the rename because we don't do merging (yet) my $tags = LJ::Tags::get_usertags($u); return undef if $tags->{$newkwid}; # escape sub my $rollback = sub { die $u->errstr unless $u->rollback; return undef; }; # start the big transaction $u->begin_work; # get items this keyword is on my $sth = $u->prepare('SELECT jitemid FROM logtags WHERE journalid = ? AND kwid = ? FOR UPDATE'); return $rollback->() if $u->err || ! $sth; # now get the items $sth->execute($u->{userid}, $kwid); return $rollback->() if $sth->err; # now get list of jitemids for later cache clearing my @jitemids; push @jitemids, $_ while $_ = $sth->fetchrow_array; # do database update to migrate from old to new foreach my $table (qw(usertags logtags logtagsrecent logkwsum)) { $u->do("UPDATE $table SET kwid = ? WHERE journalid = ? AND kwid = ?", undef, $newkwid, $u->{userid}, $kwid); return $rollback->() if $u->err; } # all done with our updates die $u->errstr unless $u->commit; # reset caches, have to do both of these, one for the usertags one for logtags LJ::Tags::reset_cache($u); LJ::Tags::reset_cache($u => \@jitemids); return 1; } # # name: LJ::Tags::set_usertag_display # class: tags # des: Set the display bool for a tag. # args: uobj, vartype, var, val # des-uobj: User id or object of account to edit tag on # des-vartype: Either 'id' or 'name'; indicating what the next parameter is # des-var: If vartype is 'id', this is the tag (keyword) id; else, it's the tag/keyword itself # des-val: 1/0; whether to turn the display flag on or off # returns: 1 on success, undef on error # sub set_usertag_display { return undef if $LJ::DISABLED{tags}; my $u = LJ::want_user(shift); my ($type, $var, $val) = @_; return undef unless $u; my $kwid; if ($type eq 'id') { $kwid = $var + 0; } elsif ($type eq 'name') { $var = LJ::Tags::validate_tag($var); return undef unless $var; # do not auto-vivify but get the keyword id $kwid = LJ::get_keyword_id($u, $var, 0); } return undef unless $kwid; $u->do("UPDATE usertags SET display = ? WHERE journalid = ? AND kwid = ?", undef, $val ? 1 : 0, $u->{userid}, $kwid); return undef if $u->err; return 1; } # # name: LJ::Tags::deleted_friend_group # class: tags # des: Called internally when a friends group is deleted. # args: uobj, bit # des-uobj: User id or object of account deleting the group. # des-bit: The id (1..30) of the friends group being deleted. # returns: 1 of success undef on failure. # sub deleted_friend_group { my $u = LJ::want_user(shift); my $bit = shift() + 0; return undef unless $u && $bit >= 1 && $bit <= 30; # delete from logkwsum and then nuke the user's tags $u->do("DELETE FROM logkwsum WHERE journalid = ? AND security = ?", undef, $u->{userid}, 1 << $bit); return undef if $u->err; # that was simple LJ::Tags::reset_cache($u); return 1; } 1;