mourningdove/cgi-bin/DW/Controller/Search/Interests.pm
2026-05-24 01:03:05 +00:00

503 lines
18 KiB
Perl

#!/usr/bin/perl
#
# DW::Controller::Search::Interests
#
# Interest search, based on code from LiveJournal.
#
# Authors:
# Jen Griffin <kareila@livejournal.com>
#
# 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} . "<br />" : '' )
. 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;