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

576 lines
19 KiB
Perl

#!/usr/bin/perl
#
# DW::Controller::Admin::FAQ
#
# For adding, organizing, and maintaining FAQs.
# Requires faqadd, faqedit, and/or faqcat privileges.
#
# Authors:
# Jen Griffin <kareila@livejournal.com>
#
# Copyright (c) 2020 by Dreamwidth Studios, LLC.
#
# This is based on code originally implemented on LiveJournal.
#
# 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::Admin::FAQ;
use strict;
use DW::Controller;
use DW::Controller::Admin;
use DW::Routing;
use DW::Template;
use LJ::Faq;
use POSIX qw( strftime );
DW::Controller::Admin->register_admin_page(
'/',
path => 'faq',
ml_scope => '/admin/faq/index.tt',
privs => [ 'faqadd', 'faqedit', 'faqcat' ]
);
DW::Routing->register_string( '/admin/faq/index', \&index_handler, app => 1, no_cache => 1 );
DW::Routing->register_string( '/admin/faq/faqedit', \&edit_handler, app => 1, no_cache => 1 );
DW::Routing->register_string( '/admin/faq/faqcat', \&cat_handler, app => 1, no_cache => 1 );
DW::Routing->register_string( '/admin/faq/readcat', \&read_handler, app => 1, no_cache => 1 );
sub _page_setup {
my ($rv) = @_;
my $vars = {};
my $remote = $rv->{remote};
my %ac_add = $remote->priv_args("faqadd");
my %ac_edit = $remote->priv_args("faqedit");
$vars->{can_add_any} = %ac_add ? 1 : 0;
$vars->{can_edit_any} = %ac_edit ? 1 : 0;
$vars->{can_add} = sub { $ac_add{ $_[0] } };
$vars->{can_edit} = sub { $ac_edit{ $_[0] } };
$vars->{can_manage} = $remote->has_priv("faqcat");
$vars->{display_faq} = sub { # to display FAQ content properly
return LJ::html_newlines( LJ::trim( $_[0] ) );
};
return $vars;
}
sub index_handler {
my ( $ok, $rv ) = controller( privcheck => [ 'faqadd', 'faqedit', 'faqcat' ] );
return $rv unless $ok;
my $vars = _page_setup($rv);
my $remote = $rv->{remote};
{ # load FAQ categories
my $dbh = LJ::get_db_writer();
my $faqcat =
$dbh->selectall_hashref( "SELECT faqcat, faqcatname, catorder FROM faqcat", 'faqcat' );
my @sorted_cats = sort { $faqcat->{$a}->{catorder} <=> $faqcat->{$b}->{catorder} }
keys %$faqcat;
# Ensure 'no category' is last.
$faqcat->{''} = { faqcat => '', faqcatname => '<No Category>' };
push @sorted_cats, '';
$vars->{faqcat} = $faqcat;
$vars->{catlist} = \@sorted_cats;
}
{ # load FAQ questions
my $dom = LJ::Lang::get_dom("faq");
my $lang = LJ::Lang::get_root_lang($dom);
my @faqs = LJ::Faq->load_all( lang => $lang->{lncode}, allow_no_cat => 1 );
my $user = $remote->user;
my $user_url = $remote->journal_base;
LJ::Faq->render_in_place( { lang => $lang, user => $user, url => $user_url }, @faqs );
# build hash of FAQs keyed by category
$vars->{faq} = {};
push( @{ $vars->{faq}->{ $_->faqcat // '' } }, $_ ) foreach @faqs;
# in each category, produce a sorted list of FAQs in that category
$vars->{faqlist} = sub {
[ sort { $a->sortorder <=> $b->sortorder } @{ $vars->{faq}->{ $_[0] } } ]
};
}
return DW::Template->render_template( 'admin/faq/index.tt', $vars );
}
sub edit_handler {
my ( $ok, $rv ) = controller( form_auth => 1, privcheck => [ 'faqadd', 'faqedit' ] );
return $rv unless $ok;
my $scope = '/admin/faq/faqedit.tt';
my $r = $rv->{r};
my $form_args = $r->did_post ? $r->post_args : $r->get_args;
my $vars = _page_setup($rv);
{ # translation setup
my $faqd = LJ::Lang::get_dom("faq");
my $rlang = LJ::Lang::get_root_lang($faqd);
$vars->{dmid} = $faqd->{dmid} if $faqd;
$vars->{lang} = $rlang->{lncode};
}
# setup for add vs. edit
if ( !$form_args->{id} ) {
return error_ml("$scope.error.noaccess.add")
unless $vars->{can_add_any};
}
else {
my $faqid = $form_args->{id} + 0;
my $faq = LJ::Faq->load( $faqid, lang => $vars->{lang} );
return error_ml( "$scope.error.notfound", { id => $faqid } ) unless $faq;
my $can_edit = $vars->{can_edit};
return error_ml( "$scope.error.noaccess" . $vars->{can_edit_any} ? '.editcat' : '.edit',
{ cat => $faq->faqcat } )
unless $can_edit->('*') || $can_edit->('') || $can_edit->( $faq->faqcat );
# initialize data variables with previously saved FAQ data
$vars->{id} = $faq->id;
$vars->{faqcat} = $faq->faqcat // '';
$vars->{sortorder} = $faq->sortorder + 0;
$vars->{question} = $faq->question_raw;
$vars->{summary} = $faq->summary_raw;
$vars->{answer} = $faq->answer_raw;
$vars->{has_summary} = $faq->has_summary;
}
$vars->{sortorder} ||= 50;
if ( $r->did_post ) { # overwrite with form data
$vars->{faqcat} = $form_args->{'faqcat'} // '';
$vars->{sortorder} = $form_args->{'sortorder'} + 0 || 50;
$vars->{question} = $form_args->{'q'};
$vars->{answer} = $form_args->{'a'};
# If summary is disabled or not present, pretend it was unchanged
$vars->{summary} = $form_args->{'s'}
if LJ::is_enabled('faq_summaries') && defined $form_args->{'s'};
}
my $dbh = LJ::get_db_writer();
my $remote = $rv->{remote};
if ( $r->post_args->{'action:save'} ) {
# severity options are deprecated - always use 0
my $text_opts = { changeseverity => 0 };
my $do_trans = sub {
my $id = $_[0];
return unless $vars->{dmid};
my @lang = ( $vars->{dmid}, $vars->{lang} );
LJ::Lang::set_text( @lang, "$id.1question", $vars->{question}, $text_opts );
LJ::Lang::set_text( @lang, "$id.2answer", $vars->{answer}, $text_opts );
LJ::Lang::set_text( @lang, "$id.3summary", $vars->{summary}, $text_opts )
if LJ::is_enabled('faq_summaries');
};
if ( !$vars->{id} ) { # create new FAQ
$dbh->do(
qq{ INSERT INTO faq
( faqid, question, summary, answer, faqcat,
sortorder, lastmoduserid, lastmodtime )
VALUES ( NULL, ?, ?, ?, ?, ?, ?, NOW() ) },
undef, $vars->{question}, $vars->{summary}, $vars->{answer},
$vars->{faqcat}, $vars->{sortorder}, $remote->id
);
return error_ml( "$scope.error.db", { err => $dbh->errstr } )
if $dbh->err;
$vars->{id} = $dbh->{mysql_insertid};
$text_opts->{childrenlatest} = 1;
if ( $vars->{id} ) {
$do_trans->( $vars->{id} );
$vars->{success} = LJ::Lang::ml( "$scope.success.add",
{ id => $vars->{id}, url => LJ::Faq->url( $vars->{id} ) } );
}
}
elsif ( $vars->{question} =~ /\S/ ) { # edit existing FAQ
$dbh->do(
qq{ UPDATE faq SET question=?, summary=?, answer=?,
faqcat=?, sortorder=?, lastmoduserid=?,
lastmodtime=NOW() WHERE faqid=? },
undef, $vars->{question}, $vars->{summary}, $vars->{answer},
$vars->{faqcat}, $vars->{sortorder}, $remote->id, $vars->{id}
);
return error_ml( "$scope.error.db", { err => $dbh->errstr } )
if $dbh->err;
$do_trans->( $vars->{id} );
$vars->{success} = LJ::Lang::ml( "$scope.success.edit",
{ id => $vars->{id}, url => LJ::Faq->url( $vars->{id} ) } );
}
else { # delete this FAQ
$dbh->do( "DELETE FROM faq WHERE faqid=?", undef, $vars->{id} );
$vars->{success} = LJ::Lang::ml("$scope.success.del");
# TODO: delete translation from ml_* ?
}
return DW::Template->render_template( 'admin/faq/faqedit.tt', $vars );
} # end action:save
if ( $r->post_args->{'action:preview'} ) {
my %faq_args = (
faqid => $vars->{id},
lastmoduserid => $remote->id,
lastmodtime => strftime( '%B %e, %Y', gmtime ),
unixmodtime => time,
);
$faq_args{$_} = $vars->{$_} foreach qw( faqcat question summary answer sortorder lang );
my $fake_faq = LJ::Faq->new(%faq_args);
$fake_faq->render_in_place( { user => $remote->user, url => $remote->journal_base } );
$vars->{preview_faq} = $fake_faq;
$vars->{remote} = $remote;
# Display summary if enabled and present.
$vars->{preview_summary} = $fake_faq->has_summary && LJ::is_enabled('faq_summaries');
# Clean this as if it were an entry, but don't allow lj-cuts
my $s_html = $fake_faq->summary_html;
LJ::CleanHTML::clean_event( \$s_html, { ljcut_disable => 1 } )
if $vars->{preview_summary};
my $a_html = $fake_faq->answer_raw;
LJ::CleanHTML::clean_event( \$a_html, { ljcut_disable => 1 } );
$vars->{s_html} = $s_html;
$vars->{a_html} = $a_html;
} # end action:preview
{ # load FAQ categories that remote has permission to use
my $faqcat =
$dbh->selectall_arrayref("SELECT faqcat, faqcatname FROM faqcat ORDER BY catorder");
my @catmenu = ( '', '' );
foreach my $cat (@$faqcat) {
push( @catmenu, @$cat )
if $vars->{can_add}->('*')
|| $vars->{can_add}->( $cat->[0] )
|| $cat->[0] eq $vars->{faqcat};
}
if ( scalar @catmenu == 2 ) {
push( @catmenu, '', LJ::Lang::ml("$scope.error.nocats") );
}
$vars->{catmenu} = \@catmenu;
}
# If FAQ has summary and summaries are disabled, leave field in,
# but make it read-only to let FAQ editors copy from it.
$vars->{show_summary} = LJ::is_enabled('faq_summaries') || $vars->{has_summary};
$vars->{readonly_summary} = LJ::is_enabled('faq_summaries') ? 0 : 1;
return DW::Template->render_template( 'admin/faq/faqedit.tt', $vars );
}
sub cat_handler {
my ( $ok, $rv ) = controller( form_auth => 1, privcheck => ['faqcat'] );
return $rv unless $ok;
my $scope = '/admin/faq/faqcat.tt';
my $r = $rv->{r};
my $form_args = $r->post_args;
my $vars = {};
my $dbh = LJ::get_db_writer();
# helper function for reordering categories
my $move_cat = sub {
my ( $direction, $faqcat ) = @_;
my %pre; # catkey -> key before
my %post; # catkey -> key after
my %catorder; # catkey -> order
my $sth = $dbh->prepare("SELECT faqcat, catorder FROM faqcat ORDER BY catorder");
$sth->execute;
my $last;
while ( my ( $key, $order ) = $sth->fetchrow_array ) {
if ( defined $last ) {
$post{$last} = $key;
$pre{$key} = $last;
}
$catorder{$key} = $order;
$last = $key;
}
my %new; # catkey -> new order
if ( $direction eq "up" ) {
$new{$faqcat} = $catorder{ $pre{$faqcat} };
$new{ $pre{$faqcat} } = $catorder{$faqcat};
}
elsif ( $direction eq "down" ) {
$new{$faqcat} = $catorder{ $post{$faqcat} };
$new{ $post{$faqcat} } = $catorder{$faqcat};
}
foreach my $n ( keys %new ) {
$dbh->do( "UPDATE faqcat SET catorder=? WHERE faqcat=?", undef, $new{$n}, $n );
}
};
if ( $r->did_post ) {
my $faqcat = $form_args->{'faqcat'};
# If coming from the cat list, see if we're editing/sorting/deleting
foreach ( split( ",", $form_args->{'faqcats'} // '' ) ) {
$faqcat = $_ if ( $form_args->{"edit:$_"} );
$faqcat = $_ if ( $form_args->{"sortup:$_"} );
$faqcat = $_ if ( $form_args->{"sortdown:$_"} );
$faqcat = $_ if ( $form_args->{"delete:$_"} );
}
if ($faqcat) {
my $action_setup = sub {
my $faqcatname = $_[0];
my $faqd = LJ::Lang::get_dom("faq");
my $rlang = LJ::Lang::get_root_lang($faqd);
undef $faqd unless $rlang;
LJ::Lang::set_text( $faqd->{dmid}, $rlang->{lncode},
"cat.$faqcatname", $faqcatname, { changeseverity => 1 } )
if $faqd;
};
# See if we're adding a new FAQ from the cat list
if ( $form_args->{'action'} && $form_args->{'action'} eq "add" ) {
my $faqcatname = LJ::trim( $form_args->{faqcatname} );
my $faqcatorder = $form_args->{faqcatorder} // 0;
$action_setup->($faqcatname);
$dbh->do(
"REPLACE INTO faqcat
( faqcat, faqcatname, catorder )
VALUES ( ?, ?, ? )",
undef, $faqcat, $faqcatname, $faqcatorder
);
$vars->{success} = LJ::Lang::ml("$scope.addcat.success");
}
# See if we're saving an edited FAQ from the edit form
elsif ( $form_args->{'action'} && $form_args->{'action'} eq "save" ) {
$faqcat = $form_args->{faqcat};
my $faqcatname = LJ::trim( $form_args->{faqcatname} );
my $faqcatorder = $form_args->{faqcatorder} // 0;
$action_setup->($faqcatname);
$dbh->do(
"UPDATE faqcat
SET faqcatname=?, catorder=?
WHERE faqcat=?",
undef, $faqcatname, $faqcatorder, $faqcat
);
$vars->{success} = LJ::Lang::ml("$scope.editcat.success");
}
# See if we're loading the edit form for a cat
elsif ( $form_args->{"edit:$faqcat"} ) {
my $sth = $dbh->prepare(
"SELECT faqcat, faqcatname, catorder
FROM faqcat WHERE faqcat=?"
);
$sth->execute($faqcat);
my ($faqcatdata) = $sth->fetchrow_hashref;
$vars->{faqcatdata} = $faqcatdata;
return DW::Template->render_template( 'admin/faq/editcat.tt', $vars );
}
# See if we're sorting a category up the order
elsif ( $form_args->{"sortup:$faqcat"} ) {
$move_cat->( "up", $faqcat );
$vars->{success} =
LJ::Lang::ml( "$scope.catsort.success", { direction => "up" } );
}
# See if we're sorting a category down the order
elsif ( $form_args->{"sortdown:$faqcat"} ) {
$move_cat->( "down", $faqcat );
$vars->{success} =
LJ::Lang::ml( "$scope.catsort.success", { direction => "down" } );
}
# See if we're deleting a FAQ category
elsif ( $form_args->{"delete:$faqcat"} ) {
my $ct = $dbh->do( "DELETE FROM faqcat WHERE faqcat=?", undef, $faqcat );
$vars->{success} = LJ::Lang::ml(
$ct
? "$scope.deletecat.success"
: "$scope.error.unknowncatkey"
);
}
}
}
# Show category list and add form
{
my %faqcat;
my $sth = $dbh->prepare("SELECT faqcat, faqcatname, catorder FROM faqcat");
$sth->execute;
$faqcat{ $_->{faqcat} } = $_ while $_ = $sth->fetchrow_hashref;
$vars->{faqcat} = \%faqcat;
$vars->{faqcats} = join( ",", map { $_->{faqcat} } values %faqcat );
$vars->{catlist} = [
sort { $faqcat{$a}->{catorder} <=> $faqcat{$b}->{catorder} }
keys %faqcat
];
}
$vars->{confirm_delete} = LJ::ejs( LJ::Lang::ml("$scope.deletecat.confirm") );
return DW::Template->render_template( 'admin/faq/faqcat.tt', $vars );
}
sub read_handler {
my ( $ok, $rv ) = controller( anonymous => 1 );
return $rv unless $ok;
# this page is public, but it's only linked from /admin/faq
my $scope = '/admin/faq/readcat.tt';
my $r = $rv->{r};
my $form_args = $r->get_args;
my $vars = _page_setup($rv);
my $remote = $rv->{remote};
{ # load requested category name
my $faqcatname = "<No Category>";
my $faqcat = $form_args->{faqcat} || '';
if ( $faqcat ne '' ) {
my $dbh = LJ::get_db_writer();
my $sth = $dbh->prepare("SELECT faqcatname FROM faqcat WHERE faqcat=?");
$sth->execute($faqcat);
($faqcatname) = $sth->fetchrow_array;
}
return error_ml( "$scope.error.catnotfound", { faqcat => LJ::ehtml($faqcat) } )
unless defined $faqcatname;
$vars->{faqcat} = $faqcat;
$vars->{faqcatname} = $faqcatname;
}
{ # load FAQ questions
my $dom = LJ::Lang::get_dom("faq");
my $lang = LJ::Lang::get_root_lang($dom);
my @faqs = LJ::Faq->load_all(
lang => $lang->{lncode},
cat => $vars->{faqcat},
allow_no_cat => 1
);
my $user;
my $user_url;
# Get remote username and journal URL, or example user's username and journal URL
if ($remote) {
$user = $remote->user;
$user_url = $remote->journal_base;
}
else {
my $u = LJ::load_user($LJ::EXAMPLE_USER_ACCOUNT);
my $unknown = "<b>[Unknown or undefined example username]</b>";
$user = $u ? $u->user : $unknown;
$user_url = $u ? $u->journal_base : $unknown;
}
LJ::Faq->render_in_place( { lang => $lang, user => $user, url => $user_url }, @faqs );
$vars->{faqs} = [ sort { $a->sortorder <=> $b->sortorder } @faqs ];
}
# ugh BML, but DW::Request doesn't appear to have a similar function
$vars->{note_mod_time} = sub { BML::note_mod_time( $_[0] ) };
# Display summary if enabled and present.
$vars->{display_summary} = $_[0] && LJ::is_enabled('faq_summaries');
$vars->{clean_content} = sub {
my $txt = LJ::trim( $_[0] );
$txt =~ s/\n( +)/"\n" . "&nbsp;&nbsp;"x length( $1 )/eg;
LJ::CleanHTML::clean_event( \$txt, { ljcut_disable => 1 } );
return $txt;
};
return DW::Template->render_template( 'admin/faq/readcat.tt', $vars );
}
1;