#!/usr/bin/perl # This code was forked from the LiveJournal project owned and operated # by Live Journal, Inc. The code has been modified and expanded by # Dreamwidth Studios, LLC. These files were originally licensed under # the terms of the license supplied by Live Journal, Inc, which can # currently be found at: # # http://code.livejournal.org/trac/livejournal/browser/trunk/LICENSE-LiveJournal.txt # # In accordance with the original license, this code and all its # modifications are provided under the GNU General Public License. # A copy of that license can be found in the LICENSE file included as # part of this distribution. package LJ::Faq; use strict; use Carp; # Initially built in a hackathon, so this is only moderately awesome # -- whitaker 2006/06/23 # FIXME: singletons? # # name: LJ::Faq::new # class: general # des: Creates a LJ::Faq object from supplied information. # args: opts # des-opts: Hash of initial field values for the new Faq. Allowed keys are: # faqid, question, summary, answer, faqcat, lastmoduserid, sortorder, # lastmodtime, unixmodtime, and lang. Default for lang is # $LJ::DEFAULT_LANG, all others undef. # returns: The new LJ::Faq object. # sub new { my $class = shift; my $self = bless {}, $class; my %opts = @_; $self->{$_} = delete $opts{$_} foreach qw(faqid question summary answer faqcat lastmoduserid sortorder lastmodtime unixmodtime); # FIXME: shouldn't that be the root language of the faq domain instead? $self->{lang} = delete $opts{lang} || $LJ::DEFAULT_LANG; croak( "unknown parameters: " . join( ", ", keys %opts ) ) if %opts; return $self; } # # name: LJ::Faq::load # class: general # des: Creates a LJ::Faq object and populates it from the database. # args: faqid, opts? # des-faqid: The integer id of the FAQ to load. # des-opts: Hash of option key => value. # lang => language, xx or xx_YY. Defaults to $LJ::DEFAULT_LANG. # returns: The newly populated LJ::Faq object. # sub load { my $class = shift; my $faqid = int(shift); croak("invalid faqid: $faqid") unless $faqid > 0; my %opts = @_; my $lang = delete $opts{lang} || $LJ::DEFAULT_LANG; croak( "unknown parameters: " . join( ", ", keys %opts ) ) if %opts; my $dbr = LJ::get_db_reader() or die "Unable to contact global reader"; my $faq; # FIXME: shouldn't that be the root language of the faq domain instead? if ( $lang eq $LJ::DEFAULT_LANG ) { my $f = $dbr->selectrow_hashref( "SELECT faqid, question, summary, answer, faqcat, lastmoduserid, " . "DATE_FORMAT(lastmodtime, '%M %D, %Y') AS lastmodtime, " . "UNIX_TIMESTAMP(lastmodtime) AS unixmodtime, sortorder " . "FROM faq WHERE faqid=?", undef, $faqid ); die $dbr->errstr if $dbr->err; return undef unless $f; $faq = $class->new( %$f, lang => $lang ); } else { # Don't load fields that lang_update_in_place will overwrite. my $f = $dbr->selectrow_hashref( "SELECT faqid, faqcat, " . "UNIX_TIMESTAMP(lastmodtime) AS unixmodtime, sortorder " . "FROM faq WHERE faqid=?", undef, $faqid ); die $dbr->errstr if $dbr->err; return undef unless $f; $faq = $class->new( %$f, lang => $lang ); $faq->lang_update_in_place; } return $faq; } # # name: LJ::Faq::load_all # class: general # des: Creates LJ::Faq objects from all FAQs in the database. # args: opts? # des-opts: Hash of option key => value. # lang => language, xx or xx_YY. Defaults to $LJ::DEFAULT_LANG. # cat => category to load (loads FAQs in all cats if absent). # returns: Array of populated LJ::Faq objects, one per FAQ loaded. # sub load_all { my $class = shift; my $dbr = LJ::get_db_reader() or die "Unable to contact global reader"; my %opts = @_; my $lang = delete $opts{lang} || $LJ::DEFAULT_LANG; my $faqcat = delete $opts{cat}; my $allow_no_cat = delete $opts{allow_no_cat} || 0; my $wherecat = ""; if ($allow_no_cat) { $wherecat = "WHERE faqcat = " . $dbr->quote($faqcat) if defined $faqcat; } else { $wherecat = "WHERE faqcat " . ( defined $faqcat && length $faqcat ? "= " . $dbr->quote($faqcat) : "!= ''" ); } croak( "unknown parameters: " . join( ", ", keys %opts ) ) if %opts; my $sth; if ( $lang eq $LJ::DEFAULT_LANG ) { $sth = $dbr->prepare( "SELECT faqid, question, summary, answer, faqcat, lastmoduserid, " . "DATE_FORMAT(lastmodtime, '%M %D, %Y') AS lastmodtime, " . "UNIX_TIMESTAMP(lastmodtime) AS unixmodtime, sortorder " . "FROM faq $wherecat" ); } else { # Don't load fields that lang_update_in_place will overwrite. $sth = $dbr->prepare( "SELECT faqid, faqcat, " . "UNIX_TIMESTAMP(lastmodtime) AS unixmodtime, sortorder " . "FROM faq $wherecat" ); } $sth->execute; die $sth->errstr if $sth->err; my @faqs; while ( my $f = $sth->fetchrow_hashref ) { push @faqs, $class->new(%$f); } # FIXME: shouldn't that be the root language of the faq domain instead? if ( $lang ne $LJ::DEFAULT_LANG ) { $class->lang_update_in_place( $lang => @faqs ); } return @faqs; } sub faqid { my $self = shift; return $self->{faqid}; } *id = \&faqid; sub lang { my $self = shift; return LJ::Lang::get_lang( $self->{lang} ) ? $self->{lang} : $LJ::DEFAULT_LANG; } sub question_raw { my $self = shift; return $self->{question}; } sub question_html { my $self = shift; return LJ::ehtml( $self->{question} ); } sub summary_raw { my $self = shift; return $self->{summary}; } sub summary_html { my $self = shift; return LJ::ehtml( $self->{summary} ); } sub answer_raw { my $self = shift; return $self->{answer}; } sub answer_html { my $self = shift; return LJ::ehtml( $self->{answer} ); } sub faqcat { my $self = shift; return $self->{faqcat}; } sub lastmoduserid { my $self = shift; return $self->{lastmoduserid}; } sub lastmodtime { my $self = shift; return $self->{lastmodtime}; } sub unixmodtime { my $self = shift; return $self->{unixmodtime}; } sub sortorder { my $self = shift; return $self->{sortorder}; } sub url { my ( $class, $faqid ) = @_; $faqid = $class->{faqid} if ref $class; return "$LJ::SITEROOT/support/faqbrowse?faqid=$faqid"; } sub url_full { my ( $class, $faqid ) = @_; $faqid = $class->{faqid} if ref $class; return "$LJ::SITEROOT/support/faqbrowse?faqid=$faqid&view=full"; } # # name: LJ::Faq::has_summary # class: general # des: Tests whether instance has a summary # args: # returns: True value if instance has a summary, false value otherwise # sub has_summary { my $self = shift; # Translators can't save empty strings, so "-" means "empty" too. return !( $self->summary_raw eq "" || $self->summary_raw eq "-" ); } # # name: LJ::Faq::lang_update_in_place # class: general # des: Fill in question, summary and answer from database for one or more FAQs. # info: May be called either as a class method or an object method, ie: # - $self->lang_update_in_place; # - LJ::Faq->lang_update_in_place($lang, @faqs); # args: lang?, faqs? # des-lang: Language to fetch strings for (as a class method). # des-faqs: Array of LJ::Faq objects to fetch strings for (as a class method). # returns: True value if successful. # sub lang_update_in_place { my $class = shift; my ( $lang, @faqs ); if ( ref $class ) { $lang = $class->{lang}; @faqs = ($class); croak("superfluous parameters") if @_; } else { $lang = shift; @faqs = @_; croak("invalid parameters") if grep { ref $_ ne 'LJ::Faq' } @faqs; } my $faqd = LJ::Lang::get_dom("faq"); my $l = LJ::Lang::get_lang($lang) || LJ::Lang::get_lang($LJ::DEFAULT_LANG); croak("missing domain") unless $faqd; croak("invalid language: $lang") unless $l; my @load; foreach (@faqs) { push @load, "$_->{faqid}.1question"; push @load, "$_->{faqid}.3summary"; push @load, "$_->{faqid}.2answer"; } my $res = LJ::Lang::get_text_multi( $l->{'lncode'}, $faqd->{'dmid'}, \@load ); foreach (@faqs) { my $id = $_->{faqid}; $_->{question} = $res->{"$id.1question"} if $res->{"$id.1question"}; $_->{summary} = $res->{"$id.3summary"} if $res->{"$id.3summary"}; $_->{answer} = $res->{"$id.2answer"} if $res->{"$id.2answer"}; $_->{summary} = $LJ::_T_FAQ_SUMMARY_OVERRIDE if $LJ::_T_FAQ_SUMMARY_OVERRIDE; # FIXME?: the join can probably be avoided, eg by using something like # LJ::Lang::get_chgtime_unix for time of last change and a single-table # "SELECT userid FROM ml_text WHERE t.lnid=? AND t.dmid=? AND t.itid=? # ORDER BY t.txtid DESC LIMIT 1" for userid. my $itid = LJ::Lang::get_itemid( $faqd->{'dmid'}, "$id.2answer" ); if ($itid) { my $sql = "SELECT DATE_FORMAT(l.chgtime, '%Y-%m-%d'), t.userid " . "FROM ml_latest AS l, ml_text AS t WHERE l.dmid = t.dmid AND l.lnid = t.lnid AND l.itid = t.itid " . "AND l.lnid=? AND l.dmid=? AND l.itid=? ORDER BY t.txtid DESC LIMIT 1"; my $dbr = LJ::get_db_reader() or die "Unable to contact global reader"; my $sth = $dbr->prepare($sql); $sth->execute( $l->{'lnid'}, $faqd->{'dmid'}, $itid ); die $sth->errstr if $sth->err; @{$_}{ 'lastmodtime', 'lastmoduserid' } = $sth->fetchrow_array; } } return 1; } # # name: LJ::Faq::render_in_place # class: general # des: Render one or more FAQs by expanding FAQ-specific mark-up. # info: May be called either as a class method or an object method, ie: # - $self->render_in_place; # - LJ::Faq->render_in_place($lang, @faqs); # Note that username, journalurl, and journalurl:* aren't expanded here. # args: opts, faqs? # des-opts: Hashref (not hash) of options: # - lang => language to render FAQs in (as a class method). # - skipfaqs => true to skip [[faqtitle:#]] markup. # - user => what to expand [[username]] to. # - url => what to expand [[journalurl]] to. # des-faqs: Array of LJ::Faq objects to render (as a class method). # returns: True value if successful. # sub render_in_place { my ( $class, $opts, @faqs ) = @_; my $lang; if ( ref $class ) { $lang = $class->{lang}; croak("superfluous parameters") if @faqs; @faqs = ($class); } else { $lang = delete $opts->{"lang"}; croak("invalid parameters") if grep { ref $_ ne 'LJ::Faq' } @faqs; } my $user = delete $opts->{"user"}; my $user_url = delete $opts->{"url"}; my $skipfaqs = delete $opts->{"skipfaqs"}; croak( "unknown parameters: " . join( ", ", keys %$opts ) ) if %$opts; # (letter => ["name", mandatory]) my %dom_data = ( g => [ "general", 1 ], f => [ "faq", 1 ], w => [ "widget", 0 ] ); my %dom = (); my %load = (); while ( my ( $k, $d ) = each %dom_data ) { my ( $n, $m ) = @$d; $dom{$k} = LJ::Lang::get_dom($n) or $m && croak("missing $n domain"); $load{$k} = []; } my $l = LJ::Lang::get_lang($lang) || LJ::Lang::get_lang($LJ::DEFAULT_LANG); croak("invalid language: $lang") unless $l; my %seen; # Collect item codes: \[\[faqtitle:\d+\]\], \[\[[gfw]mlitem:[\w/.-]+\]\] my $collect_item_codes = sub { my $text = shift; unless ($skipfaqs) { while ( $text =~ /\[\[faqtitle:(\d+)\]\]/g ) { push @{ $load{"f"} }, "${1}.1question" unless $seen{"f:${1}.1question"}++; } } while ( $text =~ m!\[\[([gfw])mlitem:([\w/.-]+)\]\]!g ) { push @{ $load{$1} }, $2 unless $seen{"$1:$2"}++; } }; foreach my $faq (@faqs) { $collect_item_codes->( $faq->question_raw ); $collect_item_codes->( $faq->summary_raw ) if $faq->has_summary; $collect_item_codes->( $faq->answer_raw ); } my %res; foreach my $k ( keys %dom ) { $res{$k} = LJ::Lang::get_text_multi( $l->{'lncode'}, $dom{$k}->{'dmid'}, $load{$k} ); } my $err_bad_variable = sub { my $var = LJ::ehtml(shift); return "[Unknown or improper variable: $var]"; }; # Replace a variable like [[var]] or [[var:arg]] with the correct text my $replace_var = sub { my ( $var, $arg, $skipfaqs ) = @_; if ( $var eq "journalurl" ) { return $user_url unless $arg; my $u_arg = LJ::load_user($arg) or return "[Unknown username: " . LJ::ehtml($arg) . "]"; return $u_arg->journal_base || $err_bad_variable->("${var}:${arg}"); } elsif ( $var eq "username" ) { return $user unless $arg; my $u_arg = LJ::load_user($arg) or return "[Unknown username: " . LJ::ehtml($arg) . "]"; return $u_arg->user || $err_bad_variable->("${var}:${arg}"); } elsif ( $arg && $var eq "faqtitle" ) { return $skipfaqs ? "[[faqtitle:$arg]]" : ( LJ::ehtml( $res{"f"}->{"${arg}.1question"} ) || "[Unknown FAQ id: " . LJ::ehtml($arg) . "]" ); } elsif ( $arg && $var =~ /^([gfw])mlitem$/ ) { # ML item (gfw = general/FAQ/widget) return $res{$1}->{$arg} || "[Unknown item code: " . LJ::ehtml($arg) . " in domain " . LJ::ehtml( $dom_data{$1}->[0] ) . "]"; } else { # Error return $err_bad_variable->( $arg ? "${var}:${arg}" : $var ); } }; # Change [[faqtitle:id]] to the FAQ id's title/question unless $skipfaqs # Change [[(g|f|w)mlitem:code]] to that item's text in general/faq/widget my $replace_all_vars = sub { my ( $text, $skipfaqs ) = @_; $text =~ s!\[\[(\w+)(?::([\w/.-]+?))?\]\]!$replace_var->($1, $2, $skipfaqs)!eg; return $text; }; foreach my $faq (@faqs) { $faq->{question} = $replace_all_vars->( $faq->question_raw, 1 ); $faq->{summary} = $replace_all_vars->( $faq->summary_raw, $skipfaqs ) if $faq->has_summary; $faq->{answer} = $replace_all_vars->( $faq->answer_raw, $skipfaqs ); } return 1; } # # name: LJ::Faq::load_matching # class: general # des: Finds all FAQs containing a search term and ranks them by relevance. # args: term, opts? # des-term: The string to search for (case-insensitive). # des-opts: Hash of option key => value. # - lang => language to render FAQs in. # - user => what to expand [[username]] to. # - url => what to expand [[journalurl]] to. # returns: A list of LJ::Faq objects matching the search term, sorted by # decreasing relevance. # sub load_matching { my $class = shift; my $term = shift; croak("search term required") unless length( $term . "" ); my %opts = @_; my $lang = delete $opts{"lang"} || $LJ::DEFAULT_LANG; my $user = delete $opts{"user"}; my $user_url = delete $opts{"url"}; croak( "unknown parameters: " . join( ", ", keys %opts ) ) if %opts; my @faqs = $class->load_all( lang => $lang, allow_no_cat => 0 ); die "unable to load faqs" unless @faqs; my %scores = (); # faqid => score my @results = (); # array of faq objects # Render FAQs, leaving [[faqtitle:#]] intact. This is to let users search # for user interface strings without FAQ titles getting in the way. # FIXME: This also expands [[username(:foo)?]] and [[journalurl(:bar)?]]. # Should it? $class->render_in_place( { skipfaqs => 1, lang => $lang, user => $user, url => $user_url }, @faqs ) or die "initial FAQ rendering failed"; foreach my $f (@faqs) { my $score = 0; $score += 3 if $f->question_raw =~ /\Q$term\E/i; $score += 5 if $f->question_raw =~ /\b\Q$term\E\b/i; $score += 2 if $f->summary_raw =~ /\Q$term\E/i; $score += 4 if $f->summary_raw =~ /\b\Q$term\E\b/i; $score += 1 if $f->answer_raw =~ /\Q$term\E/i; $score += 3 if $f->answer_raw =~ /\b\Q$term\E\b/i; next unless $score; $scores{ $f->{faqid} } = $score; push @results, $f; } return sort { $scores{ $b->{faqid} } <=> $scores{ $a->{faqid} } } @results; } 1;