#!/usr/bin/perl # # DW::Controller::Search::Interests # # Interest search, based on code from LiveJournal. # # Authors: # Jen Griffin # # Copyright (c) 2010 by Dreamwidth Studios, LLC. # # This program is free software; you may redistribute it and/or modify it under # the same terms as Perl itself. For a copy of the license, please reference # 'perldoc perlartistic' or 'perldoc perlgpl'. # package DW::Controller::Search::Interests; use strict; use warnings; use DW::Routing; use DW::Template; use DW::Controller; use LJ::Global::Constants; use LJ::Stats; DW::Routing->register_string( '/interests', \&interest_handler, app => 1 ); sub interest_handler { my $r = DW::Request->get; my $did_post = $r->did_post; my $args = $did_post ? $r->post_args : $r->get_args; return error_ml('bml.badinput.body1') unless LJ::text_in($args); return error_ml('error.invalidform') if $did_post && !LJ::check_form_auth( $args->{lj_form_auth} ); # do mode logic first, to save typing later my $mode = ''; $mode = 'int' if $args->{int} || $args->{intid}; $mode = 'popular' if $args->{view} && $args->{view} eq "popular"; if ( $args->{mode} ) { $mode = 'add' if $args->{mode} eq "add" && $args->{intid}; $mode = 'addnew' if $args->{mode} eq "addnew" && $args->{keyword}; $mode = 'findsim_do' if !$did_post && $args->{mode} eq "findsim_do"; $mode = 'enmasse' if !$did_post && $args->{mode} eq "enmasse"; $mode = 'enmasse_do' if $did_post && $args->{mode} eq "enmasse_do"; } # check whether authentication is needed or authas is allowed # default is to allow anonymous users, except for certain modes my $anon = ( $mode eq "add" || $mode eq "addnew" ) ? 0 : 1; my $authas = 0; ( $anon, $authas ) = ( 0, 1 ) if $mode eq ( $did_post ? "enmasse_do" : "enmasse" ); my ( $ok, $rv ) = controller( anonymous => $anon, authas => $authas ); return $rv unless $ok; my $remote = $rv->{remote}; my $maxinterests = $remote ? $remote->count_max_interests : 0; $rv->{can_use_popular} = LJ::is_enabled('interests-popular'); $rv->{can_use_findsim} = LJ::is_enabled('interests-findsim') && $remote && $remote->can_find_similar; # now do argument checking and database work for each mode if ( $mode eq 'popular' ) { return error_ml('interests.popular.disabled') unless $rv->{can_use_popular}; $rv->{no_text_mode} = 1 unless $args->{mode} && $args->{mode} eq 'text'; my $rows = LJ::Stats::get_popular_interests(); my %interests; foreach my $int_array (@$rows) { my ( $int, $count ) = @$int_array; $interests{$int} = { eint => LJ::ehtml($int), url => "/interests?int=" . LJ::eurl($int), value => $count }; } $rv->{pop_cloud} = LJ::tag_cloud( \%interests ); $rv->{pop_ints} = [ sort { $b->{value} <=> $a->{value} || $a->{eint} cmp $b->{eint} } values %interests ] if %interests; return DW::Template->render_template( 'interests/popular.tt', $rv ); } if ( $mode eq 'add' || $mode eq 'addnew' ) { my $rints = $remote->get_interests(); return error_ml( "interests.add.toomany", { maxinterests => $maxinterests } ) if scalar(@$rints) >= $maxinterests; my @intids; if ( $mode eq "add" ) { # adding an existing interest, so we have an intid to work with @intids = ( $args->{intid} + 0 ); } else { # adding a new interest my @keywords = LJ::interest_string_to_list( $args->{keyword} ); my @validate = LJ::validate_interest_list(@keywords); @intids = map { LJ::get_sitekeyword_id($_) } @validate; } @intids = grep { $_ } @intids; # ignore any zeroes return error_ml('error.invalidform') unless @intids; # force them to either come from the interests page, or have posted the request. # if both fail, ask them to confirm with a post form. # (only uses first interest; edge case not worth the trouble to fix) unless ( $did_post || LJ::check_referer('/interests') ) { my $int = LJ::get_interest( $intids[0] ); LJ::text_out( \$int ); $rv->{need_post} = { int => $int, intid => $intids[0] }; } else { # let the user add the interest $remote->interest_update( add => \@intids ); } return DW::Template->render_template( 'interests/add.tt', $rv ); } if ( $mode eq 'findsim_do' ) { return error_ml('error.tempdisabled') unless LJ::is_enabled('interests-findsim'); return error_ml('interests.findsim_do.account.notallowed') unless $rv->{can_use_findsim}; my $u = LJ::load_user( $args->{user} ) or return error_ml('error.username_notfound'); my $uitable = $u->is_comm ? 'comminterests' : 'userinterests'; my $dbr = LJ::get_db_reader(); my $sth = $dbr->prepare( "SELECT i.intid, i.intcount " . "FROM $uitable ui, interests i " . "WHERE ui.userid=? AND ui.intid=i.intid" ); $sth->execute( $u->userid ); my ( @ints, %intcount, %pt_count, %pt_weight ); while ( my ( $intid, $count ) = $sth->fetchrow_array ) { push @ints, $intid; $intcount{$intid} = $count || 1; } return error_ml( 'interests.findsim_do.notdefined', { user => $u->ljuser_display } ) unless @ints; # the magic's in this limit clause. that's what makes this work. # perfect results? no. but who cares if somebody that lists "music" # or "reading" doesn't get an extra point towards matching you. # we care about more unique interests. foreach (qw( userinterests comminterests )) { $sth = $dbr->prepare("SELECT userid FROM $_ WHERE intid=? LIMIT 300"); foreach my $int (@ints) { $sth->execute($int); while ( my $uid = $sth->fetchrow_array ) { next if $uid == $u->userid; $pt_weight{$uid} += ( 1 / log( $intcount{$int} + 1 ) ); $pt_count{$uid}++; } } } my %magic; # balanced points $magic{$_} = $pt_weight{$_} * 10 + $pt_count{$_} foreach keys %pt_count; my @matches = sort { $magic{$b} <=> $magic{$a} } keys %magic; @matches = @matches[ 0 .. ( $maxinterests - 1 ) ] if scalar(@matches) > $maxinterests; # load user objects my $users = LJ::load_userids(@matches); my $nocircle = $remote && $args->{nocircle}; my $count = { P => 0, C => 0, I => 0 }; my $data = { P => [], C => [], I => [] }; foreach my $uid (@matches) { my $match_u = $users->{$uid}; next unless $match_u && $match_u->is_visible; if ($nocircle) { next if $remote->watches($match_u); next if $match_u->is_person && $remote->trusts($match_u); next if $match_u->is_comm && $remote->member_of($match_u); } my $j = $match_u->journaltype; push @{ $data->{$j} }, { count => ++$count->{$j}, user => $match_u->ljuser_display, magic => sprintf( "%.3f", $magic{$uid} ) }; } return error_ml( 'interests.findsim_do.nomatch', { user => $u->ljuser_display } ) unless grep { $_ } values %$count; $rv->{findsim_u} = $u; $rv->{findsim_count} = $count; $rv->{findsim_data} = $data; $rv->{nocircle} = $nocircle; $rv->{circle_link} = LJ::page_change_getargs( nocircle => $nocircle ? '' : 1 ); return DW::Template->render_template( 'interests/findsim.tt', $rv ); } if ( $mode eq 'enmasse' ) { my $u = $rv->{u}; my $username = $u->user; my $altauthas = $remote->user ne $username; $rv->{getextra} = $altauthas ? "?authas=$username" : ''; my $fromu = LJ::load_user( $args->{fromuser} || $username ) or return error_ml('error.username_notfound'); $rv->{fromu} = $fromu; my %uint; my %fromint = %{ $fromu->interests } or return error_ml('interests.error.nointerests'); $rv->{allintids} = join( ",", values %fromint ); if ( $u->equals($fromu) ) { %uint = %fromint; $rv->{enmasse_body} = '.enmasse.body.you'; } else { %uint = %{ $u->interests }; my $other = $altauthas ? 'other_authas' : 'other'; $rv->{enmasse_body} = ".enmasse.body.$other"; } my @checkdata; foreach my $fint ( sort keys %fromint ) { push @checkdata, { checkid => "int_$fromint{$fint}", is_checked => $uint{$fint} ? 1 : 0, int => $fint }; } $rv->{enmasse_data} = \@checkdata; return DW::Template->render_template( 'interests/enmasse.tt', $rv ); } if ( $mode eq 'enmasse_do' ) { my $u = $rv->{u}; # $args is actually an object so we want a plain hashref my $argints = {}; foreach my $key ( keys %$args ) { $argints->{$key} = $args->{$key} if $key =~ /^int_\d+$/; } my @fromints = map { $_ + 0 } split /\s*,\s*/, $args->{allintids}; my $sync = $u->sync_interests( $argints, @fromints ); my $result_ml = 'interests.results.'; if ( $sync->{deleted} ) { $result_ml .= $sync->{added} ? 'both' : $sync->{toomany} ? 'del_and_toomany' : 'deleted'; } else { $result_ml .= $sync->{added} ? 'added' : $sync->{toomany} ? 'toomany' : 'nothing'; } $rv->{enmasse_do_result} = $result_ml; $rv->{toomany} = $sync->{toomany} || 0; $rv->{fromu} = LJ::load_user( $args->{fromuser} ) unless !$args->{fromuser} or $u->user eq $args->{fromuser}; return DW::Template->render_template( 'interests/enmasse_do.tt', $rv ); } if ( $mode eq 'int' ) { my $trunc_check = sub { my ( $check_int, $interest ) = @_; my $e_int = LJ::ehtml($check_int); # Determine whether the interest is too long: # 1. If the interest already exists, a long interest will result # in $check_int and $interest not matching. # 2. If it didn't already exist, we fall back on just checking # the length of $check_int. if ( ( $interest && $check_int ne $interest ) || length($check_int) > LJ::CMAX_SITEKEYWORD ) { # The searched-for interest is too long, so use the short version. my $e_int_long = $e_int; $e_int = LJ::ehtml( $interest ? $interest : substr( $check_int, 0, LJ::CMAX_SITEKEYWORD ) ); $rv->{warn_toolong} = ( $rv->{warn_toolong} ? $rv->{warn_toolong} . "
" : '' ) . LJ::Lang::ml( 'interests.error.longinterest', { sitename => $LJ::SITENAMESHORT, old_int => $e_int_long, new_int => $e_int, maxlen => LJ::CMAX_SITEKEYWORD } ); } return $e_int; }; my ( @intids, @intargs ); if ( $args->{intid} ) { @intids = ( $args->{intid} + 0 ); } else { @intargs = LJ::interest_string_to_list( $args->{int} ); @intids = map { LJ::get_sitekeyword_id( $_, 0 ) || 0 } @intargs; } my $max_search = 3; if ( scalar @intids > $max_search ) { return error_ml( 'interests.error.toomany', { num => $max_search } ); } my ( @intdata, @no_users, @not_interested ); my $index = 0; # for referencing @intargs my $remote_interests = $remote ? $remote->interests : {}; foreach my $intid (@intids) { my $intarg = @intargs ? $intargs[ $index++ ] : ''; my ( $interest, $intcount ) = LJ::get_interest($intid); my $check_int = $intarg || $interest; if ( LJ::Hooks::run_hook( "interest_search_ignore", query => $check_int, intid => $intid ) ) { return error_ml('interests.error.ignored'); } my $e_int = $trunc_check->( $check_int, $interest ); push @intdata, $e_int; push @no_users, $e_int unless $intcount; $rv->{allcount} = $intcount if scalar @intids == 1; # check to see if the remote user already has the interest push @not_interested, { int => $e_int, intid => $intid } if defined $interest && !$remote_interests->{$interest}; } $rv->{interest} = join ', ', @intdata; $rv->{query_count} = scalar @intdata; $rv->{no_users} = join ', ', @no_users; $rv->{no_users_count} = scalar @no_users; $rv->{not_interested} = $remote ? \@not_interested : []; # if any one interest is unused, the search can't succeed undef @intids if @no_users; # filtering by account type my @type_args = ( 'none', 'P', 'C', 'I' ); push @type_args, 'F' if $remote; # no circle if not logged in $rv->{type_list} = \@type_args; my $type = $args->{type}; $type = 'none' unless $type && $type =~ /^[PCIF]$/; $type = 'none' if $type eq 'F' && !$remote; # just in case $rv->{filtered} = $type ne 'none' ? 1 : 0; # constructor for filter links $rv->{type_link} = sub { return '' if $type eq $_[0]; # no link for active selection my $typearg = $_[0] eq 'none' ? '' : $_[0]; return LJ::page_change_getargs( type => $typearg ); }; # determine which account types we need to search for my $type_opts = {}; my %opt_map = ( C => 'nousers', P => 'nocomms', I => 'nocomms', F => 'circle' ); $type_opts = { $opt_map{$type} => 1 } if defined $opt_map{$type}; my @uids = LJ::users_with_all_ints( \@intids, $type_opts ); # determine the count of the full set for comparison # (already set to intcount, unless we have multiple ints) if ( $opt_map{$type} ) { $rv->{allcount} ||= scalar LJ::users_with_all_ints( \@intids ); $rv->{allcount} //= 0; # scalar(undef) isn't zero } else { # we just did the full search; count the existing list $rv->{allcount} ||= scalar @uids; } # limit results to 500 most recently updated journals if ( scalar @uids > 500 ) { my $dbr = LJ::get_db_reader(); my $qs = join ',', map { '?' } @uids; my $uref = $dbr->selectall_arrayref( "SELECT userid FROM userusage WHERE userid IN ($qs) ORDER BY timeupdate DESC LIMIT 500", undef, @uids ); die $dbr->errstr if $dbr->err; @uids = map { $_->[0] } @$uref; } my $us = LJ::load_userids(@uids); # prepare to filter and sort the results into @ul my $typefilter = sub { $rv->{comm_count}++ if $_[0]->is_community; return 1 if $type eq 'none'; return 1 if $type eq 'F'; # already filtered return $_[0]->journaltype eq $type; }; my $should_show = sub { return $_[0]->should_show_in_search_results( for => $remote ); }; my $updated = LJ::get_timeupdate_multi( keys %$us ); my $def_upd = sub { $updated->{ $_[0]->userid } || 0 }; # let undefined values be zero for sorting purposes my @ul = sort { $def_upd->($b) <=> $def_upd->($a) || $a->user cmp $b->user } grep { $_ && $typefilter->($_) && $should_show->($_) } values %$us; $rv->{type_count} = scalar @ul if $rv->{allcount} != scalar @ul; $rv->{comm_count} = 1 if $type_opts->{nocomms}; # doesn't count if (@ul) { # pagination my $curpage = $args->{page} || 1; my %items = LJ::paging( \@ul, $curpage, 30 ); my @data; # subset of users to display on this page foreach my $u ( @{ $items{items} } ) { my $desc = LJ::ehtml( $u->prop('journaltitle') ); my $label = LJ::Lang::ml('search.user.journaltitle'); # community promo desc overrides journal title if ( $u->is_comm && LJ::is_enabled('community_themes') && ( my $prop_theme = $u->prop("comm_theme") ) ) { $label = LJ::Lang::ml('search.user.commdesc'); $desc = LJ::ehtml($prop_theme); } my $userpic = $u->userpic; $userpic = $userpic ? $userpic->imgtag_lite : ''; my $updated = $updated->{ $u->id } ? LJ::diff_ago_text( $updated->{ $u->id } ) : undef; push @data, { u => $u, updated => $updated, icon => $userpic, desc => $desc, desclabel => $label }; } $rv->{navbar} = LJ::paging_bar( $items{page}, $items{pages} ); $rv->{data} = \@data; } return DW::Template->render_template( 'interests/int.tt', $rv ); } # if we got to this point, we need to render the default template return DW::Template->render_template( 'interests/index.tt', $rv ); } 1;