From 635873783bc6cd9266f818d4f6381d33bcfc257a Mon Sep 17 00:00:00 2001 From: "K. Andrew Parker" Date: Fri, 19 May 2023 14:10:36 -0400 Subject: [PATCH 01/14] Static file support if baseURL is nonempty --- lib/RenderApp.pm | 28 ++++++++++++------------- lib/RenderApp/Controller/StaticFiles.pm | 4 ++++ 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/lib/RenderApp.pm b/lib/RenderApp.pm index f63095074..a8f64d73d 100644 --- a/lib/RenderApp.pm +++ b/lib/RenderApp.pm @@ -107,20 +107,20 @@ sub startup { $r->any('/pg_files/CAPA_Graphics/*static')->to('StaticFiles#CAPA_graphics_file'); $r->any('/pg_files/tmp/*static')->to('StaticFiles#temp_file'); $r->any('/pg_files/*static')->to('StaticFiles#pg_file'); - - # any other requests fall through - $r->any('/*fail' => sub { - my $c = shift; - my $report = $c->stash('fail')."\nCOOKIE:"; - for my $cookie (@{$c->req->cookies}) { - $report .= "\n".$cookie->to_string; - } - $report .= "\nFORM DATA:"; - foreach my $k (@{$c->req->params->names}) { - $report .= "\n$k = ".join ', ', @{$c->req->params->every_param($k)}; - } - $c->log->fatal($report); - $c->rendered(404)}); + $r->any('/*fail')->to('StaticFiles#public_file'); + # # any other requests fall through + # $r->any('/*fail' => sub { + # my $c = shift; + # my $report = $c->stash('fail')."\nCOOKIE:"; + # for my $cookie (@{$c->req->cookies}) { + # $report .= "\n".$cookie->to_string; + # } + # $report .= "\nFORM DATA:"; + # foreach my $k (@{$c->req->params->names}) { + # $report .= "\n$k = ".join ', ', @{$c->req->params->every_param($k)}; + # } + # $c->log->fatal($report); + # $c->rendered(404)}); } 1; diff --git a/lib/RenderApp/Controller/StaticFiles.pm b/lib/RenderApp/Controller/StaticFiles.pm index faf8bea20..6695d53f5 100644 --- a/lib/RenderApp/Controller/StaticFiles.pm +++ b/lib/RenderApp/Controller/StaticFiles.pm @@ -28,4 +28,8 @@ sub pg_file ($c) { $c->reply_with_file_if_readable(path($ENV{PG_ROOT}, 'htdocs', $c->stash('static'))); } +sub public_file($c) { + $c->reply_with_file_if_readable($c->app->home->child('public', $c->stash('fail'))); +} + 1; From accb165c66200b6859fec7c21d0e394e3bbb7aa6 Mon Sep 17 00:00:00 2001 From: "K. Andrew Parker" Date: Fri, 19 May 2023 14:16:30 -0400 Subject: [PATCH 02/14] Convert static URLs from relative to absolute --- lib/RenderApp/Controller/FormatRenderedProblem.pm | 4 ++-- templates/exception.html.ep | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/RenderApp/Controller/FormatRenderedProblem.pm b/lib/RenderApp/Controller/FormatRenderedProblem.pm index a73928381..8ca271591 100755 --- a/lib/RenderApp/Controller/FormatRenderedProblem.pm +++ b/lib/RenderApp/Controller/FormatRenderedProblem.pm @@ -255,7 +255,7 @@ sub formatRenderedProblem { } else { my $url = getAssetURL($self->{inputs_ref}{language} // 'en', $_->{file}); push @{ $rh_result->{js} }, $SITE_URL.$url; - $extra_js_files .= CGI::script({ src => $url, %attributes }, ''); + $extra_js_files .= CGI::script({ src => $SITE_URL.$url, %attributes }, ''); } } } @@ -278,7 +278,7 @@ sub formatRenderedProblem { } else { my $url = getAssetURL($self->{inputs_ref}{language} // 'en', $_->{file}); push @{ $rh_result->{css} }, $SITE_URL.$url; - $extra_css_files .= CGI::Link({ href => $url, rel => 'stylesheet' }); + $extra_css_files .= CGI::Link({ href => $SITE_URL.$url, rel => 'stylesheet' }); } } diff --git a/templates/exception.html.ep b/templates/exception.html.ep index 2310d32ed..4c93b0964 100644 --- a/templates/exception.html.ep +++ b/templates/exception.html.ep @@ -1,5 +1,5 @@ -%= stylesheet $ENV{SITE_HOST} . '/typing-sim.css' -%= stylesheet $ENV{SITE_HOST} . '/crt-display.css' +%= stylesheet $ENV{baseURL} . '/typing-sim.css' +%= stylesheet $ENV{baseURL} . '/crt-display.css' %= javascript begin window.onload = function() { var i = 0; From 3799b1149b86b9851d02bad826f2c2aa9350ba14 Mon Sep 17 00:00:00 2001 From: "K. Andrew Parker" Date: Fri, 19 May 2023 14:23:54 -0400 Subject: [PATCH 03/14] Convert all problem requests to JWT --- lib/RenderApp/Controller/Render.pm | 14 +++++++++++--- lib/RenderApp/Controller/RenderProblem.pm | 2 +- lib/WebworkClient/classic_format.pl | 9 +-------- lib/WebworkClient/nosubmit_format.pl | 7 +------ lib/WebworkClient/practice_format.pl | 8 +------- lib/WebworkClient/simple_format.pl | 9 +-------- lib/WebworkClient/single_format.pl | 9 +-------- lib/WebworkClient/standard_format.pl | 17 +---------------- lib/WebworkClient/static_format.pl | 9 +-------- lib/WebworkClient/ww3_format.pl | 8 +------- 10 files changed, 20 insertions(+), 72 deletions(-) diff --git a/lib/RenderApp/Controller/Render.pm b/lib/RenderApp/Controller/Render.pm index 6e4966a16..7b2b10b44 100644 --- a/lib/RenderApp/Controller/Render.pm +++ b/lib/RenderApp/Controller/Render.pm @@ -37,7 +37,6 @@ sub parseRequest { foreach my $key (keys %$claims) { $params{$key} //= $claims->{$key}; } - # @params{ keys %$claims } = values %$claims; } # problemJWT sets basic problem request configuration and rendering options @@ -60,6 +59,17 @@ sub parseRequest { # $claims->{problemJWT} = $problemJWT; # because we're merging claims, this is unnecessary? # override key-values in params with those provided in the JWT @params{ keys %$claims } = values %$claims; + } else { + # if no JWT is provided, create one + $params{aud} = $ENV{SITE_HOST}; + my $req_jwt = encode_jwt( + payload => \%params, + key => $ENV{problemJWTsecret}, + alg => 'PBES2-HS512+A256KW', + enc => 'A256GCM', + auto_iat => 1 + ); + $params{problemJWT} = $req_jwt; } return \%params; } @@ -280,7 +290,6 @@ sub jweFromRequest { my $inputs_ref = $c->parseRequest; return unless $inputs_ref; $inputs_ref->{aud} = $ENV{SITE_HOST}; - $inputs_ref->{key} = $ENV{problemJWTsecret}; my $req_jwt = encode_jwt( payload => $inputs_ref, key => $ENV{problemJWTsecret}, @@ -296,7 +305,6 @@ sub jwtFromRequest { my $inputs_ref = $c->parseRequest; return unless $inputs_ref; $inputs_ref->{aud} = $ENV{SITE_HOST}; - $inputs_ref->{key} = $ENV{problemJWTsecret}; my $req_jwt = encode_jwt( payload => $inputs_ref, key => $ENV{problemJWTsecret}, diff --git a/lib/RenderApp/Controller/RenderProblem.pm b/lib/RenderApp/Controller/RenderProblem.pm index 04dc4001e..13dc13145 100644 --- a/lib/RenderApp/Controller/RenderProblem.pm +++ b/lib/RenderApp/Controller/RenderProblem.pm @@ -427,7 +427,7 @@ sub get_current_process_memory { sub generateJWTs { my $pg = shift; my $inputs_ref = shift; - my $sessionHash = {'answersSubmitted' => 1, 'iss' =>$ENV{SITE_HOST}}; + my $sessionHash = {'answersSubmitted' => 1, 'iss' =>$ENV{SITE_HOST}, problemJWT => $inputs_ref->{problemJWT}}; my $scoreHash = {}; # if no problemJWT exists, then why bother? diff --git a/lib/WebworkClient/classic_format.pl b/lib/WebworkClient/classic_format.pl index fd8376ec4..1d800d5b9 100644 --- a/lib/WebworkClient/classic_format.pl +++ b/lib/WebworkClient/classic_format.pl @@ -42,14 +42,7 @@ $scoreSummary - - - - - - - - +

diff --git a/lib/WebworkClient/nosubmit_format.pl b/lib/WebworkClient/nosubmit_format.pl index 1dd80d03a..0a5606096 100644 --- a/lib/WebworkClient/nosubmit_format.pl +++ b/lib/WebworkClient/nosubmit_format.pl @@ -39,12 +39,7 @@ $scoreSummary - - - - - - + diff --git a/lib/WebworkClient/practice_format.pl b/lib/WebworkClient/practice_format.pl index 303dc099b..b9fdc5f29 100644 --- a/lib/WebworkClient/practice_format.pl +++ b/lib/WebworkClient/practice_format.pl @@ -42,13 +42,7 @@ $scoreSummary - - - - - - - +

diff --git a/lib/WebworkClient/simple_format.pl b/lib/WebworkClient/simple_format.pl index 630490c3a..568df1b53 100644 --- a/lib/WebworkClient/simple_format.pl +++ b/lib/WebworkClient/simple_format.pl @@ -42,14 +42,7 @@ $scoreSummary - - - - - - - - +

diff --git a/lib/WebworkClient/single_format.pl b/lib/WebworkClient/single_format.pl index 3aac0e498..570947795 100644 --- a/lib/WebworkClient/single_format.pl +++ b/lib/WebworkClient/single_format.pl @@ -41,14 +41,7 @@ $scoreSummary - - - - - - - - +

diff --git a/lib/WebworkClient/standard_format.pl b/lib/WebworkClient/standard_format.pl index bb9f2212c..4c7eb5919 100644 --- a/lib/WebworkClient/standard_format.pl +++ b/lib/WebworkClient/standard_format.pl @@ -41,22 +41,7 @@ $scoreSummary - - - - - - - - - - - - - - - - +

Show:   diff --git a/lib/WebworkClient/static_format.pl b/lib/WebworkClient/static_format.pl index 443a05a5f..b6c28b4bb 100644 --- a/lib/WebworkClient/static_format.pl +++ b/lib/WebworkClient/static_format.pl @@ -39,14 +39,7 @@ $scoreSummary - - - - - - - - + diff --git a/lib/WebworkClient/ww3_format.pl b/lib/WebworkClient/ww3_format.pl index 7801b40c2..320676457 100644 --- a/lib/WebworkClient/ww3_format.pl +++ b/lib/WebworkClient/ww3_format.pl @@ -9,13 +9,7 @@ $problemText - - - - - - - + ENDPROBLEMTEMPLATE }; From 381c1ff61ee215897a9156861534bb15c0e0217f Mon Sep 17 00:00:00 2001 From: "K. Andrew Parker" Date: Mon, 22 May 2023 11:28:29 -0400 Subject: [PATCH 04/14] do not require provided sourcecode to be in base64 --- lib/RenderApp/Controller/Render.pm | 2 +- lib/RenderApp/Model/Problem.pm | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/RenderApp/Controller/Render.pm b/lib/RenderApp/Controller/Render.pm index 7b2b10b44..57be59d93 100644 --- a/lib/RenderApp/Controller/Render.pm +++ b/lib/RenderApp/Controller/Render.pm @@ -91,7 +91,7 @@ sub fetchRemoteSource_p { then( sub { my $tx = shift; - return encode_base64($tx->result->body); + return $tx->result->body; })-> catch( sub { diff --git a/lib/RenderApp/Model/Problem.pm b/lib/RenderApp/Model/Problem.pm index b435a29ac..8747231ec 100644 --- a/lib/RenderApp/Model/Problem.pm +++ b/lib/RenderApp/Model/Problem.pm @@ -8,6 +8,7 @@ use Mojo::IOLoop; use Mojo::JSON qw( encode_json ); use Mojo::Base -async_await; use Time::HiRes qw( time ); +use MIME::Base64 qw( decode_base64 ); use RenderApp::Controller::RenderProblem; ##### Problem params: ##### @@ -88,9 +89,12 @@ sub source { if ( scalar(@_) == 1 ) { my $contents = shift; + # recognize and decode base64 if necessary + $contents = Encode::decode( "UTF-8", decode_base64($contents) ) + if ( $contents =~ m!^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$!); + # UNIX style line-endings are required - $contents =~ s/\r\n/\n/g; - $contents =~ s/\r/\n/g; + $contents =~ s!\r\n?!\n!g; $self->{problem_contents} = $contents; } return $self->{problem_contents}; @@ -131,7 +135,8 @@ sub path { } $self->{_error} = "404 I cannot find a problem with that file path." unless ( -e $read_path || $force ); - $self->{read_path} = Mojo::File->new($read_path); + # if we objectify an empty string, it becomes truth-y -- AVOID! + $self->{read_path} = Mojo::File->new($read_path) if $read_path; } return $self->{read_path}; } From 3ac56a065f125becead6200505562923fa09c3ab Mon Sep 17 00:00:00 2001 From: "K. Andrew Parker" Date: Mon, 22 May 2023 11:37:51 -0400 Subject: [PATCH 05/14] refactor and cleanup --- lib/RenderApp/Controller/RenderProblem.pm | 259 ++++++---------------- 1 file changed, 66 insertions(+), 193 deletions(-) diff --git a/lib/RenderApp/Controller/RenderProblem.pm b/lib/RenderApp/Controller/RenderProblem.pm index 13dc13145..61fcaba72 100644 --- a/lib/RenderApp/Controller/RenderProblem.pm +++ b/lib/RenderApp/Controller/RenderProblem.pm @@ -26,8 +26,8 @@ use WeBWorK::Utils::Tags; use WeBWorK::Localize; use RenderApp::Controller::FormatRenderedProblem; -use 5.10.0; -$Carp::Verbose = 1; +# use 5.10.0; +# $Carp::Verbose = 1; ### verbose output when UNIT_TESTS_ON =1; our $UNIT_TESTS_ON = 0; @@ -38,7 +38,7 @@ our $UNIT_TESTS_ON = 0; # create log files :: expendable ################################################## -my $path_to_log_file = 'logs/standalone_results.log'; +my $path_to_log_file = "$ENV{RENDER_ROOT}/logs/standalone_results.log"; eval { # attempt to create log file local (*FH); @@ -76,35 +76,33 @@ sub UNIVERSAL::TO_JSON { sub process_pg_file { my $problem = shift; - my $inputHash = shift; - - my $file_path = $problem->path; - my $problem_seed = $problem->seed || '666'; + my $inputs_ref = shift; # just make sure we have the fundamentals covered... - $inputHash->{displayMode} //= 'MathJax'; - $inputHash->{sourceFilePath} ||= $file_path; - $inputHash->{outputFormat} ||= 'static'; - $inputHash->{language} ||= 'en'; + $inputs_ref->{displayMode} ||= 'MathJax'; + $inputs_ref->{outputFormat} ||= 'static'; + $inputs_ref->{language} ||= 'en'; # HACK: required for problemRandomize.pl - $inputHash->{effectiveUser} = 'red.ted'; - $inputHash->{user} = 'red.ted'; - - # OTHER fundamentals - urls have been handled already... - # form_action_url => $inputHash->{form_action_url}||'http://failure.org', - # base_url => $inputHash->{base_url}||'http://failure.org' - # #psvn => $psvn//'23456', # DEPRECATED - # #forcePortNumber => $credentials{forcePortNumber}//'', + $inputs_ref->{effectiveUser} = 'red.ted'; + $inputs_ref->{user} = 'red.ted'; - my $pg_start = - time; # this is Time::HiRes's time, which gives floating point values + my $pg_start = time; + my $memory_use_start = get_current_process_memory(); my ( $error_flag, $formatter, $error_string ) = - process_problem( $file_path, $inputHash ); + process_problem( $problem, $inputs_ref ); my $pg_stop = time; my $pg_duration = $pg_stop - $pg_start; + my $log_file_path = $problem->path() || 'source provided without path'; + my $memory_use_end = get_current_process_memory(); + my $memory_use = $memory_use_end - $memory_use_start; + writeRenderLogEntry( + sprintf( "(duration: %.3f sec) ", $pg_duration ) + . sprintf( "{memory: %6d bytes} ", $memory_use ) + . "file: $log_file_path" + ); # format result my $html = $formatter->formatRenderedProblem; @@ -122,15 +120,15 @@ sub process_pg_file { problem_state => $pg_obj->{problem_state}, flags => $pg_obj->{flags}, resources => { - regex => $pg_obj->{resources}, - tags => $pg_obj->{pgResources}, + regex => $pg_obj->{pgResources}, + alias => $pg_obj->{resources}, js => $pg_obj->{js}, css => $pg_obj->{css}, }, - form_data => $inputHash, + form_data => $inputs_ref, raw_metadata_text => $pg_obj->{raw_metadata_text}, JWT => { - problem => $inputHash->{problemJWT}, + problem => $inputs_ref->{problemJWT}, session => $pg_obj->{sessionJWT}, answer => $pg_obj->{answerJWT} }, @@ -145,7 +143,7 @@ sub process_pg_file { if $json_rh->{flags}{compoundProblem}{grader}; - $json_rh->{tags} = WeBWorK::Utils::Tags->new($file_path, $inputHash->{problemSource}) if ( $inputHash->{includeTags} ); + $json_rh->{tags} = WeBWorK::Utils::Tags->new('', $problem->source) if ( $inputs_ref->{includeTags} ); my $coder = JSON::XS->new->ascii->pretty->allow_unknown->convert_blessed; my $json = $coder->encode($json_rh); return $json; @@ -156,101 +154,36 @@ sub process_pg_file { ####################################################################### sub process_problem { - my $file_path = shift; + my $problem = shift; my $inputs_ref = shift; - my $adj_file_path; - my $source; - - # obsolete if using JSON return format - # These can FORCE display of AnsGroup AnsHash PGInfo and ResourceInfo - # $inputs_ref->{showAnsGroupInfo} = 1; #$print_answer_group; - # $inputs_ref->{showAnsHashInfo} = 1; #$print_answer_hash; - # $inputs_ref->{showPGInfo} = 1; #$print_pg_hash; - # $inputs_ref->{showResourceInfo} = 1; #$print_resource_hash; - - ### stash inputs that get wiped by PG - my $problem_seed = $inputs_ref->{problemSeed}; - die "problem seed not defined in Controller::RenderProblem::process_problem" - unless $problem_seed; - - # if base64 source is provided, use that over fetching problem path - if ( $inputs_ref->{problemSource} && $inputs_ref->{problemSource} =~ m/\S/ ) - { - # such hackery - but Mojo::Promises are so well-built that they are invisible - # ... until you leave the Mojo space - $inputs_ref->{problemSource} = $inputs_ref->{problemSource}{results}[0] if $inputs_ref->{problemSource} =~ /Mojo::Promise/; - # sanitize the base64 encoded source - $inputs_ref->{problemSource} =~ s/\s//gm; - # while ($source =~ /([^A-Za-z0-9+])/gm) { - # warn "invalid character found: ".sprintf( "\\u%04x", ord($1) )."\n"; - # } - $source = Encode::decode("UTF-8", decode_base64( $inputs_ref->{problemSource} ) ); - } - else { - ( $adj_file_path, $source ) = get_source($file_path); + my $source = $problem->{problem_contents}; + my $file_path = $inputs_ref->{sourceFilePath}; - # WHY are there so many fields in which to stash the file path? - #$inputs_ref->{fileName} = $adj_file_path; - #$inputs_ref->{probFileName} = $adj_file_path; - #$inputs_ref->{sourceFilePath} = $adj_file_path; - #$inputs_ref->{pathToProblemFile} = $adj_file_path; - } - my $raw_metadata_text = $1 if ($source =~ /(.*?)DOCUMENT\(\s*\)\s*;/s); $inputs_ref->{problemUUID} = md5_hex(Encode::encode_utf8($source)); - # TODO verify line ending are LF instead of CRLF - - # included (external) pg content is not recorded by PGalias + # external dependencies on pg content is not recorded by PGalias # record the dependency separately -- TODO: incorporate into PG.pl or PGcore? - my $pgResources = []; + my @pgResources; while ($source =~ m/includePG(?:problem|file)\(["'](.*)["']\);/g ) { warn "PG asset reference found: $1\n" if $UNIT_TESTS_ON; - push @$pgResources, $1; + push @pgResources, $1; } - # # this does not capture _all_ image asset references, unfortunately... - # # asset filenames may be stored as variables before image() is called - # while ($source =~ m/image\(\s*("[^\$]+?"|'[^\$]+?')\s*[,\)]/g) { - # warn "Image asset reference found!\n" . $1 . "\n" if $UNIT_TESTS_ON; - # my $image = $1; - # $image =~ s/['"]//g; - # $image = dirname($file_path) . '/' . $image if ($image =~ /^[^\/]*\.(?:gif|jpg|jpeg|png)$/i); - # warn "Recording image asset as: $image\n" if $UNIT_TESTS_ON; - # push @$assets, $image; - # } - - # $inputs_ref->{pathToProblemFile} = $adj_file_path - # if ( defined $adj_file_path ); - ################################################## # Process the pg file ################################################## - ### store the time before we invoke the content generator - my $cg_start = - time; # this is Time::HiRes's time, which gives floating point values - - ############################################ - # Call server via standaloneRenderer to render problem - ############################################ - our ( $return_object, $error_flag, $error_string ); $error_flag = 0; $error_string = ''; - my $memory_use_start = get_current_process_memory(); - # can include @args as third input below $return_object = standaloneRenderer( \$source, $inputs_ref ); # stash assets list in $return_object - $return_object->{pgResources} = $pgResources; - - # stash raw metadata text in $return_object - $return_object->{raw_metadata_text} = $raw_metadata_text; + $return_object->{pgResources} = \@pgResources; # generate sessionJWT to store session data and answerJWT to update grade store - # only occurs if problemJWT exists! my ($sessionJWT, $answerJWT) = generateJWTs($return_object, $inputs_ref); $return_object->{sessionJWT} = $sessionJWT // ''; $return_object->{answerJWT} = $answerJWT // ''; @@ -262,7 +195,7 @@ sub process_problem { print "\n\n Result of renderProblem \n\n" if $UNIT_TESTS_ON; print pretty_print_rh($return_object) if $UNIT_TESTS_ON; if ( not defined $return_object ) - { #FIXME make sure this is the right error message if site is unavailable + { $error_string = "0\t Could not process $file_path problem file \n"; } elsif ( defined( $return_object->{flags}->{error_flag} ) @@ -279,36 +212,14 @@ sub process_problem { # Create FormatRenderedProblems object ################################################## - # my $encoded_source = encode_base64($source); # create encoding of source_file; my $formatter = RenderApp::Controller::FormatRenderedProblem->new( return_object => $return_object, - encoded_source => '', #encode_base64($source), - sourceFilePath => $file_path, + sourceFilePath => $inputs_ref->{sourceFilePath}, url => $inputs_ref->{baseURL}, form_action_url => $inputs_ref->{formURL}, maketext => sub {return @_}, - courseID => 'blackbox', - userID => 'Motoko_Kusanagi', - course_password => 'daemon', inputs_ref => $inputs_ref, - problem_seed => $problem_seed - ); - - ################################################## - # log elapsed time - ################################################## - my $scriptName = 'standalonePGproblemRenderer'; - my $log_file_path = $file_path // 'source provided without path'; - my $cg_end = time; - my $cg_duration = $cg_end - $cg_start; - my $memory_use_end = get_current_process_memory(); - my $memory_use = $memory_use_end - $memory_use_start; - writeRenderLogEntry( - "", - "{script:$scriptName; file:$log_file_path; " - . sprintf( "duration: %.3f sec;", $cg_duration ) - . sprintf( " memory: %6d bytes;", $memory_use ) . "}", - '' + problem_seed => $inputs_ref->{problemSeed}, ); ####################################################################### @@ -331,21 +242,12 @@ sub standaloneRenderer { my $processAnswers = $inputs_ref->{processAnswers} // 1; print "NOT PROCESSING ANSWERS" unless $processAnswers == 1; - unless (ref $problemFile) { - # In this case the source file name is passed - print "standaloneProblemRenderer: setting source_file = $problemFile"; - } - # Attempt to match old parameters. my $isInstructor = $inputs_ref->{isInstructor} // ($inputs_ref->{permissionLevel} // 0) >= 10; my $pg = WeBWorK::PG->new( - ref $problemFile - ? ( - sourceFilePath => $inputs_ref->{sourceFilePath} // '', - r_source => $problemFile, - ) - : (sourceFilePath => $problemFile), + sourceFilePath => $inputs_ref->{sourceFilePath} // '', + r_source => $problemFile, problemSeed => $inputs_ref->{problemSeed}, processAnswers => $processAnswers, showHints => $inputs_ref->{showHints}, # default is to showHint (set in PG.pm) @@ -377,8 +279,8 @@ sub standaloneRenderer { my ( $internal_debug_messages, $pgwarning_messages, $pgdebug_messages ); if ( ref( $pg->{pgcore} ) ) { $internal_debug_messages = $pg->{pgcore}->get_internal_debug_messages; - $pgwarning_messages = $pg->{pgcore}->get_warning_messages(); - $pgdebug_messages = $pg->{pgcore}->get_debug_messages(); + $pgwarning_messages = $pg->{pgcore}->get_warning_messages; + $pgdebug_messages = $pg->{pgcore}->get_debug_messages; } else { $internal_debug_messages = @@ -405,19 +307,12 @@ sub standaloneRenderer { $out2; } -sub display_html_output { #display the problem in a browser - my $file_path = shift; - my $formatter = shift; - my $output_text = $formatter->formatRenderedProblem; - return $output_text; -} - ################################################## # utilities ################################################## sub get_current_process_memory { - state $pt = Proc::ProcessTable->new; + CORE::state $pt = Proc::ProcessTable->new; my %info = map { $_->pid => $_ } @{ $pt->table }; return $info{$$}->rss; } @@ -429,9 +324,18 @@ sub generateJWTs { my $inputs_ref = shift; my $sessionHash = {'answersSubmitted' => 1, 'iss' =>$ENV{SITE_HOST}, problemJWT => $inputs_ref->{problemJWT}}; my $scoreHash = {}; - - # if no problemJWT exists, then why bother? - return unless $inputs_ref->{problemJWT}; + + # TODO: sometimes student_ans causes JWT corruption in PHP - why? + # proposed restructuring of the answerJWT -- prepare with LibreTexts + # my %studentKeys = qw(student_value value student_formula formula student_ans answer original_student_ans original); + # my %previewKeys = qw(preview_text_string text preview_latex_string latex); + # my %correctKeys = qw(correct_value value correct_formula formula correct_ans ans); + # my %messageKeys = qw(ans_message answer error_message error); + # my @resultKeys = qw(score weight); + my %answers = %{unbless($pg->{answers})}; + + # once the correct answers are shown, this setting is permanent + $sessionHash->{showCorrectAnswers} = 1 if $inputs_ref->{showCorrectAnswers}; # store the current answer/response state for each entry foreach my $ans (keys %{$pg->{answers}}) { @@ -440,19 +344,20 @@ sub generateJWTs { $sessionHash->{ 'previous_' . $ans } = $inputs_ref->{$ans}; $sessionHash->{ 'MaThQuIlL_' . $ans } = $inputs_ref->{ 'MaThQuIlL_' . $ans } if ($inputs_ref->{ 'MaThQuIlL_' . $ans}); - # $scoreHash->{ans_id} = $ans; - # $scoreHash->{answer} = unbless($pg->{answers}{$ans}) // {}, - # $scoreHash->{score} = $pg->{answers}{$ans}{score} // 0, - - # TODO see why this key is causing JWT corruption in PHP - delete( $pg->{answers}{$ans}{student_ans}); + # More restructuring -- confirm with LibreTexts + # $scoreHash->{$ans}{student} = { map {exists $answers{$ans}{$_} ? ($studentKeys{$_} => $answers{$ans}{$_}) : ()} keys %studentKeys }; + # $scoreHash->{$ans}{preview} = { map {exists $answers{$ans}{$_} ? ($previewKeys{$_} => $answers{$ans}{$_}) : ()} keys %previewKeys }; + # $scoreHash->{$ans}{correct} = { map {exists $answers{$ans}{$_} ? ($correctKeys{$_} => $answers{$ans}{$_}) : ()} keys %correctKeys }; + # $scoreHash->{$ans}{message} = { map {exists $answers{$ans}{$_} ? ($messageKeys{$_} => $answers{$ans}{$_}) : ()} keys %messageKeys }; + # $scoreHash->{$ans}{result} = { map {exists $answers{$ans}{$_} ? ($_ => $answers{$ans}{$_}) : ()} @resultKeys }; } $scoreHash->{answers} = unbless($pg->{answers}); # update the number of correct/incorrect submissions if answers were 'submitted' - $sessionHash->{numCorrect} = (defined $inputs_ref->{submitAnswers}) ? + # but don't update either if the problem was already correct + $sessionHash->{numCorrect} = (defined $inputs_ref->{submitAnswers} && $inputs_ref->{numCorrect} == 0) ? $pg->{problem_state}{num_of_correct_ans} : ($inputs_ref->{numCorrect} // 0); - $sessionHash->{numIncorrect} = (defined $inputs_ref->{submitAnswers}) ? + $sessionHash->{numIncorrect} = (defined $inputs_ref->{submitAnswers} && $inputs_ref->{numCorrect} == 0) ? $pg->{problem_state}{num_of_incorrect_ans} : ($inputs_ref->{numIncorrect} // 0); # include the final result of the combined scores @@ -474,40 +379,11 @@ sub generateJWTs { # Can instead use alg => 'PBES2-HS512+A256KW', enc => 'A256GCM' for JWE my $answerJWT = encode_jwt(payload=>$responseHash, alg => 'HS256', key => $ENV{problemJWTsecret}, auto_iat => 1); + warn("answerJWT claims: ".encode_json($scoreHash)); return ($sessionJWT, $answerJWT); } -# Get problem template source and adjust file_path name -sub get_source { - my $file_path = shift; - my $source; - die "Unable to read file $file_path \n" - unless $file_path eq '-' or -r $file_path; - eval { #File::Slurp would be faster (see perl monks) - local $/ = undef; - if ( $file_path eq '-' ) { - $source = ; - } else { - # To support proper behavior with UTF-8 files, we need to open them with "<:encoding(UTF-8)" - # as otherwise, the first HTML file will render properly, but when "Preview" "Submit answer" - # or "Show correct answer" is used it will make problems, as in process_problem() the - # encodeSource() method is called on a data which is still UTF-8 encoded, and leads to double - # encoding and gibberish. - # NEW: - open( FH, "<:encoding(UTF-8)", $file_path ) - or die "Couldn't open file $file_path: $!"; - - # OLD: - #open(FH, "<" ,$file_path) or die "Couldn't open file $file_path: $!"; - $source = ; #slurp input - close FH; - } - }; - die "Something is wrong with the contents of $file_path\n" if $@; - return $file_path, $source; -} - sub pretty_print_rh { shift if UNIVERSAL::isa( $_[0] => __PACKAGE__ ); my $rh = shift; @@ -557,18 +433,15 @@ sub pretty_print_rh { return $out . " "; } -sub writeRenderLogEntry($$$) { - my ( $function, $details, $beginEnd ) = @_; - $beginEnd = - ( $beginEnd eq "begin" ) ? ">" : ( $beginEnd eq "end" ) ? "<" : "-"; +sub writeRenderLogEntry($) { + my $message = shift; local *LOG; if ( open LOG, ">>", $path_to_log_file ) { print LOG "[", time2str( "%a %b %d %H:%M:%S %Y", time ), - "] $$ " . time . " $beginEnd $function [$details]\n"; + "] $message\n"; close LOG; - } - else { + } else { warn "failed to open $path_to_log_file for writing: $!"; } } From 11ef91a90df55eb3ae9526d012d703298fe80712 Mon Sep 17 00:00:00 2001 From: "K. Andrew Parker" Date: Tue, 6 Jun 2023 09:18:46 -0400 Subject: [PATCH 06/14] convert to WW2.18 mojo::template approach --- lib/RenderApp.pm | 26 +- .../Controller/FormatRenderedProblem.pm | 357 ---- lib/RenderApp/Controller/Render.pm | 122 +- lib/RenderApp/Controller/StaticFiles.pm | 2 +- lib/RenderApp/Model/Problem.pm | 6 +- lib/WeBWorK/AttemptsTable.pm | 467 ++++++ lib/WeBWorK/FormatRenderedProblem.pm | 314 ++++ .../Controller => WeBWorK}/RenderProblem.pm | 114 +- lib/WeBWorK/Utils.pm | 120 +- lib/WeBWorK/Utils/AttemptsTable.pm | 455 ----- public/css/bootstrap.scss | 100 ++ public/css/rtl.css | 20 + public/generate-assets.js | 219 +++ public/images/favicon.ico | Bin 0 -> 370931 bytes public/js/apps/CSSMessage/css-message.js | 45 + .../apps/MathJaxConfig}/mathjax-config.js | 11 +- public/js/apps/PGCodeMirror/PG.js | 1460 +++++++++++++++++ public/js/apps/PGCodeMirror/pgeditor.js | 123 ++ public/js/apps/PGCodeMirror/pgeditor.scss | 64 + public/{ => js/apps}/Problem/problem.js | 0 public/{ => js/apps}/Problem/submithelper.js | 0 public/package.json | 43 + templates/RPCRenderFormats/default.html.ep | 144 ++ templates/RPCRenderFormats/default.json.ep | 65 + templates/RPCRenderFormats/ptx.html.ep | 7 + 25 files changed, 3294 insertions(+), 990 deletions(-) delete mode 100755 lib/RenderApp/Controller/FormatRenderedProblem.pm create mode 100644 lib/WeBWorK/AttemptsTable.pm create mode 100644 lib/WeBWorK/FormatRenderedProblem.pm rename lib/{RenderApp/Controller => WeBWorK}/RenderProblem.pm (82%) delete mode 100644 lib/WeBWorK/Utils/AttemptsTable.pm create mode 100644 public/css/bootstrap.scss create mode 100644 public/css/rtl.css create mode 100755 public/generate-assets.js create mode 100644 public/images/favicon.ico create mode 100644 public/js/apps/CSSMessage/css-message.js rename public/{Problem => js/apps/MathJaxConfig}/mathjax-config.js (81%) create mode 100644 public/js/apps/PGCodeMirror/PG.js create mode 100644 public/js/apps/PGCodeMirror/pgeditor.js create mode 100644 public/js/apps/PGCodeMirror/pgeditor.scss rename public/{ => js/apps}/Problem/problem.js (100%) rename public/{ => js/apps}/Problem/submithelper.js (100%) create mode 100644 public/package.json create mode 100644 templates/RPCRenderFormats/default.html.ep create mode 100644 templates/RPCRenderFormats/default.json.ep create mode 100644 templates/RPCRenderFormats/ptx.html.ep diff --git a/lib/RenderApp.pm b/lib/RenderApp.pm index a8f64d73d..5deedf83b 100644 --- a/lib/RenderApp.pm +++ b/lib/RenderApp.pm @@ -1,3 +1,8 @@ +use strict; +use warnings; +# use feature 'signatures'; +# no warnings qw(experimental::signatures); + package RenderApp; use Mojo::Base 'Mojolicious'; @@ -15,7 +20,7 @@ BEGIN { $ENV{PG_ROOT} = $main::dirname . '/PG'; # Used for reconstructing library paths from sym-links. - $ENV{OPL_DIRECTORY} = "webwork-open-problem-library"; + $ENV{OPL_DIRECTORY} = "$ENV{RENDER_ROOT}/webwork-open-problem-library"; $ENV{MOJO_CONFIG} = (-r "$ENV{RENDER_ROOT}/render_app.conf") ? "$ENV{RENDER_ROOT}/render_app.conf" : "$ENV{RENDER_ROOT}/render_app.conf.dist"; # $ENV{MOJO_MODE} = 'production'; @@ -26,8 +31,9 @@ use lib "$main::dirname"; print "home directory " . $main::dirname . "\n"; use RenderApp::Model::Problem; -use RenderApp::Controller::RenderProblem; use RenderApp::Controller::IO; +use WeBWorK::RenderProblem; +use WeBWorK::FormatRenderedProblem; sub startup { my $self = shift; @@ -66,6 +72,7 @@ sub startup { $self->helper(newProblem => sub { shift; RenderApp::Model::Problem->new(@_) }); # Helpers + $self->helper(format => sub { WeBWorK::FormatRenderedProblem::formatRenderedProblem(@_) }); $self->helper(validateRequest => sub { RenderApp::Controller::IO::validate(@_) }); $self->helper(parseRequest => sub { RenderApp::Controller::Render::parseRequest(@_) }); $self->helper(croak => sub { RenderApp::Controller::Render::croak(@_) }); @@ -107,20 +114,7 @@ sub startup { $r->any('/pg_files/CAPA_Graphics/*static')->to('StaticFiles#CAPA_graphics_file'); $r->any('/pg_files/tmp/*static')->to('StaticFiles#temp_file'); $r->any('/pg_files/*static')->to('StaticFiles#pg_file'); - $r->any('/*fail')->to('StaticFiles#public_file'); - # # any other requests fall through - # $r->any('/*fail' => sub { - # my $c = shift; - # my $report = $c->stash('fail')."\nCOOKIE:"; - # for my $cookie (@{$c->req->cookies}) { - # $report .= "\n".$cookie->to_string; - # } - # $report .= "\nFORM DATA:"; - # foreach my $k (@{$c->req->params->names}) { - # $report .= "\n$k = ".join ', ', @{$c->req->params->every_param($k)}; - # } - # $c->log->fatal($report); - # $c->rendered(404)}); + $r->any('/*static')->to('StaticFiles#public_file'); } 1; diff --git a/lib/RenderApp/Controller/FormatRenderedProblem.pm b/lib/RenderApp/Controller/FormatRenderedProblem.pm deleted file mode 100755 index 8ca271591..000000000 --- a/lib/RenderApp/Controller/FormatRenderedProblem.pm +++ /dev/null @@ -1,357 +0,0 @@ -#!/usr/bin/perl -w - -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2007 The WeBWorK Project, http://openwebwork.sf.net/ -# $CVSHeader: webwork2/lib/WebworkClient.pm,v 1.1 2010/06/08 11:46:38 gage Exp $ -# -# This program is free software; you can redistribute it and/or modify it under -# the terms of either: (a) the GNU General Public License as published by the -# Free Software Foundation; either version 2, or (at your option) any later -# version, or (b) the "Artistic License" which comes with this package. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the -# Artistic License for more details. -################################################################################ - -=head1 NAME - -FormatRenderedProblem.pm - -=cut - -package RenderApp::Controller::FormatRenderedProblem; - -use warnings; -use strict; - -use lib "$ENV{PG_ROOT}/lib"; - -use MIME::Base64 qw( encode_base64 decode_base64); -use WeBWorK::Utils::AttemptsTable; #import from ww2 -use WeBWorK::Utils::LanguageAndDirection; -use WeBWorK::Utils qw(wwRound getAssetURL); # required for score summary -use WeBWorK::Localize ; # for maketext -our $UNIT_TESTS_ON = 0; - -##################### -# error formatting - -sub format_hash_ref { - my $hash = shift; - warn "Use a hash reference" unless ref($hash) =~/HASH/; - return join(" ", map {$_="--" unless defined($_);$_ } %$hash),"\n"; -} - -sub new { - my $invocant = shift; - my $class = ref $invocant || $invocant; - my $self = { # Is this function redundant given the declarations within sub formatRenderedProblem? - return_object => {}, - encoded_source => {}, - sourceFilePath => '', - baseURL => $ENV{baseURL}, - form_action_url =>$ENV{formURL}, - maketext => sub {return @_}, - courseID => 'foo', # optional? - userID => 'bar', # optional? - course_password => 'baz', - inputs_ref => {}, - problem_seed => 6666, - @_, - }; - bless $self, $class; -} - -sub return_object { # out - my $self = shift; - my $object = shift; - $self->{return_object} = $object if defined $object and ref($object); # source is non-empty - $self->{return_object}; -} - -sub encoded_source { - my $self = shift; - my $source = shift; - $self->{encoded_source} =$source if defined $source and $source =~/\S/; # source is non-empty - $self->{encoded_source}; -} - -sub url { - my $self = shift; - my $new_url = shift; - $self->{url} = $new_url if defined($new_url) and $new_url =~ /\S/; - $self->{url}; -} - -sub formatRenderedProblem { - my $self = shift; - my $problemText =''; - my $rh_result = $self->return_object() || {}; # wrap problem in formats - $problemText = "No output from rendered Problem" unless $rh_result; - print "\nformatRenderedProblem return_object $rh_result = ",join(" ", sort keys %$rh_result),"\n" if $UNIT_TESTS_ON; - if (ref($rh_result) and $rh_result->{text} ) { ##text vs body_text - $problemText = $rh_result->{text}; - $problemText .= $rh_result->{flags}{comment} if ( $rh_result->{flags}{comment} && $self->{inputs_ref}{showComments} ); - } else { - $problemText .= "Unable to decode problem text
\n". - $self->{error_string}."\n".format_hash_ref($rh_result); - } - my $problemHeadText = $rh_result->{header_text}//''; ##head_text vs header_text - my $problemPostHeaderText = $rh_result->{post_header_text}//''; - my $rh_answers = $rh_result->{answers}//{}; - my $answerOrder = $rh_result->{flags}->{ANSWER_ENTRY_ORDER}//[]; #[sort keys %{ $rh_result->{answers} }]; - my $encoded_source = $self->encoded_source//''; - my $sourceFilePath = $self->{sourceFilePath}//''; - my $problemSourceURL = $self->{inputs_ref}->{problemSourceURL}; - my $warnings = ''; - print "\n return_object answers ", - join( " ", %{ $rh_result->{PG_ANSWERS_HASH} } ) - if $UNIT_TESTS_ON; - - - ################################################# - # regular Perl warning messages generated with warn - ################################################# - - if ( defined ($rh_result->{WARNINGS}) and $rh_result->{WARNINGS} ){ - $warnings = "

-

WARNINGS

" - . $rh_result->{WARNINGS} - . "

"; - } - #warn "keys: ", join(" | ", sort keys %{$rh_result }); - - ################################################# - # PG debug messages generated with DEBUG_message(); - ################################################# - - my $debug_messages = $rh_result->{debug_messages} || []; - $debug_messages = join("
\n", @{ $debug_messages }); - - ################################################# - # PG warning messages generated with WARN_message(); - ################################################# - - my $PG_warning_messages = $rh_result->{warning_messages} || []; - $PG_warning_messages = join("
\n", @{ $PG_warning_messages } ); - - ################################################# - # internal debug messages generated within PG_core - # these are sometimes needed if the PG_core warning message system - # isn't properly set up before the bug occurs. - # In general don't use these unless necessary. - ################################################# - - my $internal_debug_messages = $rh_result->{internal_debug_messages} || []; - $internal_debug_messages = join("
\n", @{ $internal_debug_messages } ); - - my $fileName = $self->{input}->{envir}->{fileName} || ""; - - ################################################# - - my $XML_URL = $self->url // ''; - my $FORM_ACTION_URL = $self->{form_action_url} // ''; - my $SITE_URL = $self->{baseURL} // ''; - my $SITE_HOST = $ENV{SITE_HOST} // ''; - my $courseID = $self->{courseID} // ''; - my $userID = $self->{userID} // ''; - my $course_password = $self->{course_password} // ''; - my $problemSeed = $self->{problem_seed}; - my $psvn = $self->{inputs_ref}{psvn} // 54321; - my $displayMode = $self->{inputs_ref}{displayMode} // 'MathJax'; - my $problemJWT = $self->{inputs_ref}{problemJWT} // ''; - my $sessionJWT = $self->{return_object}{sessionJWT} // ''; - - my $previewMode = defined( $self->{inputs_ref}{previewAnswers} ) || 0; - # showCorrectMode needs more security -- ww2 uses want/can/will - my $showCorrectMode = defined( $self->{inputs_ref}{showCorrectAnswers} ) || 0; - my $submitMode = defined($self->{inputs_ref}{submitAnswers}) || $self->{inputs_ref}{answersSubmitted} || 0; - - # problemUUID can be added to the request as a parameter. It adds a prefix - # to the identifier used by the format so that several different problems - # can appear on the same page. - my $problemUUID = $self->{inputs_ref}{problemUUID} // 1; - my $problemResult = $rh_result->{problem_result} // ''; - my $problemState = $rh_result->{problem_state} // ''; - my $showPartialCorrectAnswers = $self->{inputs_ref}{showPartialCorrectAnswers} - // $rh_result->{flags}{showPartialCorrectAnswers}; - my $showSummary = $self->{inputs_ref}{showSummary} // 1; #default to show summary for the moment - my $formLanguage = $self->{inputs_ref}{language} // 'en'; - my $showTable = $self->{inputs_ref}{hideAttemptsTable} ? 0 : 1; - my $showMessages = $self->{inputs_ref}{hideMessages} ? 0 : 1; - my $scoreSummary = ''; - - my $COURSE_LANG_AND_DIR = get_lang_and_dir($formLanguage); - # Set up the problem language and direction - # PG files can request their language and text direction be set. If we do - # not have access to a default course language, fall back to the - # $formLanguage instead. - my %PROBLEM_LANG_AND_DIR = get_problem_lang_and_dir($rh_result->{flags}, "auto:en:ltr", $formLanguage); - my $PROBLEM_LANG_AND_DIR = join(" ", map { qq{$_="$PROBLEM_LANG_AND_DIR{$_}"} } keys %PROBLEM_LANG_AND_DIR); - my $mt = WeBWorK::Localize::getLangHandle($self->{inputs_ref}{language} // 'en'); - - my $answerTemplate = ''; - if ($submitMode && $showTable) { - my $tbl = WeBWorK::Utils::AttemptsTable->new( - $rh_answers, - answersSubmitted => 1, - answerOrder => $answerOrder, - displayMode => $displayMode, - showAnswerNumbers => 0, - showAttemptAnswers => 0, - showAttemptPreviews => 1, - showAttemptResults => $showPartialCorrectAnswers, - showCorrectAnswers => $showCorrectMode, - showMessages => $showMessages, - showSummary => $showSummary, - maketext => WeBWorK::Localize::getLoc($formLanguage), - summary => $problemResult->{summary} // '', # can be set by problem grader??? - ); - - $answerTemplate = $tbl->answerTemplate; - $tbl->imgGen->render(body_text => \$answerTemplate) if $tbl->displayMode eq 'images'; - } - - # warn "imgGen is ", $tbl->imgGen; - #warn "answerOrder ", $tbl->answerOrder; - #warn "answersSubmitted ", $tbl->answersSubmitted; - # render equation images - - if ($submitMode && $problemResult && $showSummary) { - $scoreSummary = CGI::p($mt->maketext('Your score on this attempt is [_1]', wwRound(0, $problemResult->{score} * 100).'%')); - - #$scoreSummary .= CGI::p($mt->maketext("Your score was not recorded.")); - - #scoreSummary .= CGI::p('Your score on this problem has not been recorded.'); - #$scoreSummary .= CGI::hidden({id=>'problem-result-score', name=>'problem-result-score',value=>$problemResult->{score}}); - } - - # this should never? be blocked -- contains relevant info for - if ($problemResult->{msg}) { - $scoreSummary .= CGI::p($problemResult->{msg}); - } - - # This stuff is put here because eventually we will add locale support so the - # text will have to be done server side. - my $localStorageMessages = CGI::start_div({id=>'local-storage-messages'}); - $localStorageMessages.= CGI::p('Your overall score for this problem is'.' '.CGI::span({id=>'problem-overall-score'},'')); - $localStorageMessages .= CGI::end_div(); - - # Add JS files requested by problems via ADD_JS_FILE() in the PG file. - my $extra_js_files = ''; - if (ref($rh_result->{flags}{extra_js_files}) eq 'ARRAY') { - $rh_result->{js} = []; - my %jsFiles; - for (@{ $rh_result->{flags}{extra_js_files} }) { - next if $jsFiles{ $_->{file} }; - $jsFiles{ $_->{file} } = 1; - my %attributes = ref($_->{attributes}) eq 'HASH' ? %{ $_->{attributes} } : (); - if ($_->{external}) { - push @{ $rh_result->{js} }, $_->{file}; - $extra_js_files .= CGI::script({ src => $_->{file}, %attributes }, ''); - } else { - my $url = getAssetURL($self->{inputs_ref}{language} // 'en', $_->{file}); - push @{ $rh_result->{js} }, $SITE_URL.$url; - $extra_js_files .= CGI::script({ src => $SITE_URL.$url, %attributes }, ''); - } - } - } - - # Add CSS files requested by problems via ADD_CSS_FILE() in the PG file - # (the value should be an anonomous array). - my $extra_css_files = ''; - my @cssFiles; - if (ref($rh_result->{flags}{extra_css_files}) eq 'ARRAY') { - push @cssFiles, @{ $rh_result->{flags}{extra_css_files} }; - } - my %cssFilesAdded; # Used to avoid duplicates - $rh_result->{css} = []; - for (@cssFiles) { - next if $cssFilesAdded{ $_->{file} }; - $cssFilesAdded{ $_->{file} } = 1; - if ($_->{external}) { - push @{ $rh_result->{css} }, $_->{file}; - $extra_css_files .= CGI::Link({ rel => 'stylesheet', href => $_->{file} }); - } else { - my $url = getAssetURL($self->{inputs_ref}{language} // 'en', $_->{file}); - push @{ $rh_result->{css} }, $SITE_URL.$url; - $extra_css_files .= CGI::Link({ href => $SITE_URL.$url, rel => 'stylesheet' }); - } - } - - my $STRING_Preview = $mt->maketext("Preview My Answers"); - my $STRING_ShowCorrect = $mt->maketext("Show Correct Answers"); - my $STRING_Submit = $mt->maketext("Submit Answers"); - - #my $pretty_print_self = pretty_print($self); - - ###################################################### - # Return interpolated problem template - ###################################################### - my $format_name = $self->{inputs_ref}->{outputFormat}; - - if ($format_name eq "ww3") { - my $json_output = do("WebworkClient/ww3_format.pl"); - for my $key (keys %$json_output) { - # Interpolate values - $json_output->{$key} =~ s/(\$\w+)/"defined $1 ? $1 : ''"/gee; - } - $json_output->{submitButtons} = []; - push(@{$json_output->{submitButtons}}, { name => 'previewAnswers', value => $STRING_Preview }) - if $self->{inputs_ref}{showPreviewButton}; - push(@{$json_output->{submitButtons}}, { name => 'submitAnswers', value => $STRING_Submit }) - if $self->{inputs_ref}{showCheckAnswersButton}; - push(@{$json_output->{submitButtons}}, { name => 'showCorrectAnswers', value => $STRING_ShowCorrect }) - if $self->{inputs_ref}{showCorrectAnswersButton}; - return $json_output; - } - - $format_name //= 'formatRenderedProblemFailure'; - # find the appropriate template in WebworkClient folder - my $template = do("WebworkClient/${format_name}_format.pl")//''; - die "Unknown format name $format_name" unless $template; - # interpolate values into template - $template =~ s/(\$\w+)/"defined $1 ? $1 : ''"/gee; - return $template; -} - -sub pretty_print { # provides html output -- NOT a method - my $r_input = shift; - my $level = shift; - $level = 4 unless defined($level); - $level--; - return '' unless $level > 0; # only print three levels of hashes (safety feature) - my $out = ''; - if ( not ref($r_input) ) { - $out = $r_input if defined $r_input; # not a reference - $out =~ s/"; - - foreach my $key ( sort ( keys %$r_input )) { - $out .= " $key=> ".pretty_print($r_input->{$key}) . ""; - } - $out .=""; - } elsif (ref($r_input) eq 'ARRAY' ) { - my @array = @$r_input; - $out .= "( " ; - while (@array) { - $out .= pretty_print(shift @array, $level) . " , "; - } - $out .= " )"; - } elsif (ref($r_input) eq 'CODE') { - $out = "$r_input"; - } else { - $out = $r_input; - $out =~ s/{webwork} if defined $claims->{webwork}; - # $claims->{problemJWT} = $problemJWT; # because we're merging claims, this is unnecessary? # override key-values in params with those provided in the JWT @params{ keys %$claims } = values %$claims; } else { @@ -107,6 +106,7 @@ async sub problem { my $c = shift; my $inputs_ref = $c->parseRequest; return unless $inputs_ref; + $inputs_ref->{problemSource} = fetchRemoteSource_p($c, $inputs_ref->{problemSourceURL}) if $inputs_ref->{problemSourceURL}; my $file_path = $inputs_ref->{sourceFilePath}; @@ -130,71 +130,67 @@ async sub problem { return $c->exception($problem->{_message}, $problem->{status}) unless $problem->success(); - $inputs_ref->{sourceFilePath} = $problem->{read_path}; # in case the path was updated... - - my $input_errs = checkInputs($inputs_ref); - $c->render_later; # tell Mojo that this might take a while my $ww_return_json = await $problem->render($inputs_ref); return $c->exception( $problem->{_message}, $problem->{status} ) unless $problem->success(); - my $ww_return_hash = decode_json($ww_return_json); - my $output_errs = checkOutputs($ww_return_hash); - - $ww_return_hash->{debug}->{render_warn} = [$input_errs, $output_errs]; - - # if answers are submitted and there is a provided answerURL... - if ($inputs_ref->{JWTanswerURL} && $ww_return_hash->{JWT}{answer} && $inputs_ref->{submitAnswers}) { - my $answerJWTresponse = { - iss => $ENV{SITE_HOST}, - subject => 'webwork.result', - status => 502, - message => 'initial message' - }; - my $reqBody = { - Origin => $ENV{SITE_HOST}, - 'Content-Type' => 'text/plain', - }; - - $c->log->info("sending answerJWT to $inputs_ref->{JWTanswerURL}"); - await $c->ua->max_redirects(5)->request_timeout(7)->post_p($inputs_ref->{JWTanswerURL}, $reqBody, $ww_return_hash->{JWT}{answer})-> - then(sub { - my $response = shift->result; - - $answerJWTresponse->{status} = int($response->code); - # answerURL responses are expected to be JSON - if ($response->json) { - # munge data with default response object - $answerJWTresponse = { %$answerJWTresponse, %{$response->json} }; - } else { - # otherwise throw the whole body as the message - $answerJWTresponse->{message} = $response->body; - } - })-> - catch(sub { - my $err = shift; - $c->log->error($err); + my $return_object = decode_json($ww_return_json); - $answerJWTresponse->{status} = 500; - $answerJWTresponse->{message} = '[' . $c->logID . '] ' . $err; - }); + # if answerURL provided and this is a submit, then send the answerJWT + if ($inputs_ref->{JWTanswerURL} && $inputs_ref->{submitAnswers} && !$inputs_ref->{showCorrectAnswers}) { + $return_object->{JWTanswerURLstatus} = await sendAnswerJWT($c, $inputs_ref->{JWTanswerURL}, $return_object->{answerJWT}); + } - $answerJWTresponse = encode_json($answerJWTresponse); - # this will become a string literal, so single-quote characters must be escaped - $answerJWTresponse =~ s/'/\\'/g; - $c->log->info("answerJWT response ".$answerJWTresponse); + # format the response + $c->format($return_object); +} - $ww_return_hash->{renderedHTML} =~ s/JWTanswerURLstatus/$answerJWTresponse/g; - } else { - $ww_return_hash->{renderedHTML} =~ s/JWTanswerURLstatus//; - } +async sub sendAnswerJWT { + my $c = shift; + my $JWTanswerURL = shift; + my $answerJWT = shift; + + my $answerJWTresponse = { + iss => $ENV{SITE_HOST}, + subject => 'webwork.result', + status => 502, + message => 'initial message' + }; + my $reqBody = { + Origin => $ENV{SITE_HOST}, + 'Content-Type' => 'text/plain', + }; - $c->respond_to( - html => { text => $ww_return_hash->{renderedHTML} }, - json => { json => $ww_return_hash } - ); + $c->log->info("sending answerJWT to $JWTanswerURL"); + await $c->ua->max_redirects(5)->request_timeout(7)->post_p($JWTanswerURL, $reqBody, $answerJWT)-> + then(sub { + my $response = shift->result; + + $answerJWTresponse->{status} = int($response->code); + # answerURL responses are expected to be JSON + if ($response->json) { + # munge data with default response object + $answerJWTresponse = { %$answerJWTresponse, %{$response->json} }; + } else { + # otherwise throw the whole body as the message + $answerJWTresponse->{message} = $response->body; + } + })-> + catch(sub { + my $err = shift; + $c->log->error($err); + + $answerJWTresponse->{status} = 500; + $answerJWTresponse->{message} = '[' . $c->logID . '] ' . $err; + }); + + $answerJWTresponse = encode_json($answerJWTresponse); + # this will become a string literal, so single-quote characters must be escaped + $answerJWTresponse =~ s/'/\\'/g; + $c->log->info("answerJWT response ".$answerJWTresponse); + return $answerJWTresponse; } sub checkInputs { @@ -215,11 +211,12 @@ sub checkInputs { push @errs, $err; } } - return "Form data submitted for " + return @errs ? "Form data submitted for " . $inputs_ref->{sourceFilePath} . " contained errors: {" . join "}, {", @errs - . "}"; + . "}" + : undef; } sub checkOutputs { @@ -247,11 +244,12 @@ sub checkOutputs { } } } - return + return @errs ? "Output from rendering " - . ($outputs_ref->{sourceFilePath} // '') - . " contained errors: {" - . join "}, {", @errs . "}"; + . ($outputs_ref->{sourceFilePath} // '') + . " contained errors: {" + . join "}, {", @errs . "}" + : undef; } sub exception { diff --git a/lib/RenderApp/Controller/StaticFiles.pm b/lib/RenderApp/Controller/StaticFiles.pm index 6695d53f5..9b0dc9ad7 100644 --- a/lib/RenderApp/Controller/StaticFiles.pm +++ b/lib/RenderApp/Controller/StaticFiles.pm @@ -29,7 +29,7 @@ sub pg_file ($c) { } sub public_file($c) { - $c->reply_with_file_if_readable($c->app->home->child('public', $c->stash('fail'))); + $c->reply_with_file_if_readable($c->app->home->child('public', $c->stash('static'))); } 1; diff --git a/lib/RenderApp/Model/Problem.pm b/lib/RenderApp/Model/Problem.pm index 8747231ec..653c5d12e 100644 --- a/lib/RenderApp/Model/Problem.pm +++ b/lib/RenderApp/Model/Problem.pm @@ -9,7 +9,7 @@ use Mojo::JSON qw( encode_json ); use Mojo::Base -async_await; use Time::HiRes qw( time ); use MIME::Base64 qw( decode_base64 ); -use RenderApp::Controller::RenderProblem; +use WeBWorK::RenderProblem; ##### Problem params: ##### # = random_seed (set randomization for rendering) @@ -68,7 +68,7 @@ sub _init { # sourcecode takes precedence over reading from file path if ( $problem_contents =~ /\S/ ) { $self->source($problem_contents); - $self->{code_origin} = 'pg source (' . $self->path( $read_path, 'force' ) .')'; + $self->{code_origin} = 'pg source (' . ($self->path( $read_path, 'force' ) || 'no path provided') .')'; # set read_path without failing for !-e # this supports images in problems via editor } else { @@ -222,7 +222,7 @@ sub render { my $inputs_ref = shift; $self->{action} = 'render'; my $renderPromise = Mojo::IOLoop->subprocess->run_p( sub { - return RenderApp::Controller::RenderProblem::process_pg_file( $self, $inputs_ref ); + return WeBWorK::RenderProblem::process_pg_file( $self, $inputs_ref ); })->catch(sub { $self->{exception} = Mojo::Exception->new(shift)->trace; $self->{_error} = "500 Render failed: " . $self->{exception}->message; diff --git a/lib/WeBWorK/AttemptsTable.pm b/lib/WeBWorK/AttemptsTable.pm new file mode 100644 index 000000000..56717cf54 --- /dev/null +++ b/lib/WeBWorK/AttemptsTable.pm @@ -0,0 +1,467 @@ +################################################################################ +# WeBWorK Online Homework Delivery System +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of either: (a) the GNU General Public License as published by the +# Free Software Foundation; either version 2, or (at your option) any later +# version, or (b) the "Artistic License" which comes with this package. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the +# Artistic License for more details. +################################################################################ + +=head1 NAME + + AttemptsTable + +=head1 SYNPOSIS + + my $tbl = WeBWorK::HTML::AttemptsTable->new( + $answers, + answersSubmitted => 1, + answerOrder => $pg->{flags}{ANSWER_ENTRY_ORDER}, + displayMode => 'MathJax', + showAnswerNumbers => 0, + showAttemptAnswers => $showAttemptAnswers && $showEvaluatedAnswers, + showAttemptPreviews => $showAttemptPreview, + showAttemptResults => $showAttemptResults, + showCorrectAnswers => $showCorrectAnswers, + showMessages => $showAttemptAnswers, # internally checks for messages + showSummary => $showSummary, + imgGen => $imgGen, # not needed if ce is present , + ce => '', # not needed if $imgGen is present + maketext => WeBWorK::Localize::getLoc("en"), + ); + $tbl->{imgGen}->render(refresh => 1) if $tbl->displayMode eq 'images'; + my $answerTemplate = $tbl->answerTemplate; + + +=head1 DESCRIPTION + +This module handles the formatting of the table which presents the results of analyzing a student's +answer to a WeBWorK problem. It is used in Problem.pm, OpaqueServer.pm, standAlonePGproblemRender + +=head2 new + + my $tbl = WeBWorK::HTML::AttemptsTable->new( + $answers, + answersSubmitted => 1, + answerOrder => $pg->{flags}{ANSWER_ENTRY_ORDER}, + displayMode => 'MathJax', + showHeadline => 1, + showAnswerNumbers => 0, + showAttemptAnswers => $showAttemptAnswers && $showEvaluatedAnswers, + showAttemptPreviews => $showAttemptPreview, + showAttemptResults => $showAttemptResults, + showCorrectAnswers => $showCorrectAnswers, + showMessages => $showAttemptAnswers, # internally checks for messages + showSummary => $showSummary, + imgGen => $imgGen, # not needed if ce is present , + ce => '', # not needed if $imgGen is present + maketext => WeBWorK::Localize::getLoc("en"), + summary =>'', + ); + + $answers -- a hash of student answers e.g. $pg->{answers} + answersSubmitted if 0 then then the attemptsTable is not displayed (???) + answerOrder -- an array indicating the order the answers appear on the page. + displayMode 'MathJax' and 'images' are the most common + + showHeadline Show the header line 'Results for this submission' + + showAnswerNumbers, showAttemptAnswers, showAttemptPreviews,showAttemptResults, + showCorrectAnswers and showMessages control the display of each column in the table. + + attemptAnswers the student's typed in answer (possibly simplified numerically) + attemptPreview the student's answer after typesetting + attemptResults "correct", "_% correct", "incorrect" or "ungraded"- links to the answer blank + correctAnswers typeset version (untypeset versions are available via popups) + messages warns of formatting typos in the answer, or + more detailed messages about a wrong answer + summary is obtained from $pg->{result}{summary}. + If this is empty then a (localized) + version of "all answers are correct" + or "at least one answer is not coorrect" + imgGen points to a prebuilt image generator objectfor "images" mode + ce points to the CourseEnvironment -- it is needed if AttemptsTable + is required to build its own imgGen object + maketext points to a localization subroutine + +=head2 Methods + +=over 4 + +=item answerTemplate + +Returns HTML which formats the analysis of the student's answers to the problem. + +=back + +=head2 Read/Write Properties + +=over 4 + +=item showMessages, + +This can be switched on or off before exporting the answerTemplate, perhaps +under instructions from the PG problem. + +=item summary + +The contents of the summary can be defined when the attemptsTable object is created. + +The summary can be defined by the PG problem grader usually returned as +$pg->{result}{summary}. + +If the summary is not explicitly defined then (localized) versions +of the default summaries are created: + + "The answer above is correct.", + "Some answers will be graded later.", + "All of the [gradeable] answers above are correct.", + "[N] of the questions remain unanswered.", + "At least one of the answers above is NOT [fully] correct.', + +Note that if this is set after initialization, you must ensure that it is a +Mojo::ByteStream object if it contains html or characters that need escaping. + +=back + +=cut + +package WeBWorK::AttemptsTable; +use Mojo::Base 'Class::Accessor', -signatures; + +use Scalar::Util 'blessed'; +use WeBWorK::Utils 'wwRound'; + +# %options may contain: displayMode, submitted, imgGen, ce +# At least one of imgGen or ce must be provided if displayMode is 'images'. +sub new ($class, $rh_answers, $c, %options) { + $class = ref $class || $class; + ref($rh_answers) =~ /HASH/ or die 'The first entry to AttemptsTable must be a hash of answers'; + $c->isa('Mojolicious::Controller') or die 'The second entry to AttemptsTable must be a WeBWorK::Controller'; + my $self = bless { + answers => $rh_answers, + c => $c, + answerOrder => $options{answerOrder} // [], + answersSubmitted => $options{answersSubmitted} // 0, + summary => undef, # summary provided by problem grader (set in _init) + displayMode => $options{displayMode} || 'MathJax', + showHeadline => $options{showHeadline} // 1, + showAnswerNumbers => $options{showAnswerNumbers} // 1, + showAttemptAnswers => $options{showAttemptAnswers} // 1, # show student answer as entered and parsed + showAttemptPreviews => $options{showAttemptPreviews} // 1, # show preview of student answer + showAttemptResults => $options{showAttemptResults} // 1, # show results of grading student answer + showMessages => $options{showMessages} // 1, # show messages generated by evaluation + showCorrectAnswers => $options{showCorrectAnswers} // 0, # show the correct answers + showSummary => $options{showSummary} // 1, # show result summary + imgGen => undef, # set or created in _init method + mtRef => $options{mtRef} // sub { return $_[0] }, + }, $class; + + # Create accessors/mutators + $self->mk_ro_accessors(qw(answers c answerOrder answersSubmitted displayMode imgGen showAnswerNumbers + showAttemptAnswers showHeadline showAttemptPreviews showAttemptResults showCorrectAnswers showSummary)); + $self->mk_accessors(qw(showMessages summary)); + + # Sanity check and initialize imgGenerator. + $self->_init(%options); + + return $self; +} + +# Verify the display mode, and build imgGen if it is not supplied. +sub _init ($self, %options) { + $self->{submitted} = $options{submitted} // 0; + $self->{displayMode} = $options{displayMode} || 'MathJax'; + + # Only show message column if there is at least one message. + my @reallyShowMessages = grep { $self->answers->{$_}{ans_message} } @{ $self->answerOrder }; + $self->showMessages($self->showMessages && !!@reallyShowMessages); + + # Only used internally. Accessors are not needed. + $self->{numCorrect} = 0; + $self->{numBlanks} = 0; + $self->{numEssay} = 0; + + if ($self->displayMode eq 'images') { + if (blessed($options{imgGen}) && $options{imgGen}->isa('WeBWorK::PG::ImageGenerator')) { + $self->{imgGen} = $options{imgGen}; + } elsif (blessed($options{ce}) && $options{ce}->isa('WeBWorK::CourseEnvironment')) { + my $ce = $options{ce}; + + $self->{imgGen} = WeBWorK::PG::ImageGenerator->new( + tempDir => $ce->{webworkDirs}{tmp}, + latex => $ce->{externalPrograms}{latex}, + dvipng => $ce->{externalPrograms}{dvipng}, + useCache => 1, + cacheDir => $ce->{webworkDirs}{equationCache}, + cacheURL => $ce->{server_root_url} . $ce->{webworkURLs}{equationCache}, + cacheDB => $ce->{webworkFiles}{equationCacheDB}, + dvipng_align => $ce->{pg}{displayModeOptions}{images}{dvipng_align}, + dvipng_depth_db => $ce->{pg}{displayModeOptions}{images}{dvipng_depth_db}, + ); + } else { + warn 'Must provide image Generator (imgGen) or a course environment (ce) to build attempts table.'; + } + } + + # Make sure that the provided summary is a Mojo::ByteStream object. + $self->summary(blessed($options{summary}) + && $options{summary}->isa('Mojo::ByteStream') ? $options{summary} : $self->c->b($options{summary} // '')); + + return; +} + +sub formatAnswerRow ($self, $rh_answer, $ans_id, $answerNumber) { + my $c = $self->c; + + my $answerString = $rh_answer->{student_ans} // ''; + my $answerPreview = $self->previewAnswer($rh_answer) // ' '; + my $correctAnswer = $rh_answer->{correct_ans} // ''; + my $correctAnswerPreview = $self->previewCorrectAnswer($rh_answer) // ' '; + + my $answerMessage = $rh_answer->{ans_message} // ''; + $answerMessage =~ s/\n/
/g; + my $answerScore = $rh_answer->{score} // 0; + $self->{numCorrect} += $answerScore >= 1; + $self->{numEssay} += ($rh_answer->{type} // '') eq 'essay'; + $self->{numBlanks}++ unless $answerString =~ /\S/ || $answerScore >= 1; + + my $feedbackMessageClass = ($answerMessage eq '') ? '' : $self->maketext('FeedbackMessage'); + + my $resultString; + my $resultStringClass; + if ($answerScore >= 1) { + $resultString = $self->maketext('correct'); + $resultStringClass = 'ResultsWithoutError'; + } elsif (($rh_answer->{type} // '') eq 'essay') { + $resultString = $self->maketext('Ungraded'); + $self->{essayFlag} = 1; + } elsif ($answerScore == 0) { + $resultStringClass = 'ResultsWithError'; + $resultString = $self->maketext('incorrect'); + } else { + $resultString = $self->maketext('[_1]% correct', wwRound(0, $answerScore * 100)); + } + my $attemptResults = $c->tag( + 'td', + class => $resultStringClass, + $c->tag('a', href => '#', data => { answer_id => $ans_id }, $self->nbsp($resultString)) + ); + + return $c->c( + $self->showAnswerNumbers ? $c->tag('td', $answerNumber) : '', + $self->showAttemptAnswers ? $c->tag('td', dir => 'auto', $self->nbsp($answerString)) : '', + $self->showAttemptPreviews ? $self->formatToolTip($answerString, $answerPreview) : '', + $self->showAttemptResults ? $attemptResults : '', + $self->showCorrectAnswers ? $self->formatToolTip($correctAnswer, $correctAnswerPreview) : '', + $self->showMessages ? $c->tag('td', class => $feedbackMessageClass, $self->nbsp($answerMessage)) : '' + )->join(''); +} + +# Determine whether any answers were submitted and create answer template if they have been. +sub answerTemplate ($self) { + my $c = $self->c; + + return '' unless $self->answersSubmitted; # Only print if there is at least one non-blank answer + + my $tableRows = $c->c; + + push( + @$tableRows, + $c->tag( + 'tr', + $c->c( + $self->showAnswerNumbers ? $c->tag('th', '#') : '', + $self->showAttemptAnswers ? $c->tag('th', $self->maketext('Entered')) : '', + $self->showAttemptPreviews ? $c->tag('th', $self->maketext('Answer Preview')) : '', + $self->showAttemptResults ? $c->tag('th', $self->maketext('Result')) : '', + $self->showCorrectAnswers ? $c->tag('th', $self->maketext('Correct Answer')) : '', + $self->showMessages ? $c->tag('th', $self->maketext('Message')) : '' + )->join('') + ) + ); + + my $answerNumber = 0; + for (@{ $self->answerOrder() }) { + push @$tableRows, $c->tag('tr', $self->formatAnswerRow($self->{answers}{$_}, $_, ++$answerNumber)); + } + + return $c->c( + $self->showHeadline + ? $c->tag('h2', class => 'attemptResultsHeader', $self->maketext('Results for this submission')) + : '', + $c->tag( + 'div', + class => 'table-responsive', + $c->tag('table', class => 'attemptResults table table-sm table-bordered', $tableRows->join('')) + ), + $self->showSummary ? $self->createSummary : '' + )->join(''); +} + +sub previewAnswer ($self, $answerResult) { + my $displayMode = $self->displayMode; + my $imgGen = $self->imgGen; + + my $tex = $answerResult->{preview_latex_string}; + + return '' unless defined $tex and $tex ne ''; + + return $tex if $answerResult->{non_tex_preview}; + + if ($displayMode eq 'plainText') { + return $tex; + } elsif (($answerResult->{type} // '') eq 'essay') { + return $tex; + } elsif ($displayMode eq 'images') { + return $imgGen->add($tex); + } elsif ($displayMode eq 'MathJax') { + return $self->c->tag('script', type => 'math/tex; mode=display', $self->c->b($tex)); + } +} + +sub previewCorrectAnswer ($self, $answerResult) { + my $displayMode = $self->displayMode; + my $imgGen = $self->imgGen; + + my $tex = $answerResult->{correct_ans_latex_string}; + + # Some answers don't have latex strings defined return the raw correct answer + # unless defined $tex and $tex contains non whitespace characters; + return $answerResult->{correct_ans} + unless defined $tex and $tex =~ /\S/; + + return $tex if $answerResult->{non_tex_preview}; + + if ($displayMode eq 'plainText') { + return $tex; + } elsif ($displayMode eq 'images') { + return $imgGen->add($tex); + } elsif ($displayMode eq 'MathJax') { + return $self->c->tag('script', type => 'math/tex; mode=display', $self->c->b($tex)); + } +} + +# Create summary +sub createSummary ($self) { + my $c = $self->c; + + my $numCorrect = $self->{numCorrect}; + my $numBlanks = $self->{numBlanks}; + my $numEssay = $self->{numEssay}; + + my $summary; + + unless (defined($self->summary) and $self->summary =~ /\S/) { + # Default messages + $summary = $c->c; + my @answerNames = @{ $self->answerOrder() }; + if (scalar @answerNames == 1) { + if ($numCorrect == scalar @answerNames) { + push( + @$summary, + $c->tag( + 'div', + class => 'ResultsWithoutError mb-2', + $self->maketext('The answer above is correct.') + ) + ); + } elsif ($self->{essayFlag}) { + push(@$summary, $c->tag('div', $self->maketext('Some answers will be graded later.'))); + } else { + push( + @$summary, + $c->tag( + 'div', + class => 'ResultsWithError mb-2', + $self->maketext('The answer above is NOT correct.') + ) + ); + } + } else { + if ($numCorrect + $numEssay == scalar @answerNames) { + if ($numEssay) { + push( + @$summary, + $c->tag( + 'div', + class => 'ResultsWithoutError mb-2', + $self->maketext('All of the gradeable answers above are correct.') + ) + ); + } else { + push( + @$summary, + $c->tag( + 'div', + class => 'ResultsWithoutError mb-2', + $self->maketext('All of the answers above are correct.') + ) + ); + } + } elsif ($numBlanks + $numEssay != scalar(@answerNames)) { + push( + @$summary, + $c->tag( + 'div', + class => 'ResultsWithError mb-2', + $self->maketext('At least one of the answers above is NOT correct.') + ) + ); + } + if ($numBlanks > $numEssay) { + my $s = ($numBlanks > 1) ? '' : 's'; + push( + @$summary, + $c->tag( + 'div', + class => 'ResultsAlert mb-2', + $self->maketext( + '[quant,_1,of the questions remains,of the questions remain] unanswered.', $numBlanks + ) + ) + ); + } + } + $summary = $summary->join(''); + } else { + $summary = $self->summary; # Summary defined by grader + } + $summary = $c->tag('div', role => 'alert', class => 'attemptResultsSummary', $summary); + $self->summary($summary); + return $summary; +} + +# Utility subroutine that prevents unwanted line breaks, and ensures that the return value is a Mojo::ByteStream object. +sub nbsp ($self, $str) { + return $self->c->b(defined $str && $str =~ /\S/ ? $str : ' '); +} + +# Note that formatToolTip output includes the wrapper. +sub formatToolTip ($self, $answer, $formattedAnswer) { + return $self->c->tag( + 'td', + $self->c->tag( + 'div', + class => 'answer-preview', + data => { + bs_toggle => 'popover', + bs_content => $answer, + bs_placement => 'bottom', + }, + $self->nbsp($formattedAnswer) + ) + ); +} + +sub maketext ($self, @args) { + return $self->{mtRef}->(@args); +} + +1; diff --git a/lib/WeBWorK/FormatRenderedProblem.pm b/lib/WeBWorK/FormatRenderedProblem.pm new file mode 100644 index 000000000..967de676f --- /dev/null +++ b/lib/WeBWorK/FormatRenderedProblem.pm @@ -0,0 +1,314 @@ +################################################################################ +# WeBWorK Online Homework Delivery System +# Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of either: (a) the GNU General Public License as published by the +# Free Software Foundation; either version 2, or (at your option) any later +# version, or (b) the "Artistic License" which comes with this package. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the +# Artistic License for more details. +################################################################################ + +=head1 NAME + +FormatRenderedProblem.pm + +=cut + +package WeBWorK::FormatRenderedProblem; + +use strict; +use warnings; + +use JSON; +use Digest::SHA qw(sha1_base64); +use Mojo::Util qw(xml_escape); +use Mojo::DOM; + +use WeBWorK::Localize; +use WeBWorK::AttemptsTable; +use WeBWorK::Utils qw(getAssetURL); +use WeBWorK::Utils::LanguageAndDirection; + +sub formatRenderedProblem { + my $c = shift; + my $rh_result = shift; + my $inputs_ref = $rh_result->{inputs_ref}; + + my $renderErrorOccurred = 0; + + my $problemText = $rh_result->{text} // ''; + $problemText .= $rh_result->{flags}{comment} if ( $rh_result->{flags}{comment} && $inputs_ref->{showComments} ); + + if ($rh_result->{flags}{error_flag}) { + $rh_result->{problem_result}{score} = 0; # force score to 0 for such errors. + $renderErrorOccurred = 1; + } + + my $SITE_URL = $ENV{baseURL}; + my $FORM_ACTION_URL = $ENV{formURL}; + + my $displayMode = $inputs_ref->{displayMode} // 'MathJax'; + + # HTML document language setting + my $formLanguage = $inputs_ref->{language} // 'en'; + + # Third party CSS + # The second element of each array in the following is whether or not the file is a theme file. + # customize source for bootstrap.css + my @third_party_css = map { getAssetURL($formLanguage, $_->[0]) } ( + [ 'css/bootstrap.css', ], + [ 'node_modules/jquery-ui-dist/jquery-ui.min.css', ], + [ 'node_modules/@fortawesome/fontawesome-free/css/all.min.css' ], + ); + + # Add CSS files requested by problems via ADD_CSS_FILE() in the PG file + # or via a setting of $ce->{pg}{specialPGEnvironmentVars}{extra_css_files} + # which can be set in course.conf (the value should be an anonomous array). + my @cssFiles; + # if (ref($ce->{pg}{specialPGEnvironmentVars}{extra_css_files}) eq 'ARRAY') { + # push(@cssFiles, { file => $_, external => 0 }) for @{ $ce->{pg}{specialPGEnvironmentVars}{extra_css_files} }; + # } + if (ref($rh_result->{flags}{extra_css_files}) eq 'ARRAY') { + push @cssFiles, @{ $rh_result->{flags}{extra_css_files} }; + } + my %cssFilesAdded; # Used to avoid duplicates + my @extra_css_files; + for (@cssFiles) { + next if $cssFilesAdded{ $_->{file} }; + $cssFilesAdded{ $_->{file} } = 1; + if ($_->{external}) { + push(@extra_css_files, $_); + } else { + push(@extra_css_files, { file => getAssetURL($formLanguage, $_->{file}), external => 0 }); + } + } + + # Third party JavaScript + # The second element of each array in the following is whether or not the file is a theme file. + # The third element is a hash containing the necessary attributes for the script tag. + my @third_party_js = map { [ getAssetURL($formLanguage, $_->[0]), $_->[1] ] } ( + [ 'node_modules/jquery/dist/jquery.min.js', {} ], + [ 'node_modules/jquery-ui-dist/jquery-ui.min.js', {} ], + [ 'node_modules/iframe-resizer/js/iframeResizer.contentWindow.min.js', {} ], + [ "js/apps/MathJaxConfig/mathjax-config.js", { defer => undef } ], + [ 'node_modules/mathjax/es5/tex-svg.js', { defer => undef, id => 'MathJax-script' } ], + [ 'node_modules/bootstrap/dist/js/bootstrap.bundle.min.js', { defer => undef } ], + [ "js/apps/Problem/problem.js", { defer => undef } ], + [ "js/apps/Problem/submithelper.js", { defer => undef } ], + [ "js/apps/CSSMessage/css-message.js", { defer => undef } ], + ); + + # Get the requested format. (outputFormat or outputformat) + # override to static mode if showCorrectAnswers has been set + my $formatName = $inputs_ref->{showCorrectAnswers} && !$inputs_ref->{isInstructor} + ? 'static' + : $inputs_ref->{outputFormat} // $inputs_ref->{outputformat} // 'default'; + + # Add JS files requested by problems via ADD_JS_FILE() in the PG file. + my @extra_js_files; + if (ref($rh_result->{flags}{extra_js_files}) eq 'ARRAY') { + my %jsFiles; + for (@{ $rh_result->{flags}{extra_js_files} }) { + next if $jsFiles{ $_->{file} }; + $jsFiles{ $_->{file} } = 1; + my %attributes = ref($_->{attributes}) eq 'HASH' ? %{ $_->{attributes} } : (); + if ($_->{external}) { + push(@extra_js_files, $_); + } else { + push(@extra_js_files, + { file => getAssetURL($formLanguage, $_->{file}), external => 0, attributes => $_->{attributes} }); + } + } + } + + # Set up the problem language and direction + # PG files can request their language and text direction be set. If we do not have access to a default course + # language, fall back to the $formLanguage instead. + # TODO: support for right-to-left languages + my %PROBLEM_LANG_AND_DIR = + get_problem_lang_and_dir($rh_result->{flags}, 'auto:en:ltr', $formLanguage); + my $PROBLEM_LANG_AND_DIR = join(' ', map {qq{$_="$PROBLEM_LANG_AND_DIR{$_}"}} keys %PROBLEM_LANG_AND_DIR); + + # is there a reason this doesn't use the same button IDs? + my $previewMode = defined($inputs_ref->{previewAnswers}) || 0; + my $submitMode = defined($inputs_ref->{submitAnswers}) || $inputs_ref->{answersSubmitted} || 0; + my $showCorrectMode = defined($inputs_ref->{showCorrectAnswers}) || 0; + # A problemUUID should be added to the request as a parameter. It is used by PG to create a proper UUID for use in + # aliases for resources. It should be unique for a course, user, set, problem, and version. + my $problemUUID = $inputs_ref->{problemUUID} // ''; + my $problemResult = $rh_result->{problem_result} // {}; + my $showSummary = $inputs_ref->{showSummary} // 1; + my $showAnswerNumbers = $inputs_ref->{showAnswerNumbers} // 0; # default no + # allow the request to hide the results table or messages + my $showTable = $inputs_ref->{hideAttemptsTable} ? 0 : 1; + my $showMessages = $inputs_ref->{hideMessages} ? 0 : 1; + # allow the request to override the display of partial correct answers + my $showPartialCorrectAnswers = $inputs_ref->{showPartialCorrectAnswers} + // $rh_result->{flags}{showPartialCorrectAnswers}; + + # Attempts table + my $answerTemplate = ''; + + # Do not produce an AttemptsTable when we had a rendering error. + if (!$renderErrorOccurred && $submitMode && $showTable) { + my $tbl = WeBWorK::AttemptsTable->new( + $rh_result->{answers} // {}, $c, + answersSubmitted => 1, + answerOrder => $rh_result->{flags}{ANSWER_ENTRY_ORDER} // [], + displayMode => $displayMode, + showAnswerNumbers => $showAnswerNumbers, + showAttemptAnswers => 0, + showAttemptPreviews => 1, + showAttemptResults => $showPartialCorrectAnswers, + showCorrectAnswers => $showCorrectMode, + showMessages => $showMessages, + showSummary => $showSummary, + mtRef => WeBWorK::Localize::getLoc($formLanguage), + summary => $problemResult->{summary} // '', # can be set by problem grader + ); + $answerTemplate = $tbl->answerTemplate; + # $tbl->imgGen->render(refresh => 1) if $tbl->displayMode eq 'images'; + } + + # Answer hash in XML format used by the PTX format. + my $answerhashXML = ''; + if ($formatName eq 'ptx') { + my $dom = Mojo::DOM->new->xml(1); + for my $answer (sort keys %{ $rh_result->{answers} }) { + $dom->append_content($dom->new_tag( + $answer, + map { $_ => ($rh_result->{answers}{$answer}{$_} // '') } keys %{ $rh_result->{answers}{$answer} } + )); + } + $dom->wrap_content(''); + $answerhashXML = $dom->to_string; + } + + # Make sure this is defined and is an array reference as saveGradeToLTI might add to it. + $rh_result->{debug_messages} = [] unless defined $rh_result && ref $rh_result->{debug_messages} eq 'ARRAY'; + + # Execute and return the interpolated problem template + + # Raw format + # This format returns javascript object notation corresponding to the perl hash + # with everything that a client-side application could use to work with the problem. + # There is no wrapping HTML "_format" template. + if ($formatName eq 'raw') { + my $output = {}; + + # Everything that ships out with other formats can be constructed from these + $output->{rh_result} = $rh_result; + $output->{inputs_ref} = $inputs_ref; + # $output->{input} = $ws->{input}; + + # The following could be constructed from the above, but this is a convenience + $output->{answerTemplate} = $answerTemplate if ($answerTemplate); + $output->{lang} = $PROBLEM_LANG_AND_DIR{lang}; + $output->{dir} = $PROBLEM_LANG_AND_DIR{dir}; + $output->{extra_css_files} = \@extra_css_files; + $output->{extra_js_files} = \@extra_js_files; + + # Include third party css and javascript files. Only jquery, jquery-ui, mathjax, and bootstrap are needed for + # PG. See the comments before the subroutine definitions for load_css and load_js in pg/macros/PG.pl. + # The other files included are only needed to make themes work in the webwork2 formats. + $output->{third_party_css} = \@third_party_css; + $output->{third_party_js} = \@third_party_js; + + # Say what version of WeBWorK this is + # $output->{ww_version} = $ce->{WW_VERSION}; + # $output->{pg_version} = $ce->{PG_VERSION}; + + # Convert to JSON and render. + return $c->render(data => JSON->new->utf8(1)->encode($output)); + } + + # Setup and render the appropriate template in the templates/RPCRenderFormats folder depending on the outputformat. + # "ptx" has a special template. "json" uses the default json template. All others use the default html template. + my %template_params = ( + template => $formatName eq 'ptx' ? 'RPCRenderFormats/ptx' : 'RPCRenderFormats/default', + $formatName eq 'json' ? (format => 'json') : (), + formatName => $formatName, + lh => WeBWorK::Localize::getLangHandle($inputs_ref->{language} // 'en'), + rh_result => $rh_result, + SITE_URL => $SITE_URL, + FORM_ACTION_URL => $FORM_ACTION_URL, + COURSE_LANG_AND_DIR => get_lang_and_dir($formLanguage), + PROBLEM_LANG_AND_DIR => $PROBLEM_LANG_AND_DIR, + third_party_css => \@third_party_css, + extra_css_files => \@extra_css_files, + third_party_js => \@third_party_js, + extra_js_files => \@extra_js_files, + problemText => $problemText, + extra_header_text => $inputs_ref->{extra_header_text} // '', + answerTemplate => $answerTemplate, + showScoreSummary => $submitMode && !$renderErrorOccurred && $problemResult, + answerhashXML => $answerhashXML, + showPreviewButton => $inputs_ref->{showPreviewButton} // '', + showCheckAnswersButton => $inputs_ref->{showCheckAnswersButton} // '', + showCorrectAnswersButton => $inputs_ref->{showCorrectAnswersButton} // '0', + showFooter => $inputs_ref->{showFooter} // '', + pretty_print => \&pretty_print, + ); + + return $c->render(%template_params) if $formatName eq 'json' && !$inputs_ref->{send_pg_flags}; + $rh_result->{renderedHTML} = $c->render_to_string(%template_params)->to_string; + return $c->respond_to( + html => { text => $rh_result->{renderedHTML} }, + json => { json => $rh_result }); +} + +# Nice output for debugging +sub pretty_print { + my ($r_input, $level) = @_; + $level //= 4; + $level--; + return '' unless $level > 0; # Only print three levels of hashes (safety feature) + my $out = ''; + if (!ref $r_input) { + $out = $r_input if defined $r_input; + $out =~ s/}; + + for my $key (sort keys %$r_input) { + # Safety feature - we do not want to display the contents of %seed_ce which + # contains the database password and lots of other things, and explicitly hide + # certain internals of the CourseEnvironment in case one slips in. + next + if (($key =~ /database/) + || ($key =~ /dbLayout/) + || ($key eq "ConfigValues") + || ($key eq "ENV") + || ($key eq "externalPrograms") + || ($key eq "permissionLevels") + || ($key eq "seed_ce")); + $out .= "$key=> " . pretty_print($r_input->{$key}, $level) . ""; + } + $out .= ''; + } elsif (ref $r_input eq 'ARRAY') { + my @array = @$r_input; + $out .= '( '; + while (@array) { + $out .= pretty_print(shift @array, $level) . ' , '; + } + $out .= ' )'; + } elsif (ref $r_input eq 'CODE') { + $out = "$r_input"; + } else { + $out = $r_input; + $out =~ s/{displayMode} ||= 'MathJax'; $inputs_ref->{outputFormat} ||= 'static'; $inputs_ref->{language} ||= 'en'; - + $inputs_ref->{isInstructor} //= ($inputs_ref->{permissionLevel} // 0) >= 10; # HACK: required for problemRandomize.pl $inputs_ref->{effectiveUser} = 'red.ted'; $inputs_ref->{user} = 'red.ted'; @@ -90,7 +90,7 @@ sub process_pg_file { my $pg_start = time; my $memory_use_start = get_current_process_memory(); - my ( $error_flag, $formatter, $error_string ) = + my ( $return_object, $error_flag, $error_string ) = process_problem( $problem, $inputs_ref ); my $pg_stop = time; @@ -105,47 +105,48 @@ sub process_pg_file { ); # format result - my $html = $formatter->formatRenderedProblem; - my $pg_obj = $formatter->{return_object}; - my $json_rh = { - renderedHTML => $html, - answers => $pg_obj->{answers}, - debug => { - perl_warn => $pg_obj->{WARNINGS}, - pg_warn => $pg_obj->{warning_messages}, - debug => $pg_obj->{debug_messages}, - internal => $pg_obj->{internal_debug_messages} - }, - problem_result => $pg_obj->{problem_result}, - problem_state => $pg_obj->{problem_state}, - flags => $pg_obj->{flags}, - resources => { - regex => $pg_obj->{pgResources}, - alias => $pg_obj->{resources}, - js => $pg_obj->{js}, - css => $pg_obj->{css}, - }, - form_data => $inputs_ref, - raw_metadata_text => $pg_obj->{raw_metadata_text}, - JWT => { - problem => $inputs_ref->{problemJWT}, - session => $pg_obj->{sessionJWT}, - answer => $pg_obj->{answerJWT} - }, - }; + # my $html = $formatter->formatRenderedProblem; + # my $pg_obj = $formatter->{return_object}; + # my $json_rh = { + # renderedHTML => $html, + # answers => $pg_obj->{answers}, + # debug => { + # perl_warn => $pg_obj->{WARNINGS}, + # pg_warn => $pg_obj->{warning_messages}, + # debug => $pg_obj->{debug_messages}, + # internal => $pg_obj->{internal_debug_messages} + # }, + # problem_result => $pg_obj->{problem_result}, + # problem_state => $pg_obj->{problem_state}, + # flags => $pg_obj->{flags}, + # resources => { + # regex => $pg_obj->{pgResources}, + # alias => $pg_obj->{resources}, + # js => $pg_obj->{js}, + # css => $pg_obj->{css}, + # }, + # form_data => $inputs_ref, + # raw_metadata_text => $pg_obj->{raw_metadata_text}, + # JWT => { + # problem => $inputs_ref->{problemJWT}, + # session => $pg_obj->{sessionJWT}, + # answer => $pg_obj->{answerJWT} + # }, + # }; # havoc caused by problemRandomize.pl inserting CODE ref into pg->{flags} # HACK: remove flags->{problemRandomize} if it exists -- cannot include CODE refs - delete $json_rh->{flags}{problemRandomize} - if $json_rh->{flags}{problemRandomize}; + delete $return_object->{flags}{problemRandomize} + if $return_object->{flags}{problemRandomize}; # similar things happen with compoundProblem -- delete CODE refs - delete $json_rh->{flags}{compoundProblem}{grader} - if $json_rh->{flags}{compoundProblem}{grader}; + delete $return_object->{flags}{compoundProblem}{grader} + if $return_object->{flags}{compoundProblem}{grader}; - $json_rh->{tags} = WeBWorK::Utils::Tags->new('', $problem->source) if ( $inputs_ref->{includeTags} ); + $return_object->{tags} = WeBWorK::Utils::Tags->new('', $problem->source) if ( $inputs_ref->{includeTags} ); + $return_object->{inputs_ref} = $inputs_ref; my $coder = JSON::XS->new->ascii->pretty->allow_unknown->convert_blessed; - my $json = $coder->encode($json_rh); + my $json = $coder->encode($return_object); return $json; } @@ -208,25 +209,11 @@ sub process_problem { } $error_flag = 1 if $return_object->{errors}; - ################################################## - # Create FormatRenderedProblems object - ################################################## - - my $formatter = RenderApp::Controller::FormatRenderedProblem->new( - return_object => $return_object, - sourceFilePath => $inputs_ref->{sourceFilePath}, - url => $inputs_ref->{baseURL}, - form_action_url => $inputs_ref->{formURL}, - maketext => sub {return @_}, - inputs_ref => $inputs_ref, - problem_seed => $inputs_ref->{problemSeed}, - ); - ####################################################################### # End processing of the pg file ####################################################################### - return $error_flag, $formatter, $error_string; + return $return_object, $error_flag, $error_string; } ########################################### @@ -242,9 +229,6 @@ sub standaloneRenderer { my $processAnswers = $inputs_ref->{processAnswers} // 1; print "NOT PROCESSING ANSWERS" unless $processAnswers == 1; - # Attempt to match old parameters. - my $isInstructor = $inputs_ref->{isInstructor} // ($inputs_ref->{permissionLevel} // 0) >= 10; - my $pg = WeBWorK::PG->new( sourceFilePath => $inputs_ref->{sourceFilePath} // '', r_source => $problemFile, @@ -254,11 +238,11 @@ sub standaloneRenderer { showSolutions => $inputs_ref->{showSolutions}, problemNumber => $inputs_ref->{problemNumber}, # ever even relevant? num_of_correct_ans => $inputs_ref->{numCorrect} || 0, - num_of_incorrect_ans => $inputs_ref->{numIncorrect} // 1000, + num_of_incorrect_ans => $inputs_ref->{numIncorrect} || 0, displayMode => $inputs_ref->{displayMode}, useMathQuill => !defined $inputs_ref->{entryAssist} || $inputs_ref->{entryAssist} eq 'MathQuill', answerPrefix => $inputs_ref->{answerPrefix}, - isInstructor => $isInstructor, + isInstructor => $inputs_ref->{isInstructor}, forceScaffoldsOpen => $inputs_ref->{forceScaffoldsOpen}, psvn => $inputs_ref->{psvn}, problemUUID => $inputs_ref->{problemUUID}, @@ -268,7 +252,7 @@ sub standaloneRenderer { templateDirectory => "$ENV{RENDER_ROOT}/", debuggingOptions => { show_resource_info => $inputs_ref->{show_resource_info}, - view_problem_debugging_info => $inputs_ref->{view_problem_debugging_info} // $isInstructor, + view_problem_debugging_info => $inputs_ref->{view_problem_debugging_info} // $inputs_ref->{isInstructor}, show_pg_info => $inputs_ref->{show_pg_info}, show_answer_hash_info => $inputs_ref->{show_answer_hash_info}, show_answer_group_info => $inputs_ref->{show_answer_group_info} @@ -293,8 +277,8 @@ sub standaloneRenderer { post_header_text => $pg->{post_header_text}, answers => $pg->{answers}, errors => $pg->{errors}, - WARNINGS => $pg->{warnings}, - PG_ANSWERS_HASH => $pg->{pgcore}->{PG_ANSWERS_HASH}, + pg_warnings => $pg->{warnings}, + # PG_ANSWERS_HASH => $pg->{pgcore}->{PG_ANSWERS_HASH}, problem_result => $pg->{result}, problem_state => $pg->{state}, flags => $pg->{flags}, @@ -332,10 +316,10 @@ sub generateJWTs { # my %correctKeys = qw(correct_value value correct_formula formula correct_ans ans); # my %messageKeys = qw(ans_message answer error_message error); # my @resultKeys = qw(score weight); - my %answers = %{unbless($pg->{answers})}; + my %answers = %{unbless($pg->{answers})}; # once the correct answers are shown, this setting is permanent - $sessionHash->{showCorrectAnswers} = 1 if $inputs_ref->{showCorrectAnswers}; + $sessionHash->{showCorrectAnswers} = 1 if $inputs_ref->{showCorrectAnswers} && !$inputs_ref->{isInstructor}; # store the current answer/response state for each entry foreach my $ans (keys %{$pg->{answers}}) { @@ -351,7 +335,7 @@ sub generateJWTs { # $scoreHash->{$ans}{message} = { map {exists $answers{$ans}{$_} ? ($messageKeys{$_} => $answers{$ans}{$_}) : ()} keys %messageKeys }; # $scoreHash->{$ans}{result} = { map {exists $answers{$ans}{$_} ? ($_ => $answers{$ans}{$_}) : ()} @resultKeys }; } - $scoreHash->{answers} = unbless($pg->{answers}); + $scoreHash->{answers} = unbless($pg->{answers}); # update the number of correct/incorrect submissions if answers were 'submitted' # but don't update either if the problem was already correct @@ -379,8 +363,6 @@ sub generateJWTs { # Can instead use alg => 'PBES2-HS512+A256KW', enc => 'A256GCM' for JWE my $answerJWT = encode_jwt(payload=>$responseHash, alg => 'HS256', key => $ENV{problemJWTsecret}, auto_iat => 1); - warn("answerJWT claims: ".encode_json($scoreHash)); - return ($sessionJWT, $answerJWT); } diff --git a/lib/WeBWorK/Utils.pm b/lib/WeBWorK/Utils.pm index 095e34a5e..ef7e07c7c 100644 --- a/lib/WeBWorK/Utils.pm +++ b/lib/WeBWorK/Utils.pm @@ -35,50 +35,120 @@ sub wwRound(@) { return int($float * $factor + 0.5) / $factor; } +my $staticWWAssets; my $staticPGAssets; +my $thirdPartyWWDependencies; +my $thirdPartyPGDependencies; + +sub readJSON { + my $fileName = shift; + + return unless -r $fileName; + + open(my $fh, "<:encoding(UTF-8)", $fileName) or die "FATAL: Unable to open '$fileName'!"; + local $/; + my $data = <$fh>; + close $fh; + + return JSON->new->decode($data); +} + +sub getThirdPartyAssetURL { + my ($file, $dependencies, $baseURL, $useCDN) = @_; + + for (keys %$dependencies) { + if ($file =~ /^node_modules\/$_\/(.*)$/) { + if ($useCDN && $1 !~ /mathquill/) { + return + "https://cdn.jsdelivr.net/npm/$_\@" + . substr($dependencies->{$_}, 1) . '/' + . ($1 =~ s/(?:\.min)?\.(js|css)$/.min.$1/gr); + } else { + return "$baseURL/$file?version=" . ($dependencies->{$_} =~ s/#/@/gr); + } + } + } + return; +} # Get the url for static assets. sub getAssetURL { - my ($language, $file, $isThemeFile) = @_; + my ($language, $file) = @_; # Load the static files list generated by `npm install` the first time this method is called. - if (!$staticPGAssets) { + unless ($staticWWAssets) { + my $staticAssetsList = "$ENV{RENDER_ROOT}/public/static-assets.json"; + $staticWWAssets = readJSON($staticAssetsList); + unless ($staticWWAssets) { + warn "ERROR: '$staticAssetsList' not found or not readable!\n" + . "You may need to run 'npm install' from '$ENV{RENDER_ROOT}/public'."; + $staticWWAssets = {}; + } + } + + unless ($staticPGAssets) { my $staticAssetsList = "$ENV{PG_ROOT}/htdocs/static-assets.json"; - if (-r $staticAssetsList) { - my $data = do { - open(my $fh, "<:encoding(UTF-8)", $staticAssetsList) - or die "FATAL: Unable to open '$staticAssetsList'!"; - local $/; - <$fh>; - }; - - $staticPGAssets = JSON->new->decode($data); - } else { - warn "ERROR: '$staticAssetsList' not found!\n" + $staticPGAssets = readJSON($staticAssetsList); + unless ($staticPGAssets) { + warn "ERROR: '$staticAssetsList' not found or not readable!\n" . "You may need to run 'npm install' from '$ENV{PG_ROOT}/htdocs'."; + $staticPGAssets = {}; } } + unless ($thirdPartyWWDependencies) { + my $packageJSON = "$ENV{RENDER_ROOT}/public/package.json"; + my $data = readJSON($packageJSON); + warn "ERROR: '$packageJSON' not found or not readable!\n" unless $data && defined $data->{dependencies}; + $thirdPartyWWDependencies = $data->{dependencies} // {}; + } + + unless ($thirdPartyPGDependencies) { + my $packageJSON = "$ENV{PG_ROOT}/htdocs/package.json"; + my $data = readJSON($packageJSON); + warn "ERROR: '$packageJSON' not found or not readable!\n" unless $data && defined $data->{dependencies}; + $thirdPartyPGDependencies = $data->{dependencies} // {}; + } + + # Check to see if this is a third party asset file in node_modules (either in webwork2/htdocs or pg/htdocs). + # If so, then either serve it from a CDN if requested, or serve it directly with the library version + # appended as a URL parameter. + if ($file =~ /^node_modules/) { + my $wwFile = getThirdPartyAssetURL( + $file, $thirdPartyWWDependencies, + '', + 0 + ); + return $wwFile if $wwFile; + + my $pgFile = + getThirdPartyAssetURL($file, $thirdPartyPGDependencies, '/pg_files', 1); + return $pgFile if $pgFile; + } + # If a right-to-left language is enabled (Hebrew or Arabic) and this is a css file that is not a third party asset, # then determine the rtl varaint file name. This will be looked for first in the asset lists. - my $rtlfile = $file =~ s/\.css$/.rtl.css/r - if ($language =~ /^(he|ar)/ && $file !~ /node_modules/ && $file =~ /\.css$/); + my $rtlfile = + ($language =~ /^(he|ar)/ && $file !~ /node_modules/ && $file =~ /\.css$/) + ? $file =~ s/\.css$/.rtl.css/r + : undef; + + # First check to see if this is a file in the webwork htdocs location with a rtl variant. + return "/$staticWWAssets->{$rtlfile}" + if defined $rtlfile && defined $staticWWAssets->{$rtlfile}; + + # Next check to see if this is a file in the webwork htdocs location. + return "/$staticWWAssets->{$file}" if defined $staticWWAssets->{$file}; # Now check to see if this is a file in the pg htdocs location with a rtl variant. - # These also can only be local files. return "/pg_files/$staticPGAssets->{$rtlfile}" if defined $rtlfile && defined $staticPGAssets->{$rtlfile}; # Next check to see if this is a file in the pg htdocs location. - if (defined $staticPGAssets->{$file}) { - # File served by cdn. - return $staticPGAssets->{$file} if $staticPGAssets->{$file} =~ /^https?:\/\//; - # File served locally. - return "/pg_files/$staticPGAssets->{$file}"; - } + return "/pg_files/$staticPGAssets->{$file}" if defined $staticPGAssets->{$file}; - # If the file was not found in the lists, then just use the given file and assume its path is relative to the pg - # htdocs location. - return "/pg_files/$file"; + # If the file was not found in the lists, then just use the given file and assume its path is relative to the + # render app public folder. + return "/$file"; } 1; diff --git a/lib/WeBWorK/Utils/AttemptsTable.pm b/lib/WeBWorK/Utils/AttemptsTable.pm deleted file mode 100644 index 344d7e6a8..000000000 --- a/lib/WeBWorK/Utils/AttemptsTable.pm +++ /dev/null @@ -1,455 +0,0 @@ -#!/usr/bin/perl -w -use 5.010; - -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2021 The WeBWorK Project, https://github.com/openwebwork -# -# This program is free software; you can redistribute it and/or modify it under -# the terms of either: (a) the GNU General Public License as published by the -# Free Software Foundation; either version 2, or (at your option) any later -# version, or (b) the "Artistic License" which comes with this package. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the -# Artistic License for more details. -################################################################################ - -=head1 NAME - - AttemptsTable - -=head1 SYNPOSIS - - my $tbl = WeBWorK::Utils::AttemptsTable->new( - $answers, - answersSubmitted => 1, - answerOrder => $pg->{flags}->{ANSWER_ENTRY_ORDER}, - displayMode => 'MathJax', - showAnswerNumbers => 0, - showAttemptAnswers => $showAttemptAnswers && $showEvaluatedAnswers, - showAttemptPreviews => $showAttemptPreview, - showAttemptResults => $showAttemptResults, - showCorrectAnswers => $showCorrectAnswers, - showMessages => $showAttemptAnswers, # internally checks for messages - showSummary => $showSummary, - imgGen => $imgGen, # not needed if ce is present , - ce => '', # not needed if $imgGen is present - maketext => WeBWorK::Localize::getLoc("en"), - ); - $tbl->{imgGen}->render(refresh => 1) if $tbl->displayMode eq 'images'; - my $answerTemplate = $tbl->answerTemplate; - # this also collects the correct_ids and incorrect_ids - $self->{correct_ids} = $tbl->correct_ids; - $self->{incorrect_ids} = $tbl->incorrect_ids; - - -=head1 DESCRIPTION -This module handles the formatting of the table which presents the results of analyzing a student's -answer to a WeBWorK problem. It is used in Problem.pm, OpaqueServer.pm, standAlonePGproblemRender - -=head2 new - - my $tbl = WeBWorK::Utils::AttemptsTable->new( - $answers, - answersSubmitted => 1, - answerOrder => $pg->{flags}->{ANSWER_ENTRY_ORDER}, - displayMode => 'MathJax', - showHeadline => 1, - showAnswerNumbers => 0, - showAttemptAnswers => $showAttemptAnswers && $showEvaluatedAnswers, - showAttemptPreviews => $showAttemptPreview, - showAttemptResults => $showAttemptResults, - showCorrectAnswers => $showCorrectAnswers, - showMessages => $showAttemptAnswers, # internally checks for messages - showSummary => $showSummary, - imgGen => $imgGen, # not needed if ce is present , - ce => '', # not needed if $imgGen is present - maketext => WeBWorK::Localize::getLoc("en"), - summary =>'', - ); - - $answers -- a hash of student answers e.g. $pg->{answers} - answersSubmitted if 0 then then the attemptsTable is not displayed (???) - answerOrder -- an array indicating the order the answers appear on the page. - displayMode 'MathJax' and 'images' are the most common - - showHeadline Show the header line 'Results for this submission' - - showAnswerNumbers, showAttemptAnswers, showAttemptPreviews,showAttemptResults, - showCorrectAnswers and showMessages control the display of each column in the table. - - attemptAnswers the student's typed in answer (possibly simplified numerically) - attemptPreview the student's answer after typesetting - attemptResults "correct", "_% correct", "incorrect" or "ungraded"- links to the answer blank - correctAnswers typeset version (untypeset versions are available via popups) - messages warns of formatting typos in the answer, or - more detailed messages about a wrong answer - summary is obtained from $pg->{result}->{summary}. - If this is empty then a (localized) - version of "all answers are correct" - or "at least one answer is not coorrect" - imgGen points to a prebuilt image generator objectfor "images" mode - ce points to the CourseEnvironment -- it is needed if AttemptsTable - is required to build its own imgGen object - maketext points to a localization subroutine - - - - -=head2 Methods - -=over 4 - -=item answerTemplate - -Returns HTML which formats the analysis of the student's answers to the problem. - -=back - -=head2 Read/Write Properties - -=over 4 - -=item correct_ids, incorrect_ids, - -These are references to lists of the ids of the correct answers and the incorrect answers respectively. - -=item showMessages, - -This can be switched on or off before exporting the answerTemplate, perhaps under instructions - from the PG problem. - -=item summary - -The contents of the summary can be defined when the attemptsTable object is created. - -The summary can be defined by the PG problem grader -usually returned as $pg->{result}->{summary}. - -If the summary is not explicitly defined then (localized) versions -of the default summaries are created: - - "The answer above is correct.", - "Some answers will be graded later.", - "All of the [gradeable] answers above are correct.", - "[N] of the questions remain unanswered.", - "At least one of the answers above is NOT [fully] correct.', - -=back - -=cut - -package WeBWorK::Utils::AttemptsTable; -use base qw(Class::Accessor); - -use strict; -use warnings; - -use Scalar::Util 'blessed'; -use WeBWorK::Utils 'wwRound'; -use WeBWorK::PG::Environment; -use CGI; - -# Object contains hash of answer results -# Object contains display mode -# Object contains or creates Image generator -# object returns table - -sub new { - my $class = shift; - $class = (ref($class))? ref($class) : $class; # create a new object of the same class - my $rh_answers = shift; - ref($rh_answers) =~/HASH/ or die "The first entry to AttemptsTable must be a hash of answers"; - my %options = @_; # optional: displayMode=>, submitted=>, imgGen=>, ce=> - my $self = { - answers => $rh_answers // {}, - answerOrder => $options{answerOrder} // [], - answersSubmitted => $options{answersSubmitted} // 0, - summary => $options{summary} // '', # summary provided by problem grader - displayMode => $options{displayMode} || "MathJax", - showHeadline => $options{showHeadline} // 1, - showAnswerNumbers => $options{showAnswerNumbers} // 1, - showAttemptAnswers => $options{showAttemptAnswers} // 1, # show student answer as entered and simplified - # (e.g numerical formulas are calculated to produce numbers) - showAttemptPreviews => $options{showAttemptPreviews} // 1, # show preview of student answer - showAttemptResults => $options{showAttemptResults} // 1, # show whether student answer is correct - showMessages => $options{showMessages} // 1, # show any messages generated by evaluation - showCorrectAnswers => $options{showCorrectAnswers} // 1, # show the correct answers - showSummary => $options{showSummary} // 1, # show summary to students - maketext => $options{maketext} // sub {return @_}, # pointer to the maketext subroutine - imgGen => undef, # created in _init method - }; - bless $self, $class; - # create read only accessors/mutators - $self->mk_ro_accessors(qw(answers answerOrder answersSubmitted displayMode imgGen maketext)); - $self->mk_ro_accessors(qw(showAnswerNumbers showAttemptAnswers showHeadline - showAttemptPreviews showAttemptResults - showCorrectAnswers showSummary)); - $self->mk_accessors(qw(correct_ids incorrect_ids showMessages summary)); - # sanity check and initialize imgGenerator. - _init($self, %options); - return $self; -} - -sub _init { - # verify display mode - # build imgGen - my $self = shift; - my %options = @_; - $self->{submitted}=$options{submitted}//0; - $self->{displayMode} = $options{displayMode} || "MathJax"; - # only show message column if there is at least one message: - my @reallyShowMessages = grep { $self->answers->{$_}->{ans_message} } @{$self->answerOrder}; - $self->showMessages( $self->showMessages && !!@reallyShowMessages ); - # (!! forces boolean scalar environment on list) - # only used internally -- don't need accessors. - $self->{numCorrect}=0; - $self->{numBlanks}=0; - $self->{numEssay}=0; - - if ($self->displayMode eq 'images') { - my $pg_envir = WeBWorK::PG::Environment->new; - - $self->{imgGen} = WeBWorK::PG::ImageGenerator->new( - tempDir => $pg_envir->{directories}{tmp}, - latex => $pg_envir->{externalPrograms}{latex}, - dvipng => $pg_envir->{externalPrograms}{dvipng}, - useCache => 1, - cacheDir => $pg_envir->{directories}{equationCache}, - cacheURL => $pg_envir->{URLs}{equationCache}, - cacheDB => $pg_envir->{equationCacheDB}, - useMarkers => 1, - dvipng_align => $pg_envir->{displayModeOptions}{images}{dvipng_align}, - dvipng_depth_db => $pg_envir->{displayModeOptions}{images}{dvipng_depth_db}, - ); - } -} - -sub maketext { - my $self = shift; -# Uncomment to check that strings are run through maketext -# return 'xXx'.&{$self->{maketext}}(@_).'xXx'; - return &{$self->{maketext}}(@_); -} -sub formatAnswerRow { - my $self = shift; - my $rh_answer = shift; - my $ans_id = shift; - my $answerNumber = shift; - my $answerString = $rh_answer->{student_ans}//''; - # use student_ans and not original_student_ans above. student_ans has had HTML entities translated to prevent XSS. - my $answerPreview = $self->previewAnswer($rh_answer)//' '; - my $correctAnswer = $rh_answer->{correct_ans}//''; - my $correctAnswerPreview = $self->previewCorrectAnswer($rh_answer)//' '; - - my $answerMessage = $rh_answer->{ans_message}//''; - $answerMessage =~ s/\n/
/g; - my $answerScore = $rh_answer->{score}//0; - $self->{numCorrect} += $answerScore >=1; - $self->{numEssay} += ($rh_answer->{type}//'') eq 'essay'; - $self->{numBlanks}++ unless $answerString =~/\S/ || $answerScore >= 1; - - my $feedbackMessageClass = ($answerMessage eq "") ? "" : $self->maketext("FeedbackMessage"); - - my (@correct_ids, @incorrect_ids); - my $resultString; - my $resultStringClass; - if ($answerScore >= 1) { - $resultString = $self->maketext("correct"); - $resultStringClass = "ResultsWithoutError"; - } elsif (($rh_answer->{type} // '') eq 'essay') { - $resultString = $self->maketext("Ungraded"); - $self->{essayFlag} = 1; - } elsif (defined($answerScore) and $answerScore == 0) { - $resultStringClass = "ResultsWithError"; - $resultString = $self->maketext("incorrect"); - } else { - $resultString = $self->maketext("[_1]% correct", wwRound(0, $answerScore * 100)); - } - my $attemptResults = CGI::td({ class => $resultStringClass }, - CGI::a({ href => '#', data_answer_id => $ans_id }, $self->nbsp($resultString))); - - my $row = join('', - ($self->showAnswerNumbers) ? CGI::td({},$answerNumber):'', - ($self->showAttemptAnswers) ? CGI::td({dir=>"auto"},$self->nbsp($answerString)):'' , # student original answer - ($self->showAttemptPreviews)? $self->formatToolTip($answerString, $answerPreview):"" , - ($self->showAttemptResults)? $attemptResults : '' , - ($self->showCorrectAnswers)? $self->formatToolTip($correctAnswer,$correctAnswerPreview):"" , - ($self->showMessages)? CGI::td({class=>$feedbackMessageClass},$self->nbsp($answerMessage)):"", - "\n" - ); - $row; -} - -##################################################### -# determine whether any answers were submitted -# and create answer template if they have been -##################################################### - -sub answerTemplate { - my $self = shift; - my $rh_answers = $self->{answers}; - my @tableRows; - my @correct_ids; - my @incorrect_ids; - - push @tableRows,CGI::Tr( - ($self->showAnswerNumbers) ? CGI::th("#"):'', - ($self->showAttemptAnswers)? CGI::th($self->maketext("Entered")):'', # student original answer - ($self->showAttemptPreviews)? CGI::th($self->maketext("Answer Preview")):'', - ($self->showAttemptResults)? CGI::th($self->maketext("Result")):'', - ($self->showCorrectAnswers)? CGI::th($self->maketext("Correct Answer")):'', - ($self->showMessages)? CGI::th($self->maketext("Message")):'', - ); - - my $answerNumber = 1; - foreach my $ans_id (@{ $self->answerOrder() }) { - push @tableRows, CGI::Tr($self->formatAnswerRow($rh_answers->{$ans_id}, $ans_id, $answerNumber++)); - push @correct_ids, $ans_id if ($rh_answers->{$ans_id}->{score}//0) >= 1; - push @incorrect_ids, $ans_id if ($rh_answers->{$ans_id}->{score}//0) < 1; - #$self->{essayFlag} = 1; - } - my $answerTemplate = ""; - $answerTemplate .= CGI::h3({ class => 'attemptResultsHeader' }, $self->maketext("Results for this submission")) - if $self->showHeadline; - $answerTemplate .= CGI::table({ class => 'attemptResults table table-sm table-bordered' }, @tableRows); - ### "results for this submission" is better than "attempt results" for a headline - $answerTemplate .= ($self->showSummary)? $self->createSummary() : ''; - $answerTemplate = "" unless $self->answersSubmitted; # only print if there is at least one non-blank answer - $self->correct_ids(\@correct_ids); - $self->incorrect_ids(\@incorrect_ids); - $answerTemplate; -} -################################################# - -sub previewAnswer { - my $self =shift; - my $answerResult = shift; - my $displayMode = $self->displayMode; - my $imgGen = $self->imgGen; - - # note: right now, we have to do things completely differently when we are - # rendering math from INSIDE the translator and from OUTSIDE the translator. - # so we'll just deal with each case explicitly here. there's some code - # duplication that can be dealt with later by abstracting out dvipng/etc. - - my $tex = $answerResult->{preview_latex_string}; - - return "" unless defined $tex and $tex ne ""; - - return $tex if $answerResult->{non_tex_preview}; - - if ($displayMode eq "plainText") { - return $tex; - } elsif (($answerResult->{type}//'') eq 'essay') { - return $tex; - } elsif ($displayMode eq "images") { - $imgGen->add($tex); - } elsif ($displayMode eq "MathJax") { - return ''; - } -} - -sub previewCorrectAnswer { - my $self =shift; - my $answerResult = shift; - my $displayMode = $self->displayMode; - my $imgGen = $self->imgGen; - - my $tex = $answerResult->{correct_ans_latex_string}; - return $answerResult->{correct_ans} unless defined $tex and $tex=~/\S/; # some answers don't have latex strings defined - # return "" unless defined $tex and $tex ne ""; - - return $tex if $answerResult->{non_tex_preview}; - - if ($displayMode eq "plainText") { - return $tex; - } elsif ($displayMode eq "images") { - $imgGen->add($tex); - # warn "adding $tex"; - } elsif ($displayMode eq "MathJax") { - return ''; - } -} - -########################################### -# Create summary -########################################### -sub createSummary { - my $self = shift; - my $summary = ""; - my $numCorrect = $self->{numCorrect}; - my $numBlanks = $self->{numBlanks}; - my $numEssay = $self->{numEssay}; - - unless (defined($self->summary) and $self->summary =~ /\S/) { - my @answerNames = @{ $self->answerOrder() }; - if (scalar @answerNames == 1) { #default messages - if ($numCorrect == scalar @answerNames) { - $summary .= - CGI::div({ class => 'ResultsWithoutError mb-2' }, $self->maketext('The answer above is correct.')); - } elsif ($self->{essayFlag}) { - $summary .= CGI::div($self->maketext('Some answers will be graded later.')); - } else { - $summary .= - CGI::div({ class => 'ResultsWithError mb-2' }, $self->maketext('The answer above is NOT correct.')); - } - } else { - if ($numCorrect + $numEssay == scalar @answerNames) { - if ($numEssay) { - $summary .= CGI::div({ class => 'ResultsWithoutError mb-2' }, - $self->maketext('All of the gradeable answers above are correct.')); - } else { - $summary .= CGI::div({ class => 'ResultsWithoutError mb-2' }, - $self->maketext('All of the answers above are correct.')); - } - } elsif ($numBlanks + $numEssay != scalar(@answerNames)) { - $summary .= CGI::div({ class => 'ResultsWithError mb-2' }, - $self->maketext('At least one of the answers above is NOT correct.')); - } - if ($numBlanks > $numEssay) { - my $s = ($numBlanks > 1) ? '' : 's'; - $summary .= CGI::div( - { class => 'ResultsAlert mb-2' }, - $self->maketext( - '[quant,_1,of the questions remains,of the questions remain] unanswered.', $numBlanks - ) - ); - } - } - } else { - $summary = $self->summary; # summary has been defined by grader - } - $summary = CGI::div({role=>"alert", class=>"attemptResultsSummary"}, - $summary); - $self->summary($summary); - return $summary; # return formatted version of summary in class "attemptResultsSummary" div -} -################################################ - -############################################ -# utility subroutine -- prevents unwanted line breaks -############################################ -sub nbsp { - my ($self, $str) = @_; - return (defined $str && $str =~/\S/) ? $str : " "; -} - -# note that formatToolTip output includes CGI::td wrapper -sub formatToolTip { - my $self = shift; - my $answer = shift; - my $formattedAnswer = shift; - return CGI::td(CGI::span({ - class => "answer-preview", - data_bs_toggle => "popover", - data_bs_content => $answer, - data_bs_placement => "bottom", - }, - $self->nbsp($formattedAnswer)) - ); -} - -1; diff --git a/public/css/bootstrap.scss b/public/css/bootstrap.scss new file mode 100644 index 000000000..bc6ea82a9 --- /dev/null +++ b/public/css/bootstrap.scss @@ -0,0 +1,100 @@ +/* WeBWorK Online Homework Delivery System + * Copyright © 2000-2021 The WeBWorK Project, https://github.com/openwebwork + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of either: (a) the GNU General Public License as published by the + * Free Software Foundation; either version 2, or (at your option) any later + * version, or (b) the "Artistic License" which comes with this package. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the + * Artistic License for more details. + */ + +// Include functions first (so you can manipulate colors, SVGs, calc, etc) +@import "../node_modules/bootstrap/scss/functions"; + +// Variable overrides + +// Enable shadows and gradients. These are disabled by default. +$enable-shadows: true; + +// Use a smaller grid gutter width. The default is 1.5rem. +$grid-gutter-width: 1rem; + +// Fonts +$font-size-base: 0.85rem; +$headings-font-weight: 600; + +// Links +$link-decoration: none; +$link-hover-decoration: underline; + +// Make breadcrumb dividers and active items a bit darker. +$breadcrumb-divider-color: #495057; +$breadcrumb-active-color: #495057; + +@import "./theme-colors"; + +// Include the remainder of bootstrap's scss configuration +@import "../node_modules/bootstrap/scss/variables"; +@import "../node_modules/bootstrap/scss/maps"; +@import "../node_modules/bootstrap/scss/mixins"; +@import "../node_modules/bootstrap/scss/utilities"; + +// Layout & components +@import "../node_modules/bootstrap/scss/root"; +@import "../node_modules/bootstrap/scss/reboot"; +@import "../node_modules/bootstrap/scss/type"; +@import "../node_modules/bootstrap/scss/images"; +@import "../node_modules/bootstrap/scss/containers"; +@import "../node_modules/bootstrap/scss/grid"; +@import "../node_modules/bootstrap/scss/tables"; +@import "../node_modules/bootstrap/scss/forms"; +@import "../node_modules/bootstrap/scss/buttons"; +@import "../node_modules/bootstrap/scss/transitions"; +@import "../node_modules/bootstrap/scss/dropdown"; +@import "../node_modules/bootstrap/scss/button-group"; +@import "../node_modules/bootstrap/scss/nav"; +@import "../node_modules/bootstrap/scss/navbar"; +@import "../node_modules/bootstrap/scss/card"; +@import "../node_modules/bootstrap/scss/accordion"; +@import "../node_modules/bootstrap/scss/breadcrumb"; +@import "../node_modules/bootstrap/scss/pagination"; +@import "../node_modules/bootstrap/scss/badge"; +@import "../node_modules/bootstrap/scss/alert"; +@import "../node_modules/bootstrap/scss/placeholders"; +@import "../node_modules/bootstrap/scss/progress"; +@import "../node_modules/bootstrap/scss/list-group"; +@import "../node_modules/bootstrap/scss/close"; +@import "../node_modules/bootstrap/scss/toasts"; +@import "../node_modules/bootstrap/scss/modal"; +@import "../node_modules/bootstrap/scss/tooltip"; +@import "../node_modules/bootstrap/scss/popover"; +@import "../node_modules/bootstrap/scss/carousel"; +@import "../node_modules/bootstrap/scss/spinners"; +@import "../node_modules/bootstrap/scss/offcanvas"; + +// Helpers +@import "../node_modules/bootstrap/scss/helpers"; + +// Utilities +@import "../node_modules/bootstrap/scss/utilities/api"; + +// WeBWorK specific colors +:root { + --ww-logo-background-color: #{$ww-logo-background-color}; + --ww-primary-foreground-color: #{color-contrast($primary)}; + --ww-achievement-level-color: #{$ww-achievement-level-color}; +} + +// Overrides +a:not(.btn):focus { + color: $link-hover-color; + outline-style: solid; + outline-color: $link-hover-color; + outline-width: 1px; +} + +@import "theme-overrides"; diff --git a/public/css/rtl.css b/public/css/rtl.css new file mode 100644 index 000000000..979d21a46 --- /dev/null +++ b/public/css/rtl.css @@ -0,0 +1,20 @@ +/* WeBWorK Online Homework Delivery System + * Copyright © 2000-2022 The WeBWorK Project, https://github.com/openwebwork + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of either: (a) the GNU General Public License as published by the + * Free Software Foundation; either version 2, or (at your option) any later + * version, or (b) the "Artistic License" which comes with this package. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the + * Artistic License for more details. + */ + +/* --- Modify some CSS for Right to left courses/problems --- */ + +/* The changes which were needed here in WeBWorK 2.16 are no + * longer needed in WeBWorK 2.17. The file is being retained + * for potential future use. */ + diff --git a/public/generate-assets.js b/public/generate-assets.js new file mode 100755 index 000000000..fa9a061ce --- /dev/null +++ b/public/generate-assets.js @@ -0,0 +1,219 @@ +#!/usr/bin/env node + +/* eslint-env node */ + +const yargs = require('yargs'); +const chokidar = require('chokidar'); +const path = require('path'); +const { minify } = require('terser'); +const fs = require('fs'); +const crypto = require('crypto'); +const sass = require('sass'); +const autoprefixer = require('autoprefixer'); +const postcss = require('postcss'); +const rtlcss = require('rtlcss'); +const cssMinify = require('cssnano'); + +const argv = yargs + .usage('$0 Options').version(false).alias('help', 'h').wrap(100) + .option('enable-sourcemaps', { + alias: 's', + description: 'Generate source maps. (Not for use in production!)', + type: 'boolean' + }) + .option('watch-files', { + alias: 'w', + description: 'Continue to watch files for changes. (Developer tool)', + type: 'boolean' + }) + .option('clean', { + alias: 'd', + description: 'Delete all generated files.', + type: 'boolean' + }) + .argv; + +const assetFile = path.resolve(__dirname, 'static-assets.json'); +const assets = {}; + +const cleanDir = (dir) => { + for (const file of fs.readdirSync(dir, { withFileTypes: true })) { + if (file.isDirectory()) { + cleanDir(path.resolve(dir, file.name)); + } else { + if (/.[a-z0-9]{8}.min.(css|js)$/.test(file.name)) { + const fullPath = path.resolve(dir, file.name); + console.log(`\x1b[34mRemoving ${fullPath} from previous build.\x1b[0m`); + fs.unlinkSync(fullPath); + } + } + } +} + +// The is set to true after all files are processed for the first time. +let ready = false; + +const processFile = async (file, _details) => { + if (file) { + const baseName = path.basename(file); + + if (/(? { + // If a file is deleted, then also delete the corresponding generated file. + if (assets[file]) { + console.log(`\x1b[34mDeleting minified file for ${file}.\x1b[0m`); + fs.unlinkSync(path.resolve(__dirname, assets[file])); + delete assets[file]; + } + }) + .on('error', (error) => console.log(`\x1b[32m${error}\x1b[0m`)); diff --git a/public/images/favicon.ico b/public/images/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..92fc9bea956e9badce98fed4066045e6c6063ec5 GIT binary patch literal 370931 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

}dpB?l6U;K_&yq?KhYhbi0Fl zy0)4qcs5SL&tz$S4Ly z&4)Ri#G!CU??>-gY6;ArV-GZF$c5XqpQZ|;d}W!a@Q?9#HC8Cw1}Hdw>|Yh{SQ&vC zfd~dQG?r=$x25{BwM8pk$Gr;T?CT;#kmk~`c$mLzv+IWsoqvNlh65WTN8r4=3cz8~ z7Z69{xFJ#XU_IG2-+pk?aEC5);RyD zv4BwG@r=@f?Z$Am23@#tqL?3v0WMa3(wGj)=h+BuXy85PFp*7>!h{aevR`!ycU9fe zy~mC8G4+~xPX)UavlP=5t1vai#WO4ry}<&afzvp0{j)pr6D2PwFK7>1q@MxLSHT86 z;vMoCzKULuqm6GS$rsC)7+8Uo7U{PbQNv_tGyqC?2E5fB%CdOIKJ>&GVoqA#?aaKs z^fqCa5|b8_9!2?Yhz29tIt;u1@6y zPc%I5*_}x0<=mbHFs06D%H}@XkPr+B$+QesE*1C+1H^Wx{|@JTgsu8o&sxvxR{^ad ztzt*+FkHlr{2g2b;ru8*uE?UY(P?6J8t-lOf{5dH?341J=#m)K-AoDUB=>}6kOSDPFrMhbLcdde*CER z3h7-(+_IgihZ{Rt&$sK&f{|m(u*BI5xN<&5GCa0$E4X7VLPsYbgx%ZN+dxa|9|k3c z_7*^L3D^efj_%^se4VCO(Co+S0;|T;W!pgRqo1dr&#rW*z^L(im-ds1di&W|sJQx}I zTRW=XZr5JzdORw|G?BNd=c>ONAVvx}JFd>|j^JqET-!6xjGVDI?N42_>uFqDw%q1| z$iDh^{@z$CSx(opBVrBKmO!UHR-P(d@uDQPi|yF-qwDa!e)P($)Gx6rDgVYr73rxW zNLgMlz!-RO4H&r@xmoFBEP72j61ea;Sx>gvCJM2nH=$4D`Z0VMPv?AHlVWE^hkk#p zY#|=A8O2K%EB#R2e@_qUj&bp-`|EcoFLjH9vL<>13hQUW0%2USt{e@S3)Zs>8F4#N zT?T3qos3pWZeQiL-6s(Wj7dt zK{zuklfT!^wJWD_Y`_lu+AI)IfW34t9e;L^9%45he+Yoyy1VAn6h#zL9C8Q~0+^)( z#Kt>a+O5)49V<;0%ZIHy*-axphEm%pd*m-TB<$TsOw%0DALn|(RN4^6+7zYO;4oQk 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

A88cLUr2boX_vGis$3@c^F+!x75956r0sM}z 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