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..6d513f738 --- /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} + ? 'static' + : $inputs_ref->{outputFormat} // $inputs_ref->{outputformat} // 'simple'; + + # 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/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; } @@ -212,21 +213,21 @@ sub process_problem { # 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}, - ); + # 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; } ########################################### @@ -254,7 +255,7 @@ 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}, @@ -293,8 +294,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 +333,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}; # store the current answer/response state for each entry foreach my $ans (keys %{$pg->{answers}}) { @@ -351,7 +352,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 +380,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 000000000..92fc9bea9 Binary files /dev/null and b/public/images/favicon.ico differ diff --git a/public/js/apps/CSSMessage/css-message.js b/public/js/apps/CSSMessage/css-message.js new file mode 100644 index 000000000..f3adc5861 --- /dev/null +++ b/public/js/apps/CSSMessage/css-message.js @@ -0,0 +1,45 @@ +window.document.getElementsByName('JWTanswerURLstatus').forEach(e => { + console.log("response message ", JSON.parse(e.value)); + window.parent.postMessage(e.value, '*'); +}); + +window.addEventListener('message', event => { + let message; + try { + message = JSON.parse(event.data); + } + catch (e) { + return; + } + + if (message.hasOwnProperty('elements')) { + message.elements.forEach((incoming) => { + let elements; + if (incoming.hasOwnProperty('selector')) { + elements = window.document.querySelectorAll(incoming.selector); + if (incoming.hasOwnProperty('style')) { + elements.forEach(el => { el.style.cssText = incoming.style }); + } + if (incoming.hasOwnProperty('class')) { + elements.forEach(el => { el.className = incoming.class }); + } + } + }); + event.source.postMessage('updated elements', event.origin); + } + + if (message.hasOwnProperty('templates')) { + message.templates.forEach((cssString) => { + const element = document.createElement('style'); + element.innerText = cssString; + document.head.insertAdjacentElement('beforeend', element); + }); + event.source.postMessage('updated templates', event.origin); + } + + if (message.hasOwnProperty('showSolutions')) { + const elements = Array.from(window.document.querySelectorAll('.knowl[data-type="solution"]')); + const solutions = elements.map(el => el.dataset.knowlContents); + event.source.postMessage(JSON.stringify({ solutions: solutions }), event.origin); + } +}); diff --git a/public/Problem/mathjax-config.js b/public/js/apps/MathJaxConfig/mathjax-config.js similarity index 81% rename from public/Problem/mathjax-config.js rename to public/js/apps/MathJaxConfig/mathjax-config.js index 73ba8128b..3ef132bef 100644 --- a/public/Problem/mathjax-config.js +++ b/public/js/apps/MathJaxConfig/mathjax-config.js @@ -1,11 +1,12 @@ if (!window.MathJax) { window.MathJax = { tex: { - packages: { '[+]': ['noerrors'] } + packages: {'[+]': ['noerrors']}, + processEscapes: false, }, loader: { load: ['input/asciimath', '[tex]/noerrors'] }, startup: { - ready: function () { + ready: function() { var AM = MathJax.InputJax.AsciiMath.AM; for (var i = 0; i < AM.symbols.length; i++) { if (AM.symbols[i].input == '**') { @@ -18,13 +19,13 @@ if (!window.MathJax) { options: { renderActions: { findScript: [10, function (doc) { - document.querySelectorAll('script[type^="math/tex"]').forEach(function (node) { + document.querySelectorAll('script[type^="math/tex"]').forEach(function(node) { var display = !!node.type.match(/; *mode=display/); var math = new doc.options.MathItem(node.textContent, doc.inputJax[0], display); var text = document.createTextNode(''); node.parentNode.replaceChild(text, node); - math.start = { node: text, delim: '', n: 0 }; - math.end = { node: text, delim: '', n: 0 }; + math.start = {node: text, delim: '', n: 0}; + math.end = {node: text, delim: '', n: 0}; doc.math.push(math); }); }, ''] diff --git a/public/js/apps/PGCodeMirror/PG.js b/public/js/apps/PGCodeMirror/PG.js new file mode 100644 index 000000000..62de45cfd --- /dev/null +++ b/public/js/apps/PGCodeMirror/PG.js @@ -0,0 +1,1460 @@ +// Bassed off of CodeMirror mode perl file: +// https://github.com/codemirror/CodeMirror/blob/master/mode/perl/perl.js + +'use strict'; + +(() => { + CodeMirror.defineMode("PG",function(){ + // http://perldoc.perl.org + const PERL={ // null - magic touch + // 1 - keyword + // 2 - def + // 3 - atom + // 4 - operator + // 5 - variable-2 (predefined) + // [x,y] - x=1,2,3; y=must be defined if x{...} + // PERL operators + '->' : 4, + '++' : 4, + '--' : 4, + '**' : 4, + // ! ~ \ and unary + and - + '=~' : 4, + '!~' : 4, + '*' : 4, + '/' : 4, + '%' : 4, + 'x' : 4, + '+' : 4, + '-' : 4, + '.' : 4, + '<<' : 4, + '>>' : 4, + // named unary operators + '<' : 4, + '>' : 4, + '<=' : 4, + '>=' : 4, + 'lt' : 4, + 'gt' : 4, + 'le' : 4, + 'ge' : 4, + '==' : 4, + '!=' : 4, + '<=>' : 4, + 'eq' : 4, + 'ne' : 4, + 'cmp' : 4, + '~~' : 4, + '&' : 4, + '|' : 4, + '^' : 4, + '&&' : 4, + '||' : 4, + '//' : 4, + '..' : 4, + '...' : 4, + '?' : 4, + ':' : 4, + '=' : 4, + '+=' : 4, + '-=' : 4, + '*=' : 4, // etc. ??? + ',' : 4, + '=>' : 4, + '::' : 4, + // list operators (rightward) + 'not' : 4, + 'and' : 4, + 'or' : 4, + 'xor' : 4, + // PERL predefined variables (I know, what this is a paranoid idea, but may be needed for people, who learn PERL, and for me as well, ...and may be for you?;) + 'BEGIN' : [5,1], + 'END' : [5,1], + 'PRINT' : [5,1], + 'PRINTF' : [5,1], + 'GETC' : [5,1], + 'READ' : [5,1], + 'READLINE' : [5,1], + 'DESTROY' : [5,1], + 'TIE' : [5,1], + 'TIEHANDLE' : [5,1], + 'UNTIE' : [5,1], + 'STDIN' : 5, + 'STDIN_TOP' : 5, + 'STDOUT' : 5, + 'STDOUT_TOP' : 5, + 'STDERR' : 5, + 'STDERR_TOP' : 5, + '$ARG' : 5, + '$_' : 5, + '@ARG' : 5, + '@_' : 5, + '$LIST_SEPARATOR' : 5, + '$"' : 5, + '$PROCESS_ID' : 5, + '$PID' : 5, + '$$' : 5, + '$REAL_GROUP_ID' : 5, + '$GID' : 5, + '$(' : 5, + '$EFFECTIVE_GROUP_ID' : 5, + '$EGID' : 5, + '$)' : 5, + '$PROGRAM_NAME' : 5, + '$0' : 5, + '$SUBSCRIPT_SEPARATOR' : 5, + '$SUBSEP' : 5, + '$;' : 5, + '$REAL_USER_ID' : 5, + '$UID' : 5, + '$<' : 5, + '$EFFECTIVE_USER_ID' : 5, + '$EUID' : 5, + '$>' : 5, +// '$a' : 5, +// '$b' : 5, + '$COMPILING' : 5, + '$^C' : 5, + '$DEBUGGING' : 5, + '$^D' : 5, + '${^ENCODING}' : 5, + '$ENV' : 5, + '%ENV' : 5, + '$SYSTEM_FD_MAX' : 5, + '$^F' : 5, + '@F' : 5, + '${^GLOBAL_PHASE}' : 5, + '$^H' : 5, + '%^H' : 5, + '@INC' : 5, + '%INC' : 5, + '$INPLACE_EDIT' : 5, + '$^I' : 5, + '$^M' : 5, + '$OSNAME' : 5, + '$^O' : 5, + '${^OPEN}' : 5, + '$PERLDB' : 5, + '$^P' : 5, + '$SIG' : 5, + '%SIG' : 5, + '$BASETIME' : 5, + '$^T' : 5, + '${^TAINT}' : 5, + '${^UNICODE}' : 5, + '${^UTF8CACHE}' : 5, + '${^UTF8LOCALE}' : 5, + '$PERL_VERSION' : 5, + '$^V' : 5, + '${^WIN32_SLOPPY_STAT}' : 5, + '$EXECUTABLE_NAME' : 5, + '$^X' : 5, + '$1' : 5, // - regexp $1, $2... + '$MATCH' : 5, + '$&' : 5, + '${^MATCH}' : 5, + '$PREMATCH' : 5, + '$`' : 5, + '${^PREMATCH}' : 5, + '$POSTMATCH' : 5, + "$'" : 5, + '${^POSTMATCH}' : 5, + '$LAST_PAREN_MATCH' : 5, + '$+' : 5, + '$LAST_SUBMATCH_RESULT' : 5, + '$^N' : 5, + '@LAST_MATCH_END' : 5, + '@+' : 5, + '%LAST_PAREN_MATCH' : 5, + '%+' : 5, + '@LAST_MATCH_START' : 5, + '@-' : 5, + '%LAST_MATCH_START' : 5, + '%-' : 5, + '$LAST_REGEXP_CODE_RESULT' : 5, + '$^R' : 5, + '${^RE_DEBUG_FLAGS}' : 5, + '${^RE_TRIE_MAXBUF}' : 5, + '$ARGV' : 5, + '@ARGV' : 5, + 'ARGV' : 5, + 'ARGVOUT' : 5, + '$OUTPUT_FIELD_SEPARATOR' : 5, + '$OFS' : 5, + '$,' : 5, + '$INPUT_LINE_NUMBER' : 5, + '$NR' : 5, + '$.' : 5, + '$INPUT_RECORD_SEPARATOR' : 5, + '$RS' : 5, + '$/' : 5, + '$OUTPUT_RECORD_SEPARATOR' : 5, + '$ORS' : 5, + '$\\' : 5, + '$OUTPUT_AUTOFLUSH' : 5, + '$|' : 5, + '$ACCUMULATOR' : 5, + '$^A' : 5, + '$FORMAT_FORMFEED' : 5, + '$^L' : 5, + '$FORMAT_PAGE_NUMBER' : 5, + '$%' : 5, + '$FORMAT_LINES_LEFT' : 5, + '$-' : 5, + '$FORMAT_LINE_BREAK_CHARACTERS' : 5, + '$:' : 5, + '$FORMAT_LINES_PER_PAGE' : 5, + '$=' : 5, + '$FORMAT_TOP_NAME' : 5, + '$^' : 5, + '$FORMAT_NAME' : 5, + '$~' : 5, + '${^CHILD_ERROR_NATIVE}' : 5, + '$EXTENDED_OS_ERROR' : 5, + '$^E' : 5, + '$EXCEPTIONS_BEING_CAUGHT' : 5, + '$^S' : 5, + '$WARNING' : 5, + '$^W' : 5, + '${^WARNING_BITS}' : 5, + '$OS_ERROR' : 5, + '$ERRNO' : 5, + '$!' : 5, + '%OS_ERROR' : 5, + '%ERRNO' : 5, + '%!' : 5, + '$CHILD_ERROR' : 5, + '$?' : 5, + '$EVAL_ERROR' : 5, + '$@' : 5, + '$OFMT' : 5, + '$#' : 5, + '$*' : 5, + '$ARRAY_BASE' : 5, + '$[' : 5, + '$OLD_PERL_VERSION' : 5, + '$]' : 5, + // PERL blocks + 'if' :[1,1], + elsif :[1,1], + 'else' :[1,1], + 'while' :[1,1], + unless :[1,1], + until :[1,1], + 'for' :[1,1], + foreach :[1,1], + // PERL functions + 'abs' :1, // - absolute value function + accept :1, // - accept an incoming socket connect + alarm :1, // - schedule a SIGALRM + 'atan2' :1, // - arctangent of Y/X in the range -PI to PI + bind :1, // - binds an address to a socket + binmode :1, // - prepare binary files for I/O + bless :1, // - create an object + bootstrap :1, // + 'break' :1, // - break out of a "given" block + caller :1, // - get context of the current subroutine call + chdir :1, // - change your current working directory + chmod :1, // - changes the permissions on a list of files + chomp :1, // - remove a trailing record separator from a string + chop :1, // - remove the last character from a string + chown :1, // - change the ownership on a list of files + chr :1, // - get character this number represents + chroot :1, // - make directory new root for path lookups + close :1, // - close file (or pipe or socket) handle + closedir :1, // - close directory handle + connect :1, // - connect to a remote socket + 'continue' :[1,1], // - optional trailing block in a while or foreach + 'cos' :1, // - cosine function + crypt :1, // - one-way passwd-style encryption + dbmclose :1, // - breaks binding on a tied dbm file + dbmopen :1, // - create binding on a tied dbm file + 'default' :1, // + defined :1, // - test whether a value, variable, or function is defined + 'delete' :1, // - deletes a value from a hash + die :1, // - raise an exception or bail out + 'do' :1, // - turn a BLOCK into a TERM + dump :1, // - create an immediate core dump + each :1, // - retrieve the next key/value pair from a hash + endgrent :1, // - be done using group file + endhostent :1, // - be done using hosts file + endnetent :1, // - be done using networks file + endprotoent :1, // - be done using protocols file + endpwent :1, // - be done using passwd file + endservent :1, // - be done using services file + eof :1, // - test a filehandle for its end + 'eval' :1, // - catch exceptions or compile and run code + 'exec' :1, // - abandon this program to run another + exists :1, // - test whether a hash key is present + exit :1, // - terminate this program + 'exp' :1, // - raise I to a power + fcntl :1, // - file control system call + fileno :1, // - return file descriptor from filehandle + flock :1, // - lock an entire file with an advisory lock + fork :1, // - create a new process just like this one + format :1, // - declare a picture format with use by the write() function + formline :1, // - internal function used for formats + getc :1, // - get the next character from the filehandle + getgrent :1, // - get next group record + getgrgid :1, // - get group record given group user ID + getgrnam :1, // - get group record given group name + gethostbyaddr :1, // - get host record given its address + gethostbyname :1, // - get host record given name + gethostent :1, // - get next hosts record + getlogin :1, // - return who logged in at this tty + getnetbyaddr :1, // - get network record given its address + getnetbyname :1, // - get networks record given name + getnetent :1, // - get next networks record + getpeername :1, // - find the other end of a socket connection + getpgrp :1, // - get process group + getppid :1, // - get parent process ID + getpriority :1, // - get current nice value + getprotobyname :1, // - get protocol record given name + getprotobynumber :1, // - get protocol record numeric protocol + getprotoent :1, // - get next protocols record + getpwent :1, // - get next passwd record + getpwnam :1, // - get passwd record given user login name + getpwuid :1, // - get passwd record given user ID + getservbyname :1, // - get services record given its name + getservbyport :1, // - get services record given numeric port + getservent :1, // - get next services record + getsockname :1, // - retrieve the sockaddr for a given socket + getsockopt :1, // - get socket options on a given socket + given :1, // + glob :1, // - expand filenames using wildcards + gmtime :1, // - convert UNIX time into record or string using Greenwich time + 'goto' :1, // - create spaghetti code + grep :1, // - locate elements in a list test true against a given criterion + hex :1, // - convert a string to a hexadecimal number + 'import' :1, // - patch a module's namespace into your own + index :1, // - find a substring within a string + 'int' :1, // - get the integer portion of a number + ioctl :1, // - system-dependent device control system call + 'join' :1, // - join a list into a string using a separator + keys :1, // - retrieve list of indices from a hash + kill :1, // - send a signal to a process or process group + last :1, // - exit a block prematurely + lc :1, // - return lower-case version of a string + lcfirst :1, // - return a string with just the next letter in lower case + length :1, // - return the number of bytes in a string + 'link' :1, // - create a hard link in the filesystem + listen :1, // - register your socket as a server + local : 2, // - create a temporary value for a global variable (dynamic scoping) + localtime :1, // - convert UNIX time into record or string using local time + lock :1, // - get a thread lock on a variable, subroutine, or method + 'log' :1, // - retrieve the natural logarithm for a number + lstat :1, // - stat a symbolic link + m :null, // - match a string with a regular expression pattern + map :1, // - apply a change to a list to get back a new list with the changes + mkdir :1, // - create a directory + msgctl :1, // - SysV IPC message control operations + msgget :1, // - get SysV IPC message queue + msgrcv :1, // - receive a SysV IPC message from a message queue + msgsnd :1, // - send a SysV IPC message to a message queue + my : 2, // - declare and assign a local variable (lexical scoping) + 'new' :1, // + next :1, // - iterate a block prematurely + no :1, // - unimport some module symbols or semantics at compile time + oct :1, // - convert a string to an octal number + open :1, // - open a file, pipe, or descriptor + opendir :1, // - open a directory + ord :1, // - find a character's numeric representation + our : 2, // - declare and assign a package variable (lexical scoping) + pack :1, // - convert a list into a binary representation + 'package' :1, // - declare a separate global namespace + pipe :1, // - open a pair of connected filehandles + pop :1, // - remove the last element from an array and return it + pos :1, // - find or set the offset for the last/next m//g search + print :1, // - output a list to a filehandle + printf :1, // - output a formatted list to a filehandle + prototype :1, // - get the prototype (if any) of a subroutine + push :1, // - append one or more elements to an array + q :null, // - singly quote a string + qq :null, // - doubly quote a string + qr :null, // - Compile pattern + quotemeta :null, // - quote regular expression magic characters + qw :null, // - quote a list of words + qx :null, // - backquote quote a string + rand :1, // - retrieve the next pseudorandom number + read :1, // - fixed-length buffered input from a filehandle + readdir :1, // - get a directory from a directory handle + readline :1, // - fetch a record from a file + readlink :1, // - determine where a symbolic link is pointing + readpipe :1, // - execute a system command and collect standard output + recv :1, // - receive a message over a Socket + redo :1, // - start this loop iteration over again + ref :1, // - find out the type of thing being referenced + rename :1, // - change a filename + require :1, // - load in external functions from a library at runtime + reset :1, // - clear all variables of a given name + 'return' :1, // - get out of a function early + reverse :1, // - flip a string or a list + rewinddir :1, // - reset directory handle + rindex :1, // - right-to-left substring search + rmdir :1, // - remove a directory + s :null, // - replace a pattern with a string + say :1, // - print with newline + scalar :1, // - force a scalar context + seek :1, // - reposition file pointer for random-access I/O + seekdir :1, // - reposition directory pointer + select :1, // - reset default output or do I/O multiplexing + semctl :1, // - SysV semaphore control operations + semget :1, // - get set of SysV semaphores + semop :1, // - SysV semaphore operations + send :1, // - send a message over a socket + setgrent :1, // - prepare group file for use + sethostent :1, // - prepare hosts file for use + setnetent :1, // - prepare networks file for use + setpgrp :1, // - set the process group of a process + setpriority :1, // - set a process's nice value + setprotoent :1, // - prepare protocols file for use + setpwent :1, // - prepare passwd file for use + setservent :1, // - prepare services file for use + setsockopt :1, // - set some socket options + shift :1, // - remove the first element of an array, and return it + shmctl :1, // - SysV shared memory operations + shmget :1, // - get SysV shared memory segment identifier + shmread :1, // - read SysV shared memory + shmwrite :1, // - write SysV shared memory + shutdown :1, // - close down just half of a socket connection + 'sin' :1, // - return the sine of a number + sleep :1, // - block for some number of seconds + socket :1, // - create a socket + socketpair :1, // - create a pair of sockets + 'sort' :1, // - sort a list of values + splice :1, // - add or remove elements anywhere in an array + 'split' :1, // - split up a string using a regexp delimiter + sprintf :1, // - formatted print into a string + 'sqrt' :1, // - square root function + srand :1, // - seed the random number generator + stat :1, // - get a file's status information + state :1, // - declare and assign a state variable (persistent lexical scoping) + study :1, // - optimize input data for repeated searches + 'sub' :1, // - declare a subroutine, possibly anonymously + 'substr' :1, // - get or alter a portion of a string + symlink :1, // - create a symbolic link to a file + syscall :1, // - execute an arbitrary system call + sysopen :1, // - open a file, pipe, or descriptor + sysread :1, // - fixed-length unbuffered input from a filehandle + sysseek :1, // - position I/O pointer on handle used with sysread and syswrite + system :1, // - run a separate program + syswrite :1, // - fixed-length unbuffered output to a filehandle + tell :1, // - get current seekpointer on a filehandle + telldir :1, // - get current seekpointer on a directory handle + tie :1, // - bind a variable to an object class + tied :1, // - get a reference to the object underlying a tied variable + time :1, // - return number of seconds since 1970 + times :1, // - return elapsed time for self and child processes + tr :null, // - transliterate a string + truncate :1, // - shorten a file + uc :1, // - return upper-case version of a string + ucfirst :1, // - return a string with just the next letter in upper case + umask :1, // - set file creation mode mask + undef :1, // - remove a variable or function definition + unlink :1, // - remove one link to a file + unpack :1, // - convert binary structure into normal perl variables + unshift :1, // - prepend more elements to the beginning of a list + untie :1, // - break a tie binding to a variable + use :1, // - load in a module at compile time + utime :1, // - set a file's last access and modify times + values :1, // - return a list of the values in a hash + vec :1, // - test or set particular bits in a string + wait :1, // - wait for any child process to die + waitpid :1, // - wait for a particular child process to die + wantarray :1, // - get void vs scalar vs list context of current subroutine call + warn :1, // - print debugging info + when :1, // + write :1, // - print a picture record + y :null, // - transliterate a string + }; + // PG Keywords and Variables + const PGstyle = 'atom'; + const PGkeyword = 'keyword'; + const PGcmds = new Set([ + 'DOCUMENT', + 'ENDDOCUMENT', + 'loadMacros', + 'TEXT', + 'SOLUTION', + 'HINT', + 'STATEMENT', + 'COMMENT', + 'MODES', + 'htmlLink', + 'helpLink', + 'knowlLink', + 'image', + 'Context', + 'Compute', + 'Real', + 'Formula', + 'String', + 'List', + 'Complex', + 'Point', + 'Vector', + 'Matrix', + 'Interval', + 'Set', + 'Fraction', + 'ANS', + 'NAMED_ANS', + 'WEIGHTED_ANS', + 'MultiAnswer', + 'Value', + 'random', + 'list_random', + 'non_zero_random', + 'NchooseK', + ]); + const PGvars = new Set([ + 'BR', + 'RBR', + 'PAR', + 'LQ', + 'RQ', + 'BM', + 'EM', + 'BDM', + 'EDM', + 'LTS', + 'GTS', + 'LTE', + 'GTE', + 'BEGIN_ONE_COLUMN', + 'END_ONE_COLUMN', + 'SOL', + 'SOLUTION', + 'HINT', + 'COMMENT', + 'US', + 'SPACE', + 'NBSP', + 'NDASH', + 'MDASH', + 'BLABEL', + 'ELABEL', + 'BBOLD', + 'EBOLD', + 'BITALIC', + 'EITALIC', + 'BUL', + 'EUL', + 'BCENTER', + 'ECENTER', + 'BLTR', + 'ELTR', + 'BKBD', + 'EKBD', + 'HR', + 'LBRACE', + 'RBRACE', + 'LB', + 'RB', + 'DOLLAR', + 'PERCENT', + 'CARET', + 'PI', + 'E', + 'LATEX', + 'TEX', + 'APOS', + 'showPartialCorrectAnswers', + 'refreshCachedImages', + 'ITEM', + 'ITEMSEP' + ]); + + const RXstyle="string-2"; + const RXmodifiers=/[goseximacplud]/; // NOTE: "m", "s", "y" and "tr" need to correct real modifiers for each regexp type + + function tokenChain(stream,state,chain,style,tail,tokener){ // NOTE: chain.length > 2 is not working now (it's for s[...][...]geos;) + state.chain=null; // 12 3tail + state.style=null; + state.tail=null; + state.tokenize=function(stream,state){ + var e=false,c,i=0; + while(c=stream.next()){ + if(c===chain[i]&&!e){ + if(chain[++i]!==undefined){ + state.chain=chain[i]; + state.style=style; + state.tail=tail;} + else if(tail) + stream.eatWhile(tail); + state.tokenize=tokener || tokenPerl; + return style;} + e=!e&&c=="\\";} + return style;}; + return state.tokenize(stream,state);} + + function tokenSOMETHING(stream,state,string){ + state.tokenize=function(stream,state){ + if(stream.string==string) + state.tokenize=tokenPerl; + stream.skipToEnd(); + return "string";}; + return state.tokenize(stream,state);} + + // EV3 block formatting + function tokenEV3(stream,state,string,style,prevState){ + state.tokenize = function(stream,state) { + if (prevState && prevState.mode == "math") { + var reg = new RegExp("^\\\\"+string); + } else { + var reg = new RegExp("^"+string); + } + if (stream.match(reg)) { + if (!prevState) { + state.tokenize = tokenPerl; + return PGstyle; + } else { + state.tokenize = function(stream,state) { + return tokenEV3(stream,state,prevState.string, + prevState.style,prevState.prevState); + } + } + if (string.includes("BOLD")) + return PGstyle + " strong"; + if (string.includes("ITALIC")) + return PGstyle + " em"; + if (prevState.endstyle) + return prevState.endstyle; + return style; + } else { + state.tokenize = function(stream,state) { + return tokenEV3(stream,state,string,style,prevState); + } + } + + const newPrevState = {}; + if (prevState) { + newPrevState.prevState = JSON.parse(JSON.stringify(prevState)); + } else { + newPrevState.prevState = null; + } + newPrevState.style = style; + newPrevState.string = string; + + if (prevState && prevState.mode == "cmd") { // Some additional formatting for perl code blocks + newPrevState.mode = "cmd"; + if (stream.match(/^[$@%]{/)) { // ${, @{, %{ nested blocks + style = "variable"; + state.tokenize = function(stream,state) { + return tokenEV3(stream,state,"\\}",style,newPrevState); + } + return style; + } + if (stream.match(/^\(/)) { // Nested ( ) blocks + newPrevState.endstyle = "variable"; + state.tokenize = function(stream,state) { + return tokenEV3(stream,state,"\\)",style,newPrevState); + } + return "variable"; + } + if (stream.match(/^\[/)) { // Nested [ ] blocks + newPrevState.endstyle = "variable"; + state.tokenize = function(stream,state) { + return tokenEV3(stream,state,"\\]",style,newPrevState); + } + return "variable"; + } + if (stream.match(/^\{/)) { // Nested { } blocks + newPrevState.endstyle = "variable"; + state.tokenize = function(stream,state) { + return tokenEV3(stream,state,"\\}",style,newPrevState); + } + return "variable"; + } + if (stream.match(/^\w+/)) { // Check for PG keywords + if (PGcmds.has(stream.current())) + return PGkeyword; + else + return style; + } + if (stream.match(/^['"]/)) { // Quotes + return tokenChain(stream,state,[stream.current()],"string",null,function(stream,state) { + tokenEV3(stream,state,string,style,prevState) }); + } + if (stream.match(/^[=,;/\*><%&|.~?:+/-]/)) { // Catch some perl operators + return "variable"; + } + } + + if (stream.match(/^\\\(/)) { // \(...\) TeX block + if (prevState) { + style = "error"; + } else { + style = "comment"; + } + state.tokenize = function(stream,state) { + newPrevState.mode = "math"; + return tokenEV3(stream,state,"\\)",style,newPrevState); + } + } else if (stream.match(/^\\\[/)) { // \[...\] TeX block + if (prevState) { + style = "error"; + } else { + style = "comment"; + } + state.tokenize = function(stream,state) { + newPrevState.mode = "math"; + return tokenEV3(stream,state,"\\]",style,newPrevState); + } + } else if (stream.match(/^\\{/)) { // \{...\} Perl code block + if (prevState) { + style = "error"; + } else { + style = "variable-2"; + } + state.tokenize = function(stream,state) { + newPrevState.mode = "cmd"; + return tokenEV3(stream,state,"\\\\}",style,newPrevState); + } + } else if (stream.match(/^``/)) { // ``...`` math object math + if (prevState && prevState.mode != "math") { + style = "error"; + } else { + style = "variable-3"; + } + state.tokenize = function(stream,state) { + return tokenEV3(stream,state,"``",style,newPrevState); + } + } else if (stream.match(/^`/)) { // `...` math object math + if (prevState && prevState.mode != "math") { + style = "error"; + } else { + style = "variable-3"; + } + state.tokenize = function(stream,state) { + return tokenEV3(stream,state,"`([^`]|$)",style,newPrevState,"tick"); + } + } else if (stream.match(/^(\$BBOLD|\${BBOLD})/)) { // Bold + style = style = " strong"; + state.tokenize = function(stream,state) { + return tokenEV3(stream,state,"(\\$EBOLD|\\${EBOLD})",style,newPrevState); + } + return PGstyle + " strong"; + } else if (stream.match(/^(\$BITALIC|\${BITALIC})/)) { // Italic + style = style + " em"; + state.tokenize = function(stream,state) { + return tokenEV3(stream,state,"(\\$EITALIC|\\${EITALIC})",style,newPrevState); + } + return PGstyle + " em"; + } else if (stream.match(/^[$@%]\w+/)) { // PG Variables + if (PGvars.has(stream.current().substring(1))) + return PGstyle; + return "variable"; + } else if (stream.match(/^[$@%]{\w+}/)) { // ${foo} PG variables + if (PGvars.has(stream.current().slice(2,-1))) + return PGstyle; + return "variable"; + } else if (stream.match(/^ +$/)) { // Trailing white space + return "trailingspace"; + } else if (stream.match(/^[\[\]\\ (){}$@%`]/)) { // Advance a single character if special + return style; + } else { // Otherwise advance through all non special characters + if (prevState && prevState.mode == "cmd") { // Only eat through words in perl code mode + if (stream.match(/\w+/)) + return style; + else + stream.next(); + } else { + stream.eatWhile(/[^\[\]\\ (){}$@%`]/); + } + } + return style; + } + return state.tokenize(stream,state); + } + + // No additional formatting inside comment block, only looks for end string. + // Currently only used for comments and ``` code blocks. + // The final stream.match and stream.eatWhile may need updated if used for other blocks. + function tokenPGMLComment(stream,state,string,style,prevState){ + state.tokenize = function(stream,state) { + var reg = new RegExp("^"+string); + if (stream.match(reg)) { + state.tokenize = function(stream,state) { + return tokenPGML(stream,state,prevState.string, + prevState.style,prevState.prevState); + } + return style; + } else { + state.tokenize = function(stream,state) { + return tokenPGMLComment(stream,state,string,style,prevState); + } + } + if (stream.match(/^[\]%`]/)) + return style; + stream.eatWhile(/[^\]%`]/); + return style; + } + return state.tokenize(stream,state); + } + + // PGML subblock which has limited formatting options compared to main block. + // This block nests {} and [] blocks, for correct pairing in variables and commands. + function tokenPGMLSubBlock(stream,state,string,style,prevState){ + state.tokenize = function(stream,state) { + var reg = new RegExp("^"+string); + if (stream.match(reg)) { + // Needed to ensure ': ' verbatim lines exit out if ended with a secondary subblock. + if (stream.eol() && prevState.subblock && prevState.prevState && prevState.prevState.stopeol) { + state.tokenize = function(stream,state) { + return tokenPGMLSubBlock(stream,state,prevState.prevState.string, + prevState.prevState.style,prevState.prevState.prevState); + } + } else if (stream.eol() && prevState.prevState && prevState.prevState.stopeol) { + state.tokenize = function(stream,state) { + return tokenPGML(stream,state,prevState.prevState.string, + prevState.prevState.style,prevState.prevState.prevState); + } + } else if (prevState.subblock){ + state.tokenize = function(stream,state) { + return tokenPGMLSubBlock(stream,state,prevState.string, + prevState.style,prevState.prevState); + } + } else { + state.tokenize = function(stream,state) { + return tokenPGML(stream,state,prevState.string, + prevState.style,prevState.prevState); + } + } + if (prevState.mode == "var" || prevState.mode == "cmd") + stream.match(/^\*{1,3}([^\*]|$)/); + if (prevState.mode == "calc") { + if (!stream.match(/^\*(\s|$)/)) + stream.match(/^\{.+\}/); + } + if (prevState.endstyle) + return prevState.endstyle; + return style; + } else { + state.tokenize = function(stream,state) { + return tokenPGMLSubBlock(stream,state,string,style,prevState); + } + } + + var newPrevState = {}; + if (prevState) { + newPrevState.prevState = JSON.parse(JSON.stringify(prevState)); + } else { + newPrevState.prevState = null; + } + newPrevState.style = style; + newPrevState.string = string; + newPrevState.subblock = true; + if (prevState.mode) + newPrevState.mode = prevState.mode; + + if (prevState.mode == "cmd") { // Some formatting for [@ ... @] blocks + if (stream.match(/^[$@%]\w+/)) { // $, @, % variables + if (PGvars.has(stream.current().substring(1))) + return PGstyle; + else + return "variable"; + } + if (stream.match(/^[$@%]{\w+}/)) { // ${foo}, @{foo}, %{foo} variables + if (PGvars.has(stream.current().slice(2,-1))) + return PGstyle; + else + return "variable"; + } + if (stream.match(/^[$@%]{/)) { // ${, @{, %{ nested blocks + style = "variable"; + state.tokenize = function(stream,state) { + return tokenPGMLSubBlock(stream,state,"\\}",style,newPrevState); + } + return style; + } + if (stream.match(/^\(/)) { // Nested ( ) blocks + newPrevState.endstyle = "variable"; + state.tokenize = function(stream,state) { + return tokenPGMLSubBlock(stream,state,"\\)",style,newPrevState); + } + return "variable"; + } + if (stream.match(/^\w+/)) { // Check for PG keywords + if (PGcmds.has(stream.current())) + return PGkeyword; + else + return style; + } + if (stream.match(/^['"]/)) { // Quotes + return tokenChain(stream,state,[stream.current()],"string",null,function(stream,state) { + tokenPGMLSubBlock(stream,state,string,style,prevState) }); + } + if (stream.match(/^[=,;/\*><%$&|.~?:]/)) // Catch some perl operators + return "variable"; + } + + if (stream.match(/^\[\$/)) { // Variable + const p = stream.pos; + if (stream.match(/^\w+/) && PGvars.has(stream.current().substring(2)) && stream.eat(']')) { + stream.match(/^\*{1,3}/); + return PGstyle; + } else { + stream.pos = p; + } + style = "variable"; + state.tokenize = function(stream,state) { + newPrevState.mode = "var"; + return tokenPGMLSubBlock(stream,state,"\\]",style,newPrevState); + } + } else if (stream.match(/^\[/)) { // Nested [ ] blocks + if (prevState.mode == "cmd") + newPrevState.endstyle = "variable"; + state.tokenize = function(stream,state) { + return tokenPGMLSubBlock(stream,state,"\\]",style,newPrevState); + } + if (prevState.mode == "cmd") + return "variable"; + } else if (stream.match(/^\{/)) { // Nested { } blocks + if (prevState.mode == "cmd") + newPrevState.endstyle = "variable"; + state.tokenize = function(stream,state) { + return tokenPGMLSubBlock(stream,state,"\\}",style,newPrevState); + } + if (prevState.mode == "cmd") + return "variable"; + } else if (stream.match(/^\w+\s*/)) { // Grab next word before going forward + return style; + } else { // Catchall to advanced one character if no match was found. + stream.eat(/./); + } + return style; + } + return state.tokenize(stream,state); + } + + // Main PGML block. Can nest to allow subblocks with PGML formatting in them. + function tokenPGML(stream,state,string,style,prevState){ + state.tokenize = function(stream,state) { + var reg = new RegExp("^"+string); + if (stream.match(reg)) { + if (!prevState) { + state.tokenize = tokenPerl; + return PGkeyword; + // Needed to ensure ': ' verbatim lines exit out if ended with a secondary block. + } else if (stream.eol() && prevState.prevState && prevState.prevState.stopeol) { + state.tokenize = function(stream,state) { + return tokenPGML(stream,state,prevState.prevState.string, + prevState.prevState.style,prevState.prevState.prevState); + } + } else { + state.tokenize = function(stream,state) { + return tokenPGML(stream,state,prevState.string, + prevState.style,prevState.prevState); + } + } + return style; + } else { + state.tokenize = function(stream,state) { + return tokenPGML(stream,state,string,style,prevState); + } + } + + var newPrevState = {}; + if (prevState) { + newPrevState.prevState = JSON.parse(JSON.stringify(prevState)); + } else { + newPrevState.prevState = null; + } + newPrevState.style = style; + newPrevState.string = string; + + if (stream.sol()) { + if (stream.match(/^ *(>> +)?[ivxlIVXL]+[.)] /) || + stream.match(/^ *(>> +)?\d+[.)] /) || + stream.match(/^ *(>> +)?\w[.)] /) || + stream.match(/^ *(>> +)?[*\-+o] /)) { // Lists + return "atom strong"; + } + if (stream.match(/^ *[\-=]{3,}/)) { // Rules + stream.match(/^\{[^}]*\}/); + stream.match(/^\{[^}]*\}/); + return "hr"; + } + if (stream.match(/^ *(>> +)?#{1,}.*$/)) // Headers + return "header"; + if (stream.match(/^ *>> /)) // Justification + return "atom strong"; + if (stream.match(/^ *: /)) { // Single line verbatim + style = "tag"; + state.tokenize = function(stream,state) { + newPrevState.stopeol = true; + return tokenPGML(stream,state,".$",style,newPrevState); + } + return style; + } + } + + if (stream.match(/^\[:{1,3}/)) { // Algebra notation math + style = "variable-3"; + const endstring = stream.current().substring(1) + "\\]"; + state.tokenize = function(stream,state) { + newPrevState.mode = "calc"; + return tokenPGMLSubBlock(stream,state,endstring,style,newPrevState); + } + } else if (stream.match(/^\[`{1,3}/)) { // TeX notation math + style = "comment"; + const endstring = stream.current().substring(1) + "\\]"; + state.tokenize = function(stream,state) { + newPrevState.mode = "tex"; + return tokenPGMLSubBlock(stream,state,endstring,style,newPrevState); + } + } else if (stream.match(/^\[\|+/)) { // Verbatim + style = "tag"; + const endstring = stream.current().substring(1) + "\\]"; + state.tokenize = function(stream,state) { + return tokenPGML(stream,state,endstring,style,newPrevState); + } + } else if (!prevState && stream.match(/^```/)) { // Multiline verbatim / code + style = "tag"; + state.tokenize = function(stream,state) { + return tokenPGMLComment(stream,state,"```",style,newPrevState); + } + } else if (stream.match(/^\[%/)) { // Comment + style = "bracket"; + state.tokenize = function(stream,state) { + return tokenPGMLComment(stream,state,"%\\]",style,newPrevState); + } + } else if (stream.match(/^\[@/)) { // Perl code + style = "variable-2"; + state.tokenize = function(stream,state) { + newPrevState.mode = "cmd"; + return tokenPGMLSubBlock(stream,state,"@\\]",style,newPrevState); + } + } else if (stream.match(/^\[\$/)) { // Variable + const p = stream.pos; + if (stream.match(/^[\w\d_]+/) && PGvars.has(stream.current().substring(2)) && stream.eat(']')) { + stream.match(/^\*{1,3}/); + return PGstyle; + } else { + stream.pos = p; + } + style = "variable"; + state.tokenize = function(stream,state) { + newPrevState.mode = "var"; + return tokenPGMLSubBlock(stream,state,"\\]",style,newPrevState); + } + } else if (stream.match(/^\[_+\]/)) { // Answer blank + if (stream.match(/^\*?\{/)) { + state.tokenize = function(stream,state) { + return tokenPGMLSubBlock(stream,state,"\\}","builtin",newPrevState); + } + } + return "builtin"; + } else if (stream.match(/<< *$/)) { // Justification + return "atom strong"; + } else if (stream.match(/^(\*_|_\*)\w/)) { // Bold and italic + style = style + " strong em"; + const endstring = (stream.current().charAt(1) + stream.current().charAt(0)).replace(/\*/, "\\*"); + state.tokenize = function(stream,state) { + return tokenPGML(stream,state,endstring,style,newPrevState); + } + } else if (stream.match(/^\*{1,3}\w/)) { // Bold + style = style + " strong"; + const endstring = stream.current().slice(0,-1).replace(/\*/g, "\\*"); + state.tokenize = function(stream,state) { + return tokenPGML(stream,state,endstring,style,newPrevState); + } + } else if (stream.match(/^_{1,3}\w/)) { // Italic + style = style + " em"; + const endstring = stream.current().slice(0,-1); + state.tokenize = function(stream,state) { + return tokenPGML(stream,state,endstring,style,newPrevState); + } + } else if (stream.match(/^ +$/)) { // Trailing whitespace + return "trailingspace"; + } else if (stream.match(/[A-Za-z0-9]+\s*/)) { // Grab next word before going forward + return style; + } else { // Catchall to advanced one character if no match was found. + stream.eat(/./); + } + return style; + }; + + return state.tokenize(stream,state); + } + + function tokenPerl(stream,state){ + if(stream.eatSpace()) + return null; + if(state.chain) + return tokenChain(stream,state,state.chain,state.style,state.tail); + if(stream.match(/^(\-?((\d[\d_]*)?\.\d+(e[+-]?\d+)?|\d+\.\d*)|0x[\da-fA-F_]+|0b[01_]+|\d[\d_]*(e[+-]?\d+)?)/)) + return 'number'; + if(stream.match(/^<<(?=[_a-zA-Z])/)){ // NOTE: <"],RXstyle,RXmodifiers);} + if(/[\^'"!~\/]/.test(c)){ + eatSuffix(stream, 1); + return tokenChain(stream,state,[stream.eat(c)],RXstyle,RXmodifiers);}} + else if(c=="q"){ + c=look(stream, 1); + if(c=="("){ + eatSuffix(stream, 2); + return tokenChain(stream,state,[")"],"string");} + if(c=="["){ + eatSuffix(stream, 2); + return tokenChain(stream,state,["]"],"string");} + if(c=="{"){ + eatSuffix(stream, 2); + return tokenChain(stream,state,["}"],"string");} + if(c=="<"){ + eatSuffix(stream, 2); + return tokenChain(stream,state,[">"],"string");} + if(/[\^'"!~\/]/.test(c)){ + eatSuffix(stream, 1); + return tokenChain(stream,state,[stream.eat(c)],"string");}} + else if(c=="w"){ + c=look(stream, 1); + if(c=="("){ + eatSuffix(stream, 2); + return tokenChain(stream,state,[")"],"bracket");} + if(c=="["){ + eatSuffix(stream, 2); + return tokenChain(stream,state,["]"],"bracket");} + if(c=="{"){ + eatSuffix(stream, 2); + return tokenChain(stream,state,["}"],"bracket");} + if(c=="<"){ + eatSuffix(stream, 2); + return tokenChain(stream,state,[">"],"bracket");} + if(/[\^'"!~\/]/.test(c)){ + eatSuffix(stream, 1); + return tokenChain(stream,state,[stream.eat(c)],"bracket");}} + else if(c=="r"){ + c=look(stream, 1); + if(c=="("){ + eatSuffix(stream, 2); + return tokenChain(stream,state,[")"],RXstyle,RXmodifiers);} + if(c=="["){ + eatSuffix(stream, 2); + return tokenChain(stream,state,["]"],RXstyle,RXmodifiers);} + if(c=="{"){ + eatSuffix(stream, 2); + return tokenChain(stream,state,["}"],RXstyle,RXmodifiers);} + if(c=="<"){ + eatSuffix(stream, 2); + return tokenChain(stream,state,[">"],RXstyle,RXmodifiers);} + if(/[\^'"!~\/]/.test(c)){ + eatSuffix(stream, 1); + return tokenChain(stream,state,[stream.eat(c)],RXstyle,RXmodifiers);}} + else if(/[\^'"!~\/(\[{<]/.test(c)){ + if(c=="("){ + eatSuffix(stream, 1); + return tokenChain(stream,state,[")"],"string");} + if(c=="["){ + eatSuffix(stream, 1); + return tokenChain(stream,state,["]"],"string");} + if(c=="{"){ + eatSuffix(stream, 1); + return tokenChain(stream,state,["}"],"string");} + if(c=="<"){ + eatSuffix(stream, 1); + return tokenChain(stream,state,[">"],"string");} + if(/[\^'"!~\/]/.test(c)){ + return tokenChain(stream,state,[stream.eat(c)],"string");}}}} + if(ch=="m"){ + var c=look(stream, -2); + if(!(c&&/\w/.test(c))){ + c=stream.eat(/[(\[{<\^'"!~\/]/); + if(c){ + if(/[\^'"!~\/]/.test(c)){ + return tokenChain(stream,state,[c],RXstyle,RXmodifiers);} + if(c=="("){ + return tokenChain(stream,state,[")"],RXstyle,RXmodifiers);} + if(c=="["){ + return tokenChain(stream,state,["]"],RXstyle,RXmodifiers);} + if(c=="{"){ + return tokenChain(stream,state,["}"],RXstyle,RXmodifiers);} + if(c=="<"){ + return tokenChain(stream,state,[">"],RXstyle,RXmodifiers);}}}} + if(ch=="s"){ + var c=/[\/>\]})\w]/.test(look(stream, -2)); + if(!c){ + c=stream.eat(/[(\[{<\^'"!~\/]/); + if(c){ + if(c=="[") + return tokenChain(stream,state,["]","]"],RXstyle,RXmodifiers); + if(c=="{") + return tokenChain(stream,state,["}","}"],RXstyle,RXmodifiers); + if(c=="<") + return tokenChain(stream,state,[">",">"],RXstyle,RXmodifiers); + if(c=="(") + return tokenChain(stream,state,[")",")"],RXstyle,RXmodifiers); + return tokenChain(stream,state,[c,c],RXstyle,RXmodifiers);}}} + if(ch=="y"){ + var c=/[\/>\]})\w]/.test(look(stream, -2)); + if(!c){ + c=stream.eat(/[(\[{<\^'"!~\/]/); + if(c){ + if(c=="[") + return tokenChain(stream,state,["]","]"],RXstyle,RXmodifiers); + if(c=="{") + return tokenChain(stream,state,["}","}"],RXstyle,RXmodifiers); + if(c=="<") + return tokenChain(stream,state,[">",">"],RXstyle,RXmodifiers); + if(c=="(") + return tokenChain(stream,state,[")",")"],RXstyle,RXmodifiers); + return tokenChain(stream,state,[c,c],RXstyle,RXmodifiers);}}} + if(ch=="t"){ + var c=/[\/>\]})\w]/.test(look(stream, -2)); + if(!c){ + c=stream.eat("r");if(c){ + c=stream.eat(/[(\[{<\^'"!~\/]/); + if(c){ + if(c=="[") + return tokenChain(stream,state,["]","]"],RXstyle,RXmodifiers); + if(c=="{") + return tokenChain(stream,state,["}","}"],RXstyle,RXmodifiers); + if(c=="<") + return tokenChain(stream,state,[">",">"],RXstyle,RXmodifiers); + if(c=="(") + return tokenChain(stream,state,[")",")"],RXstyle,RXmodifiers); + return tokenChain(stream,state,[c,c],RXstyle,RXmodifiers);}}}} + if(ch=="`"){ + return tokenChain(stream,state,[ch],"variable-2");} + if(ch=="/"){ + if(!/~\s*$/.test(prefix(stream))) + return "operator"; + else + return tokenChain(stream,state,[ch],RXstyle,RXmodifiers);} + if(ch=="$"){ + var p=stream.pos; + if(stream.eatWhile(/\w/)&&PGvars.has(stream.current().substring(1))) + return PGstyle; + else + stream.pos=p; + if(stream.eatWhile(/\d/)||stream.eat("{")&&stream.eatWhile(/\d/)&&stream.eat("}")) + return "variable-2"; + else + stream.pos=p;} + if(/[$@%]/.test(ch)){ + var p=stream.pos; + if(stream.eat("^")&&stream.eat(/[A-Z]/)||!/[@$%&]/.test(look(stream, -2))&&stream.eat(/[=|\\\-#?@;:&`~\^!\[\]*'"$+.,\/<>()]/)){ + var c=stream.current(); + if(PERL[c]) + return "variable-2";} + stream.pos=p;} + if(/[$@%&]/.test(ch)){ + if(stream.eatWhile(/[\w$]/)||stream.eat("{")&&stream.eatWhile(/[\w$]/)&&stream.eat("}")){ + var c=stream.current(); + if(PERL[c]) + return "variable-2"; + else + return "variable";}} + if(ch=="#"){ + if(look(stream, -2)!="$"){ + stream.skipToEnd(); + return "comment";}} + if(ch=="-"&&look(stream, -2)!=" "&&stream.match(/>\w+/)) + return "variable"; + if(/[:+\-\^*$&%@=<>!?|\/~\.]/.test(ch)){ + var p=stream.pos; + stream.eatWhile(/[:+\-\^*$&%@=<>!?|\/~\.]/); + if(PERL[stream.current()]) + return "operator"; + else + stream.pos=p;} + if(ch=="_"){ + if(stream.pos==1){ + if(suffix(stream, 6)=="_END__"){ + return tokenChain(stream,state,['\0'],"comment");} + else if(suffix(stream, 7)=="_DATA__"){ + return tokenChain(stream,state,['\0'],"variable-2");} + else if(suffix(stream, 7)=="_C__"){ + return tokenChain(stream,state,['\0'],"string");}}} + if(/\w/.test(ch)){ + var p=stream.pos; + if(look(stream, -2)=="{"&&(look(stream, 0)=="}"||stream.eatWhile(/\w/)&&look(stream, 0)=="}")) + return "string"; + else + stream.pos=p; + if(stream.match(/\w* *=>/)) + return "string";} + if(/[A-Z]/.test(ch)){ + var l=look(stream, -2); + var p=stream.pos; + stream.eatWhile(/[A-Z_]/); + if(/[\da-z]/.test(look(stream, 0))){ + stream.pos=p;} + else{ + var c=PERL[stream.current()]; + var isPG = PGcmds.has(stream.current()); + if(!c && !isPG) + return "meta"; + if(isPG) + return PGkeyword; + if(c[1]) + c=c[0]; + if(l!=":"){ + if(c==1) + return "keyword"; + else if(c==2) + return "def"; + else if(c==3) + return "atom"; + else if(c==4) + return "operator"; + else if(c==5) + return "variable-2"; + else + return "meta";} + else + return "meta";}} + if(/[a-zA-Z_]/.test(ch)){ + var l=look(stream, -2); + stream.eatWhile(/\w/); + var c=PERL[stream.current()]; + var isPG = PGcmds.has(stream.current()); + if(!c && !isPG) + return "meta"; + if(isPG) + return PGkeyword; + if(c[1]) + c=c[0]; + if(l!=":"){ + if(c==1) + return "keyword"; + else if(c==2) + return "def"; + else if(c==3) + return "atom"; + else if(c==4) + return "operator"; + else if(c==5) + return "variable-2"; + else + return "meta";} + else + return "meta";} + return null;} + + return { + startState: function() { + return { + tokenize: tokenPerl, + chain: null, + style: null, + tail: null + }; + }, + token: function(stream, state) { + return (state.tokenize || tokenPerl)(stream, state); + }, + lineComment: '#' + }; + }); + + CodeMirror.registerHelper("wordChars", "perl", /[\w$]/); + + CodeMirror.defineMIME("text/x-perl", "perl"); + + // it's like "peek", but need for look-ahead or look-behind if index < 0 + function look(stream, c){ + return stream.string.charAt(stream.pos+(c||0)); + } + + // return a part of prefix of current stream from current position + function prefix(stream, c){ + if(c){ + var x=stream.pos-c; + return stream.string.substr((x>=0?x:0),c);} + else{ + return stream.string.substr(0,stream.pos-1); + } + } + + // return a part of suffix of current stream from current position + function suffix(stream, c){ + var y=stream.string.length; + var x=y-stream.pos+1; + return stream.string.substr(stream.pos,(c&&c=(y=stream.string.length-1)) + stream.pos=y; + else + stream.pos=x; + } + +})(); diff --git a/public/js/apps/PGCodeMirror/pgeditor.js b/public/js/apps/PGCodeMirror/pgeditor.js new file mode 100644 index 000000000..10d8573c8 --- /dev/null +++ b/public/js/apps/PGCodeMirror/pgeditor.js @@ -0,0 +1,123 @@ +/* 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. + */ + +(async function () { + if (!CodeMirror) return; + + const loadResource = async (src) => { + return new Promise((resolve, reject) => { + let shouldAppend = false; + let el; + if (/\.js(?:\?[0-9a-zA-Z=^.]*)?$/.exec(src)) { + el = document.querySelector(`script[src="${src}"]`); + if (!el) { + el = document.createElement('script'); + el.async = false; + el.src = src; + shouldAppend = true; + } + } else if (/\.css(?:\?[0-9a-zA-Z=^.]*)?$/.exec(src)) { + el = document.querySelector(`link[href="${src}"]`); + if (!el) { + el = document.createElement('link'); + el.rel = 'stylesheet'; + el.href = src; + shouldAppend = true; + } + } else { + reject(); + return; + } + + if (el.dataset.loaded) { + resolve(); + return; + } + + el.addEventListener('error', reject); + el.addEventListener('abort', reject); + el.addEventListener('load', () => { + if (el) el.dataset.loaded = 'true'; + resolve(); + }); + + if (shouldAppend) document.head.appendChild(el); + }); + }; + + const loadConfig = async (file) => { + const configName = [...file.matchAll(/.*\/([^.]*?)(?:\.min)?\.(?:js|css)(?:\?[0-9a-zA-Z=^.]*)?$/g)][0]?.[1] + ?? 'default'; + if (configName !== 'default') { + try { + await loadResource(file); + } catch { + return 'default'; + } + } + return configName; + }; + + const cm = webworkConfig.pgCodeMirror = CodeMirror.fromTextArea(document.querySelector('.codeMirrorEditor'), { + mode: document.querySelector('.codeMirrorEditor')?.dataset.mode ?? 'PG', + indentUnit: 4, + tabMode: 'spaces', + lineNumbers: true, + lineWrapping: true, + extraKeys: { Tab: (cm) => cm.execCommand('insertSoftTab') }, + highlightSelectionMatches: { annotateScrollbar: true }, + matchBrackets: true, + inputStyle: 'contenteditable', + spellcheck: localStorage.getItem('WW_PGEditor_spellcheck') === 'true', + }); + cm.setSize('100%', '550px'); + + const currentThemeFile = localStorage.getItem('WW_PGEditor_selected_theme') ?? 'default'; + const currentThemeName = await loadConfig(currentThemeFile); + cm.setOption('theme', currentThemeName); + + const currentKeymapFile = localStorage.getItem('WW_PGEditor_selected_keymap') ?? 'default'; + const currentKeymapName = await loadConfig(currentKeymapFile); + cm.setOption('keyMap', currentKeymapName); + + const selectTheme = document.getElementById('selectTheme'); + selectTheme.value = currentThemeName === 'default' ? 'default' : currentThemeFile; + selectTheme.addEventListener('change', async () => { + const themeName = await loadConfig(selectTheme.value); + cm.setOption('theme', themeName); + localStorage.setItem('WW_PGEditor_selected_theme', themeName === 'default' ? 'default' : selectTheme.value); + }); + + const selectKeymap = document.getElementById('selectKeymap'); + selectKeymap.value = currentKeymapName === 'default' ? 'default' : currentKeymapFile; + selectKeymap.addEventListener('change', async () => { + const keymapName = await loadConfig(selectKeymap.value); + cm.setOption('keyMap', keymapName); + localStorage.setItem('WW_PGEditor_selected_keymap', + keymapName === 'default' ? 'default' : selectKeymap.value); + }); + + const enableSpell = document.getElementById('enableSpell'); + enableSpell.checked = localStorage.getItem('WW_PGEditor_spellcheck') === 'true'; + enableSpell.addEventListener('change', () => { + cm.setOption('spellcheck', enableSpell.checked); + localStorage.setItem('WW_PGEditor_spellcheck', enableSpell.checked); + cm.focus(); + }); + + const forceRTL = document.getElementById('forceRTL'); + forceRTL.addEventListener('change', () => { + cm.setOption('direction', forceRTL.checked ? 'rtl' : 'ltr'); + }); +})(); diff --git a/public/js/apps/PGCodeMirror/pgeditor.scss b/public/js/apps/PGCodeMirror/pgeditor.scss new file mode 100644 index 000000000..ea12c09fa --- /dev/null +++ b/public/js/apps/PGCodeMirror/pgeditor.scss @@ -0,0 +1,64 @@ +/* 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. + */ + +.CodeMirror { + border: 1px solid #ddd; + min-height: 400px; + resize: vertical; +} + +// This style is only used if the CodeMirror editor is disabled in localOverrides.conf. +.codeMirrorEditor { + border: 1px solid #ddd; + padding: 2px; + height: 550px; + min-height: 400px; + width: 100%; + resize: vertical; +} + +// Additional CSS for codemirror addons and overrides + +// CodeMirror overrides +.CodeMirror-code { + outline: none; +} + +pre.CodeMirror-line { + unicode-bidi: embed; +} + +// Match Highligher CSS +.CodeMirror-focused { + .cm-matchhighlight { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFklEQVQI12NgYGBgkKzc8x9CMDAwAAAmhwSbidEoSQAAAABJRU5ErkJggg==); + background-position: bottom; + background-repeat: repeat-x; + } +} + +.cm-matchhighlight { + background-color: lightgreen; +} + +.CodeMirror-selection-highlight-scrollbar { + background-color: green; +} + +// CSS to highlight trailing whitespace in PGML blocks +.cm-trailingspace { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAACCAYAAAB/qH1jAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3QUXCToH00Y1UgAAACFJREFUCNdjPMDBUc/AwNDAAAFMTAwMDA0OP34wQgX/AQBYgwYEx4f9lQAAAABJRU5ErkJggg==); + background-position: bottom left; + background-repeat: repeat-x; +} diff --git a/public/Problem/problem.js b/public/js/apps/Problem/problem.js similarity index 100% rename from public/Problem/problem.js rename to public/js/apps/Problem/problem.js diff --git a/public/Problem/submithelper.js b/public/js/apps/Problem/submithelper.js similarity index 100% rename from public/Problem/submithelper.js rename to public/js/apps/Problem/submithelper.js diff --git a/public/package.json b/public/package.json new file mode 100644 index 000000000..aa0b34c35 --- /dev/null +++ b/public/package.json @@ -0,0 +1,43 @@ +{ + "name": "renderer.javascript_package_manager", + "description": "Third party javascript for Standalone Renderer", + "license": "GPL-2.0+", + "scripts": { + "generate-assets": "node generate-assets", + "prepare": "npm run generate-assets" + }, + "repository": { + "type": "git", + "url": "https://github.com/openwebwork/renderer" + }, + "dependencies": { + "@fortawesome/fontawesome-free": "^6.2.1", + "bootstrap": "~5.2.3", + "codemirror": "^5.65.11", + "iframe-resizer": "^4.3.2", + "jquery": "^3.6.3", + "jquery-ui-dist": "^1.13.2", + "mathjax": "^3.2.2" + }, + "devDependencies": { + "autoprefixer": "^10.4.13", + "chokidar": "^3.5.3", + "cssnano": "^6.0.0", + "postcss": "^8.4.21", + "rtlcss": "^4.0.0", + "sass": "^1.57.1", + "terser": "^5.16.1", + "yargs": "^17.6.2" + }, + "browserslist": [ + "last 10 Chrome versions", + "last 10 Firefox versions", + "last 4 Edge versions", + "last 7 Safari versions", + "last 8 Android versions", + "last 8 ChromeAndroid versions", + "last 8 FirefoxAndroid versions", + "last 10 iOS versions", + "last 5 Opera versions" + ] +} diff --git a/templates/RPCRenderFormats/default.html.ep b/templates/RPCRenderFormats/default.html.ep new file mode 100644 index 000000000..9a58ec1d6 --- /dev/null +++ b/templates/RPCRenderFormats/default.html.ep @@ -0,0 +1,144 @@ +% use WeBWorK::Utils qw(getAssetURL wwRound); +% + +> + + + + + WeBWorK using host: <%= $SITE_URL %>, + format: <%= $formatName %>, + + " rel="shortcut icon"> + % # Add third party css and javascript as well as css and javascript requested by the problem. + % for (@$third_party_css) { + %= stylesheet $_ + % } + % for (@$extra_css_files) { + %= stylesheet $_->{file} + % } + % for (@$third_party_js) { + %= javascript $_->[0], %{ $_->[1] // {} } + % } + % for (@$extra_js_files) { + %= javascript $_->{file}, %{ $_->{attributes} } + % } + %== $rh_result->{header_text} // '' + %== $rh_result->{post_header_text} // '' + %== $extra_header_text + + +
+
+
+ %== $answerTemplate + <%= form_for $FORM_ACTION_URL, id => 'problemMainForm', class => 'problem-main-form', + name => 'problemMainForm', method => 'POST', begin %> +
> + %== $problemText +
+ % if ($showScoreSummary) { +

<%= $lh->maketext('You received a score of [_1] for this attempt.', + wwRound(0, $rh_result->{problem_result}{score} * 100) . '%') %>

+ % if ($rh_result->{problem_result}{msg}) { +

<%= $rh_result->{problem_result}{msg} %>

+ % } + <%= hidden_field 'problem-result-score' => $rh_result->{problem_result}{score}, + id => 'problem-result-score' %> + % } + %= hidden_field sessionJWT => $rh_result->{sessionJWT} + % if ($rh_result->{JWTanswerURLstatus}) { + %= hidden_field JWTanswerURLstatus => $rh_result->{JWTanswerURLstatus} + % } + % if ($formatName eq 'debug' && $rh_result->{inputs_ref}{clientDebug}) { + %= hidden_field clientDebug => $rh_result->{inputs_ref}{clientDebug} + % } + % if ($formatName ne 'static') { +
+ % # Submit buttons (all are shown by default) + % if ($showPreviewButton ne '0') { + <%= submit_button $lh->maketext('Preview My Answers'), + name => 'previewAnswers', id => 'previewAnswers_id', class => 'btn btn-primary mb-1' %> + % } + % if ($showCheckAnswersButton ne '0') { + <%= submit_button $lh->maketext('Submit Answers'), + name => 'submitAnswers', class => 'btn btn-primary mb-1' %> + % } + % if ($showCorrectAnswersButton ne '0') { + <%= submit_button $lh->maketext('Show Correct Answers'), + name => 'showCorrectAnswers', class => 'btn btn-primary mb-1' %> + % } +
+ % } + % end +
+
+ % # PG warning messages (this includes translator warnings but not translator errors). + % if ($rh_result->{pg_warnings}) { +
+

<%= $lh->maketext('Warning messages') %>

+
    + % for (split("\n", $rh_result->{pg_warnings})) { +
  • <%== $_ %>
  • + % } +
+
+ % } + % # PG warning messages generated with WARN_message. + % if (ref $rh_result->{warning_messages} eq 'ARRAY' && @{ $rh_result->{warning_messages} }) { +
+

<%= $lh->maketext('PG warning messages') %>

+
    + % for (@{ $rh_result->{warning_messages} }) { +
  • <%== $_ %>
  • + % } +
+
+ % } + % # Translator errors. + % if ($rh_result->{flags}{error_flag}) { +
+

Translator errors

+ <%== $rh_result->{errors} %> +
+ % } + % # Additional information output only for the debug format. + % if ($formatName eq 'debug') { + % # PG debug messages generated with DEBUG_message. + % if (@{ $rh_result->{debug_messages} }) { +
+

PG debug messages

+
    + % for (@{ $rh_result->{debug_messages} }) { +
  • <%== $_ %>
  • + % } +
+
+ % } + % # Internal debug messages generated within PGcore. + % if (ref $rh_result->{internal_debug_messages} eq 'ARRAY' && @{ $rh_result->{internal_debug_messages} }) { +
+

Internal errors

+
    + % for (@{ $rh_result->{internal_debug_messages} }) { +
  • <%== $_ %>
  • + % } +
+
+ % } + % if ($rh_result->{inputs_ref}{clientDebug}) { +

Webwork client data

+ %== $pretty_print->($rh_result) + % } + % } +
+ % # Show the footer unless it is explicity disabled. + % if ($showFooter ne '0') { + + % } + + diff --git a/templates/RPCRenderFormats/default.json.ep b/templates/RPCRenderFormats/default.json.ep new file mode 100644 index 000000000..1c10ff170 --- /dev/null +++ b/templates/RPCRenderFormats/default.json.ep @@ -0,0 +1,88 @@ +% use Mojo::JSON qw(to_json); +% use WeBWorK::Utils qw(wwRound); +% +% my $json_output = { + % head_part001 => "", + % head_part010 => q{} + % . qq{}, + % head_part300 => join('', + % (map { stylesheet($_) } @$third_party_css), + % (map { stylesheet($_->{file}) } @$extra_css_files), + % (map { javascript($_->[0], %{ $_->[1] // {} }) } @$third_party_js), + % (map { javascript($_->{file}, %{ $_->{attributes} }) } @$extra_js_files), + % $rh_result->{header_text} // '', + % $rh_result->{post_header_text} // '', + % $extra_header_text + % ), + % head_part400 => 'WeBWorK problem', + % head_part999 => '', + % + % body_part001 => '', + % body_part100 => '
', + % body_part300 => $answerTemplate, + % body_part500 => '
', + % body_part530 => qq{
}, + % body_part550 => $problemText, + % body_part590 => '
', + % body_part650 => '

' . $lh->maketext('You received a score of [_1] for this attempt.', + % wwRound(0, $rh_result->{problem_result}{score} * 100) . '%') . '

' + % . ($rh_result->{problem_result}{msg} ? ('

' . $rh_result->{problem_result}{msg} . '

') : '') + % . ($ce->{hideWasNotRecordedMessage} ? '' : '

' . $lh->maketext('Your score was not recorded.') . '

') + % . hidden_field('problem-result-score' => $rh_result->{problem_result}{score}, + % id => 'problem-result-score'), + % body_part700 => join('', '

', + % $showPreviewButton eq '0' ? '' : submit_button($lh->maketext('Preview My Answers'), + % name => 'preview', id => 'previewAnswers_id', class => 'btn btn-primary mb-1'), + % $showCheckAnswersButton eq '0' ? '' : submit_button($lh->maketext('Check Answers'), + % name => 'WWsubmit', class => 'btn btn-primary mb-1'), + % $showCorrectAnswersButton eq '0' ? '' : submit_button($lh->maketext('Show Correct Answers'), + % name => 'WWcorrectAns', class => 'btn btn-primary mb-1'), + % '

'), + % body_part999 => '
' + % . ($showFooter eq '0' ? '' + % : qq{") + % . '}', + % + % hidden_input_field => { + % answersSubmitted => '1', + % sourceFilePath => $sourceFilePath, + % problemSource => $problemSource, + % problemSeed => $problemSeed, + % problemUUID => $problemUUID, + % psvn => $psvn, + % pathToProblemFile => $fileName, + % courseID => $courseID, + % user => $user, + % passwd => $passwd, + % displayMode => $displayMode, + % key => $key, + % outputformat => 'json', + % theme => $theme, + % language => $formLanguage, + % showSummary => $showSummary, + % showHints => $showHints, + % showSolutions => $showSolutions, + % showAnswerNumbers => $showAnswerNumbers, + % showPreviewButton => $showPreviewButton, + % showCheckAnswersButton => $showCheckAnswersButton, + % showCorrectAnswersButton => $showCorrectAnswersButton, + % showFooter => $showFooter, + % extraHeaderText => $extra_header_text + % }, + % + % # Add the current score to the json output + % score => $ws->{inputs_ref}{WWsubmit} && $rh_result->{problem_result} + % ? wwRound(0, $rh_result->{problem_result}{score} * 100) + % : 0, + % + % # These are the real WeBWorK server URLs which the intermediate needs to use + % # to communicate with WW, while the distant client must use URLs of the + % # intermediate server (the man in the middle). + % real_webwork_SITE_URL => $SITE_URL, + % real_webwork_FORM_ACTION_URL => $FORM_ACTION_URL, + % internal_problem_lang_and_dir => $PROBLEM_LANG_AND_DIR +% }; +% +%== to_json($json_output) diff --git a/templates/RPCRenderFormats/ptx.html.ep b/templates/RPCRenderFormats/ptx.html.ep new file mode 100644 index 000000000..c950e4a70 --- /dev/null +++ b/templates/RPCRenderFormats/ptx.html.ep @@ -0,0 +1,7 @@ + + + +%== $problemText +%== $answerhashXML + +